diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..da6ff1c87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# Shaders: the committed .qsb are binary (NUL in the header) but also carry +# raw CR bytes; a future `* text` would mangle them. Text formats are LF so a +# Windows checkout with autocrlf cannot turn `2.1.0` into `2.1.0` in a +# version grep, or CRLF the GLSL/QML sources. +*.qsb binary +*.frag text eol=lf +*.vert text eol=lf +*.qml text eol=lf +*.toml text eol=lf +*.po text eol=lf +*.sh text eol=lf +*.ico binary +# Not in the original plan list, added deliberately: .rs is the largest text +# set in the tree (639 files) and a CRLF checkout of it is the same class of +# invisible breakage the .toml line exists to prevent. +*.rs text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 08edabcc0..f46c34d9c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,33 +1,34 @@ name: Bug report -description: Report something that is not working as expected -title: "[Bug] " +description: Tell us about something that is not working as expected labels: ["bug"] body: - type: markdown attributes: value: | - ## ⚠️ Bug reports need logs - Without logs I am debugging blind, and the report will almost certainly - stall waiting on `needs: info` instead of getting fixed. + Thanks for reporting this. Please describe what happened and, if + possible, how to reproduce it. Logs help with most reports, but they + are not required when the issue is self-explanatory or unavailable. - **Please attach your logs in the Logs section below** — it is the single - most useful thing you can do to get this fixed fast - (**Settings → Developer Mode → View Logs → Upload** the Terminal *and* - Console tabs, then paste both URLs). If the app crashes before the UI - opens, use the terminal fallback described in that section. + If you investigated the cause, please include what you found. Patches + are welcome, but never required. - - type: checkboxes - id: preflight + - type: dropdown + id: area attributes: - label: Before submitting - description: A bug report without these is much harder to act on. + label: Area + description: Which part of QBZ is affected? options: - - label: I have attached my logs below (Terminal + Console), or explained why I cannot get them. - required: true - - label: I searched the existing issues and this is not a duplicate. - required: true - - label: I included concrete steps to reproduce. - required: true + - Startup or crash + - Playback or audio + - Qobuz login or catalog + - Local or offline library + - Qobuz Connect or casting + - Interface or navigation + - qbzd or headless use + - Installation, update or packaging + - Other + validations: + required: true - type: dropdown id: platform @@ -36,41 +37,29 @@ body: options: - Linux - macOS + - Windows + - Other validations: required: true - type: textarea id: description attributes: - label: Description - description: A clear and concise description of the problem. + label: What happened? + description: Tell us what you observed and what you expected instead. + placeholder: Describe the problem in your own words. validations: required: true - type: textarea id: steps attributes: - label: Steps to reproduce - value: | - 1. - 2. - 3. - validations: - required: true - - - type: textarea - id: expected - attributes: - label: Expected behavior - description: What you expected to happen. - validations: - required: true - - - type: textarea - id: actual - attributes: - label: Actual behavior - description: What actually happened. + label: How can we reproduce it? + description: If it is intermittent, describe what you were doing before it appeared. + placeholder: | + 1. Open... + 2. Select... + 3. Observe... validations: required: true @@ -78,7 +67,8 @@ body: id: version attributes: label: QBZ version - placeholder: e.g. v1.2.0 + description: Find it under About, or include the package version. + placeholder: e.g. 2.1.0 validations: required: true @@ -88,94 +78,51 @@ body: label: Installation method options: - AppImage - - .deb - - .rpm + - APT repository or .deb + - RPM repository or .rpm - AUR - Gentoo overlay - - Tarball + - Nix or NixOS - Flatpak - - DMG (macOS) + - Snap + - Homebrew Cask + - Signed macOS DMG + - Upstream macOS DMG + - Windows MSI or ZIP + - Standalone qbzd tarball - Built from source + - Other validations: required: true - - type: input - id: distro + - type: textarea + id: environment attributes: - label: Distribution / macOS version - description: Linux distro or macOS version. - placeholder: e.g. Arch Linux, Ubuntu 24.04, Fedora 43, macOS 15.3 Sequoia - - - type: input - id: de - attributes: - label: Desktop environment / WM - description: Linux only. - placeholder: e.g. KDE Plasma 6, GNOME 46 - - - type: dropdown - id: display - attributes: - label: Display server - description: Linux only. - options: - - Wayland - - X11 - - N/A (macOS) - - - type: input - id: gpu - attributes: - label: GPU - placeholder: e.g. NVIDIA RTX 3070, AMD RX 6600 XT, Apple M3 - - - type: dropdown - id: audio_backend - attributes: - label: Audio backend - options: - - PipeWire - - ALSA Direct - - PulseAudio - - System Default (macOS) - - Auto - - - type: input - id: dac - attributes: - label: Audio device / DAC - placeholder: e.g. Schiit Bifrost 2, FiiO K17 + label: Environment details + description: Add only what seems relevant to this report. + placeholder: | + OS or distribution: + Desktop environment / display server: + Audio backend / DAC: + GPU: - type: textarea id: logs attributes: label: Logs description: | - **Sharing your logs when you open an issue helps me fix it much faster — please include them whenever you can.** + Logs make most bugs much faster to diagnose and often reveal whether + the cause is QBZ, packaging, configuration or the surrounding system. - 1. Open **Settings > Developer Mode > View Logs** - 2. Click **Upload** on both the **Terminal** and **Console** tabs - 3. Paste both URLs here - - Where to find the logs (click the image to enlarge): - - [![Settings > Developer Mode > View Logs](https://raw.githubusercontent.com/vicrodh/qbz/main/docs/release-1.1.15/screenshots/log_access_for_bug_reporting.png)](https://raw.githubusercontent.com/vicrodh/qbz/main/docs/release-1.1.15/screenshots/log_access_for_bug_reporting.png) - - **If QBZ crashes on startup**, run from terminal: - `flatpak run com.blitzfc.qbz 2>&1 | tee /tmp/qbz-crash.log` - Then upload: `curl -F'file=@/tmp/qbz-crash.log' https://0x0.st` - value: | - - Terminal log URL: - - Console log URL: + If they are relevant and available, open Settings > Developer Mode > + View Logs, upload both Terminal and Console, and paste both links here. + For startup failures, terminal output is enough. + placeholder: | + Terminal log URL: + Console log URL: - type: textarea - id: screenshots + id: attachments attributes: - label: Screenshots - description: If applicable, add screenshots to help illustrate the issue. - - - type: textarea - id: context - attributes: - label: Additional context - description: Anything else that may help reproduce or understand the issue. + label: Screenshots or additional context + description: Drag in images or files, or add anything else that may help. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..2817073b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and troubleshooting + url: https://github.com/vicrodh/qbz/discussions + about: Ask for help or discuss QBZ without opening a bug report. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 29cd8c16c..9480d1559 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,5 @@ name: Feature request description: Suggest an idea or improvement -title: "[Feature] " labels: ["enhancement"] body: - type: dropdown @@ -12,6 +11,7 @@ body: - All platforms - Linux - macOS + - Windows validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feedback.yml b/.github/ISSUE_TEMPLATE/feedback.yml index ee87ec3bc..e8d62d145 100644 --- a/.github/ISSUE_TEMPLATE/feedback.yml +++ b/.github/ISSUE_TEMPLATE/feedback.yml @@ -1,6 +1,5 @@ name: Feedback description: General feedback, UX thoughts, or non-bug discussion -title: "[Feedback] " labels: ["feedback"] body: - type: dropdown @@ -11,6 +10,7 @@ body: - "" - Linux - macOS + - Windows validations: required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e62b8809e..990770937 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,10 +2,40 @@ +## Context and approach + + + +## Validation + + + +## AI / LLM assistance + + + +- **Models and roles:** +- **Human verification:** + ## Checklist -- [ ] My change targets the `crates/` workspace. The Svelte `src/` and Tauri - `src-tauri/` trees were **removed in 2.0.2** (preserved only at git tag - `legacy-tauri-svelte` for reference); PRs against those paths can't be - merged. If your fix targets the old tree, open an issue instead and we'll - help you port it. +- [ ] This PR targets `pre-release`, not `main`. +- [ ] I reviewed the complete diff and described how I verified the change. +- [ ] My change targets the live Qt/Rust tree. The Slint crates and the deleted + Svelte/Tauri trees are historical references and cannot accept fixes. +- [ ] If I changed UI text, I updated all eight locales. diff --git a/.github/workflows/build-qt-linux.yml b/.github/workflows/build-qt-linux.yml new file mode 100644 index 000000000..b030258d7 --- /dev/null +++ b/.github/workflows/build-qt-linux.yml @@ -0,0 +1,283 @@ +# Linux build of the Qt frontend — ONE recipe, reused by every Linux release +# workflow (release-linux, release-linux-aarch64, release-snap, release-flatpak) +# via `workflow_call`, and dispatchable on its own as the pre-flight ("does the +# release recipe still build; publish nothing") — the job build-slint.yml used +# to do. The 2026-07-04 lesson was two copies of the same recipe drifting apart; +# this file exists so there is exactly one. +# +# WHAT CHANGED VS THE SLINT ERA (all measured 2026-08-25, see +# qbz-nix-docs/cicd/2026-08-25-qt-workflows-contracts/): +# * No memory wall. `-p qbz-qt` never pulls the 30 GB `qbz-ui` unit: no swap +# ceremony, no CARGO_BUILD_JOBS=1 / codegen-units=256 / opt-level 2 (those +# were memory tiers and cost runtime performance for nothing now), no +# nightly, no mold, no RUSTFLAGS (any of them re-keys the whole cache). +# * Qt comes from aqt (official binaries), NOT from apt: ubuntu-24.04 ships +# Qt 6.4.2 and has no QtQuick.Effects module at all; the tree needs 6.8. +# * The runner is ubuntu-22.04 (glibc 2.35 floor, asserted below), which is +# also the platform the official Qt binaries are built for. +# * Two outputs per arch: the BARE binary (AUR / Gentoo / Flathub — ecosystems +# with their own Qt >= 6.8) and an AppDir with Qt bundled (deb / rpm / +# AppImage / snap — so Ubuntu 24.04 keeps working). The AppDir is built by +# linuxdeploy + linuxdeploy-plugin-qt and is self-contained (proven by +# running it offscreen with the Qt install removed). +# * The smoke gate runs against THE ARTIFACT THAT SHIPS (the AppDir), not the +# build tree: offscreen boot, zero QML complaints, core initialized. A +# `cargo check` sees nothing of the QML — a missing bridge member compiles +# clean and throws when the user opens the view. +# +# CONTRACT: artifact names are consumed downstream by name. Do not rename. +# qbz-linux- bare binary (stripped by the workspace profile) +# qbz-linux--unstripped symbolication copy (CARGO_PROFILE_RELEASE_STRIP=none) +# qbz-appdir- tar of the self-contained AppDir +# is x86_64 | aarch64. + +name: build-qt-linux + +on: + workflow_call: + inputs: + arch: + description: "x86_64 | aarch64" + required: true + type: string + source_ref: + description: "Optional branch, tag or SHA to build" + required: false + default: "" + type: string + outputs: + version: + description: "Workspace version from crates/Cargo.toml" + value: ${{ jobs.build.outputs.version }} + workflow_dispatch: + inputs: + arch: + description: "Architecture to pre-flight" + required: true + default: x86_64 + type: choice + options: [x86_64, aarch64] + source_ref: + description: "Optional branch, tag or SHA to build. Empty = dispatched ref." + required: false + type: string + +# The Qt pin. Repository variables win (set QT_VERSION_LINUX in Settings → +# Variables to bump without a commit); the literal is the measured floor. +# 6.8 is the LTS line and the floor the tree needs once the six macOS-only +# SafeArea/padding lines are guarded (contracts §1 / §4 #0). Keep in sync +# with the same pin in test-crates.yml. +jobs: + build: + name: build (${{ inputs.arch }}) + # aarch64 on 24.04-arm: aqt's arm64 Qt host tools (6.8.3 AND 6.9.3 qmake) + # SIGSEGV on ubuntu-22.04-arm with a clean ldd (2026-08-29, two runs). + # Accepted 2026-08-29: the arm64 DESKTOP floor is glibc 2.39; qbzd keeps + # 2.35 (Pi OS bookworm) — see the floor gate below. + runs-on: ${{ inputs.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }} + timeout-minutes: 120 + env: + # x86_64 keeps the 6.8 LTS floor. aarch64 pins its own: aqt's 6.8.3 + # linux_gcc_arm64 qmake SEGFAULTS on ubuntu-22.04-arm (2026-08-29 run, + # exit 139 on `qmake -query`), so the arm line takes 6.9.3 until an + # arm64 6.8.x that runs there exists. `vars.QT_VERSION_LINUX_ARM64` + # overrides without a commit. + QT_VERSION: ${{ inputs.arch == 'aarch64' && (vars.QT_VERSION_LINUX_ARM64 || '6.9.3') || (vars.QT_VERSION_LINUX || '6.8.3') }} + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.source_ref || github.ref }} + + - name: Workspace version + id: version + run: | + V="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')" + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "workspace version: $V" + + # Build-time deps only. fontconfig / freetype / wayland / xcb / mesa dev + # headers were Slint's direct needs; Qt links its own. The xcb/wayland + # RUNTIME libs are for linuxdeploy-plugin-qt: it resolves libqxcb.so's + # dependencies on the runner and dies on a missing libxkbcommon-x11.so.0 + # (measured). patchelf + file are linuxdeploy's own tools. + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config cmake clang libclang-dev nasm \ + libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev \ + libgl1-mesa-dev libegl1-mesa-dev libxkbcommon-dev libwayland-dev \ + libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 \ + libxcb-keysyms1 libxcb-render-util0 libxcb-xinerama0 libxcb-xkb1 \ + libxcb-shape0 libxcb-randr0 libxcb-sync1 libxcb-xfixes0 libxcb-glx0 \ + libwayland-client0 libwayland-egl1 libwayland-cursor0 libegl1 libgl1 \ + patchelf file binutils dbus-daemon + + # Official Qt binaries — WITHOUT qtshadertools: with qsb on PATH build.rs + # re-bakes shaders by mtime and a fresh checkout is nondeterministic. + # The build ships the committed .qsb (build.rs warns and keeps them); + # shader health is judged by test-crates.yml's shader-gate. The arm64 + # host exists in aqt from 6.7 — the first aarch64 dispatch is the proof. + - name: Install Qt ${{ env.QT_VERSION }} + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: ${{ inputs.arch == 'aarch64' && 'linux_arm64' || 'linux' }} + target: desktop + arch: ${{ inputs.arch == 'aarch64' && 'linux_gcc_arm64' || 'linux_gcc_64' }} + cache: true + + - name: Setup Rust (stable) + uses: dtolnay/rust-toolchain@stable + + # Cache key split by toolchain/profile and NEVER shared with the old + # `slint-*` keys. Trusted manual preflights on pre-release may seed it, + # so release-linux -> Flatpak/Snap and ARM -> Snap do not rebuild the + # identical graph from zero. PR/fork refs remain read-only. + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: crates -> target + shared-key: qt-linux-${{ inputs.arch }}-${{ env.QT_VERSION }} + cache-on-failure: true + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }} + + # NEVER `-p qbz`: crates/qbz (the frozen Slint binary) declares the same + # bin name in the same target dir and would silently win the file. + - name: Build (release) + env: + CARGO_INCREMENTAL: "0" + # Keep symbols in this build: the unstripped copy is archived for + # crash symbolication, then stripped for every shipped artifact. + CARGO_PROFILE_RELEASE_STRIP: "none" + # EXPLICIT qmake, as the macOS/Windows recipes do. On the arm64 + # runner cxx-qt-build failed with "Could not find Qt installation: + # QtMissing" although QT_ROOT_DIR was correct (2026-08-29 run) — + # the PATH lookup is not something to rely on across hosts. + QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake + CMAKE_PREFIX_PATH: ${{ env.QT_ROOT_DIR }} + run: | + # Diagnostics first: a qmake that cannot run here is the arm64 + # failure mode (segfault), and cxx-qt-build only says "QtMissing". + uname -a; getconf PAGESIZE; ldd --version | head -1 + file "$QMAKE" || true + ldd "$QMAKE" | grep -E 'not found' || true + "$QT_ROOT_DIR/libexec/moc" --version || true + "$QT_ROOT_DIR/libexec/qmlcachegen" --version || true + "$QMAKE" -query QT_VERSION + python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -p qbz-qt + cp crates/target/release/qbz qbz-unstripped + strip -o qbz crates/target/release/qbz + ls -la qbz qbz-unstripped + + - name: Slint-free dep-graph gate (qbz-qt) + run: | + set -euo pipefail + hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbz-qt -e normal \ + | grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true) + [ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; } + + # The empirical-verification rule: the README/website say what objdump + # says, not what the runner image advertises. Two floors, decided + # 2026-08-29: x86_64 2.35 (ubuntu-22.04 runner); aarch64 2.39 — the + # arm64 Qt host tools only run on ubuntu-24.04-arm — while qbzd stays at + # 2.35 on both (its own job, 22.04-arm) for the 1 GB Pi OS boards. + # README "Requires glibc" + the website glibcNote must match these. + - name: glibc floor gate (x86_64 <= 2.35, aarch64 <= 2.39) + run: | + set -euo pipefail + floor=$(objdump -T qbz | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -Vu | tail -1) + limit=$([ "${{ inputs.arch }}" = aarch64 ] && echo GLIBC_2.39 || echo GLIBC_2.35) + echo "glibc floor: $floor (limit $limit)" + highest=$(printf '%s\n' "$floor" "$limit" | sort -V | tail -1) + [ "$highest" = "$limit" ] || { echo "::error::glibc floor $floor exceeds $limit"; exit 1; } + + # Self-contained AppDir. The plugin scans QML_SOURCES_PATHS for imports + # and copies exactly the Qt QML modules the app uses (Effects, labs, + # Controls, ...) plus the platform plugins — but xcb ONLY unless told + # otherwise: Wayland needs both EXTRA_PLATFORM_PLUGINS and + # EXTRA_QT_MODULES=waylandcompositor (libQt6WaylandClient + the + # wayland-* plugin dirs). offscreen is what the smoke below runs on. + # It writes usr/bin/qt.conf and patches RPATH to $ORIGIN/../lib, which + # is why the tree works from any install prefix with no env at all. + - name: Build AppDir (linuxdeploy + Qt plugin) + env: + QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake + QML_SOURCES_PATHS: ${{ github.workspace }}/crates/qbz-qt/qml + EXTRA_PLATFORM_PLUGINS: "libqoffscreen.so;libqwayland-generic.so;libqwayland-egl.so" + EXTRA_QT_MODULES: waylandcompositor + LD_LIBRARY_PATH: ${{ env.QT_ROOT_DIR }}/lib + ARCH_TAG: ${{ inputs.arch }} + run: | + set -euo pipefail + curl -fsSL -o linuxdeploy \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH_TAG}.AppImage" + curl -fsSL -o linuxdeploy-plugin-qt \ + "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-${ARCH_TAG}.AppImage" + chmod +x linuxdeploy linuxdeploy-plugin-qt + # The desktop file goes in under the MPRIS DesktopEntry basename + # (com.blitzfc.qbz.desktop), so an integrated AppImage resolves the + # media-widget icon like the deb/rpm do. Icon=qbz -> qbz.png. + cp packaging/linux/qbz.desktop com.blitzfc.qbz.desktop + cp packaging/icons/256x256.png qbz.png + ./linuxdeploy --appimage-extract-and-run --appdir AppDir \ + -e qbz -d com.blitzfc.qbz.desktop -i qbz.png --plugin qt + scripts/packaging/desktop_entry_check.sh --appdir AppDir + # Nothing in the app uses Qt SQL / network-information plugins. + rm -rf AppDir/usr/plugins/sqldrivers AppDir/usr/plugins/networkinformation + mkdir -p AppDir/usr/share/licenses/qbz + cp LICENSE AppDir/usr/share/licenses/qbz/LICENSE + cp -r licenses AppDir/usr/share/licenses/qbz/third-party + echo "platforms: $(find AppDir/usr/plugins/platforms -maxdepth 1 -type f -printf '%f ')" + test -f AppDir/usr/plugins/platforms/libqwayland-generic.so + test -f AppDir/usr/plugins/platforms/libqoffscreen.so + test -f AppDir/usr/qml/QtQuick/Effects/libeffectsplugin.so + test -f AppDir/usr/bin/qt.conf + test -f AppDir/usr/share/licenses/qbz/third-party/Apache-2.0-dst-decoder.txt + du -sh AppDir + + # The shipped AppDir must initialize AND survive the observation window. + # Hide the SDK and use a fresh profile/private bus; early exit (even 0) + # fails. The helper reaps the launcher and its private process group. + - name: Offscreen smoke (the AppDir that ships) + run: | + python3 scripts/packaging/smoke_release.py ./AppDir/AppRun \ + --hide-qt "$QT_ROOT_DIR" --seconds 60 --log smoke.log + + - name: Upload deployed startup evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: qbz-appdir-smoke-${{ inputs.arch }} + path: smoke.log* + if-no-files-found: ignore + retention-days: 14 + + - name: Upload bare binary + uses: actions/upload-artifact@v7 + with: + name: qbz-linux-${{ inputs.arch }} + path: qbz + if-no-files-found: error + retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }} + + - name: Upload unstripped binary (symbolication) + uses: actions/upload-artifact@v7 + with: + name: qbz-linux-${{ inputs.arch }}-unstripped + path: qbz-unstripped + retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }} + + # tar keeps the symlinks (AppRun, .DirIcon) and the exec bits that + # upload-artifact's zip would drop. + - name: Upload AppDir + run: tar -cf appdir.tar AppDir + - uses: actions/upload-artifact@v7 + with: + name: qbz-appdir-${{ inputs.arch }} + path: appdir.tar + if-no-files-found: error + retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }} diff --git a/.github/workflows/build-slint-selfhosted.yml b/.github/workflows/build-slint-selfhosted.yml deleted file mode 100644 index 045b1166a..000000000 --- a/.github/workflows/build-slint-selfhosted.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Build Slint (self-hosted) - -# Builds qbz-slint on the self-hosted runner registered on the dev box -# (`qbz-box`, labels: self-hosted,linux,x64,qbz-builder). The box CAN compile -# qbz-slint (proven locally), so this is the reliable path to a real CI/CD -# artifact while the GitHub-hosted private runner (2 vCPU / 8 GB) cannot. -# -# Self-hosted runners keep their work directory between runs, so `target/` -# persists → builds are INCREMENTAL after the first (no `actions/cache` needed). -# -# MANUAL trigger only (workflow_dispatch): the box shares RAM with the desktop -# and earlyoom will SIGTERM the build if RAM runs low. Trigger this when the box -# is free (close heavy apps / TTY), and do NOT run a local build at the same -# time (one heavy build at a time — the box hard-freezes on double-build OOM). - -on: - workflow_dispatch: - -concurrency: - group: selfhosted-slint - cancel-in-progress: true - -jobs: - build: - runs-on: [self-hosted, qbz-builder] - timeout-minutes: 180 - steps: - - uses: actions/checkout@v7 - - - name: Free RAM check (earlyoom will kill the build if too low) - run: | - free -h | head -2 - avail=$(free -m | awk '/^Mem:/ {print $7}') - echo "MemAvailable: ${avail} MB" - if [ "${avail}" -lt 16000 ]; then - echo "::warning::Only ${avail} MB free — earlyoom may SIGTERM the qbz-slint rustc. Close apps / drop to TTY for headroom." - fi - - # Desktop-proof recipe, root-caused 2026-07-04 (ftrace): earlyoom's - # RAM-only trigger SIGTERM'd rustc whenever global MemAvailable dipped - # to ~3 GB — the cap margin, not the settings, was the bug. With - # slint-run.sh anchoring memory.high at avail-9G, this exact recipe - # (threads=2, cgu=256, opt=3, capped) completed with a 5.7 GB global - # floor and zero signals while the desktop ran. Same flags as the - # distribution profile → the runner cache stays warm between runs. - - name: Build qbz-slint (desktop-proof — capped, threads=2, opt-3) - run: NORUN=1 CAPPED=1 THREADS=2 CODEGEN_UNITS=256 OPT=3 ./scripts/slint-run.sh - - - name: Strip + report size - run: | - BIN=crates/target/release/qbz - echo "unstripped:"; ls -lh "$BIN" | awk '{print $5}' - cp "$BIN" qbz-linux-x86_64 - strip qbz-linux-x86_64 || true - echo "stripped:"; ls -lh qbz-linux-x86_64 | awk '{print $5}' - - - uses: actions/upload-artifact@v7 - with: - name: qbz-linux-x86_64 - path: qbz-linux-x86_64 - if-no-files-found: error diff --git a/.github/workflows/build-slint.yml b/.github/workflows/build-slint.yml deleted file mode 100644 index 25f147991..000000000 --- a/.github/workflows/build-slint.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: Build Slint (linux) - -# Builds the Slint app on a stock GitHub-hosted runner. The wall is the qbz_ui -# rustc (the generated Slint UI = ONE ~1.6M-line module): its peak memory DEMAND -# is ~30 GB (measured RSS+Swap, 2026-06) and it is FRONTEND-bound — codegen knobs -# (codegen-units, opt-level, CARGO_BUILD_JOBS, -Z threads) barely move it (jobs=1 -# only shaved ~3 GB vs cargo defaults). So the build is NOT made to fit by tuning; -# it is made to fit by giving it enough RAM+SWAP to hold ~30 GB. A stock -# `ubuntu-24.04` runner has ~16 GB RAM, so this job adds a large swapfile so -# RAM(16) + swap(~4 base + 18) ≈ 38 GB comfortably exceeds the ~30 GB demand: the -# resident working set stays near RAM size while cold pages page out (slow but it -# completes). The MIN-tier rustc env below keeps the resident set as low as -# possible. rust-cache (added below) makes this heavy compile happen ONCE per -# qbz_ui change, not on every push — that, not the peak, is the minute saver. -# Build-only for now (no signing/bundling). - -on: - # Manual trigger ONLY (pick the ref in the Actions UI). Repo policy: public - # workflows fire on release tags, never on plain pushes — a push to main IS - # a release merge, and the tag that follows immediately fires the real - # release builds, so an auto-build here would just double-burn ~2h of - # runner time for nothing. - workflow_dispatch: - -# A new push to slint-mvp cancels the older in-flight build — only the latest -# commit matters, and stacked ~20-min builds are the main minute-burn. -concurrency: - group: build-slint-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-24.04 - timeout-minutes: 180 - steps: - - uses: actions/checkout@v7 - - - name: Free disk + swap - run: | - echo "::group::before"; df -h /; free -h; echo "::endgroup::" - # On ubuntu-24.04 there is no separate large /mnt disk — everything is - # on the 72 GB root (~16 GB free after the image). Reclaim the big - # preinstalled toolchains we don't need (~25-30 GB) so the cargo target - # fits, then add a modest swap cushion for the qbz-slint rustc peak. - sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \ - /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \ - /usr/local/lib/node_modules || true - sudo docker image prune --all --force || true - # The runner ships an active /swapfile (~4 GB); add a LARGE extra swap - # so RAM(16) + swap(~4 + 18) ≈ 38 GB holds the measured ~30 GB qbz_ui - # demand (frontend-bound; not tunable away). 8 GB was NOT enough - # (16+4+8 ≈ 28 GB < 30 GB → OOM risk). Guarded so a swap hiccup never - # fails the job. - ( sudo fallocate -l 18G /swap_extra \ - && sudo chmod 600 /swap_extra \ - && sudo mkswap /swap_extra \ - && sudo swapon /swap_extra ) || echo "extra swap skipped (non-fatal)" - echo "::group::after"; df -h /; free -h; echo "::endgroup::" - - - name: System dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config cmake clang libclang-dev mold nasm \ - libasound2-dev libjack-jackd2-dev \ - libfontconfig1-dev libfreetype-dev \ - libxkbcommon-dev libwayland-dev libxcb1-dev \ - libgl1-mesa-dev libegl1-mesa-dev \ - libdbus-1-dev libssl-dev \ - libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \ - libjpeg-dev zlib1g-dev libgumbo-dev - - - name: Rust toolchain (pinned dated nightly — build uses -Z threads) - # Pinned (not floating @nightly) so rustc -V is byte-stable across runs: - # rust-cache embeds the rustc version in its key, and a floating nightly - # re-keys the cache + invalidates every Cargo fingerprint daily (the - # from-scratch-every-run waste). Bump this date deliberately when a newer - # nightly feature is actually needed. - uses: dtolnay/rust-toolchain@master - with: - toolchain: nightly-2026-06-23 - - - name: Cargo cache (Swatinem/rust-cache) - # THE biggest minute-saver: caches ~/.cargo + crates/target so a push - # that only touches controllers restores the prebuilt qbz-ui rlib (the - # ~20 GB / ~20-min compilation unit) instead of recompiling it; only the - # cheap qbz bin then rebuilds + links. `workspaces` MUST point at crates/ - # (manifest is crates/Cargo.toml, artifacts land in crates/target — not - # the default `. -> target`). save-if gates cache WRITES to slint-mvp so - # dispatch/PR runs read but don't churn the 10 GB Actions cache budget. - uses: Swatinem/rust-cache@v2 - with: - workspaces: crates -> target - shared-key: slint-linux - cache-on-failure: true - save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/slint-mvp' }} - - - name: Build qbz-slint (memory-tuned, MIN tier) - env: - CARGO_INCREMENTAL: '0' - CARGO_BUILD_JOBS: '1' - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256' - CARGO_PROFILE_RELEASE_OPT_LEVEL: '2' - RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1' - run: | - cargo build --release --manifest-path crates/Cargo.toml -p qbz - echo "::group::result"; ls -lh crates/target/release/qbz; df -h /; free -h; echo "::endgroup::" - - - name: Upload binary - uses: actions/upload-artifact@v7 - with: - name: qbz-linux-x86_64 - path: crates/target/release/qbz - if-no-files-found: error diff --git a/.github/workflows/issue-platform-triage.yml b/.github/workflows/issue-platform-triage.yml index 143da3d96..a2e2ff3e6 100644 --- a/.github/workflows/issue-platform-triage.yml +++ b/.github/workflows/issue-platform-triage.yml @@ -52,3 +52,49 @@ jobs: issue_number: context.payload.issue.number, labels: ['platform:linux'] }); + + - name: Welcome and label Windows reports + if: steps.platform.outputs.value == 'Windows' + uses: actions/github-script@v9 + with: + script: | + const issue = context.payload.issue.number; + const label = 'platform:windows'; + + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label + }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + color: '0078D4', + description: 'Issues specific to Windows' + }); + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue, + labels: [label] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue, + body: [ + '', + 'Thanks for reporting this on Windows — Windows reports are welcome.', + '', + 'The Windows build comes from the same shared QBZ codebase, so it receives the features and fixes developed for the project as a whole. At the moment, however, Windows does not have a dedicated maintainer or regular testing coverage. Windows-specific problems may therefore take longer to reproduce and resolve.', + '', + 'We are looking for someone who uses Windows regularly and would like to help test releases or maintain the platform. If that sounds like you, we would love to hear from you.' + ].join('\n') + }); diff --git a/.github/workflows/qt-gate-windows.yml b/.github/workflows/qt-gate-windows.yml new file mode 100644 index 000000000..91cd85a9d --- /dev/null +++ b/.github/workflows/qt-gate-windows.yml @@ -0,0 +1,212 @@ +# The Windows sibling of test-crates.yml's `qt-gate` job. +# +# Same five gates, same complaint regex, same "did it reach QbzCore" check, so +# a Windows regression is caught by the same shape of evidence as a Linux one. +# +# ── TRIGGERS ────────────────────────────────────────────────────────────── +# +# The repository rule, and the same one every release-*.yml follows: +# AUTOMATIC RUNS ON A TAG ONLY, and a manual dispatch is always available. +# No branch pushes and no pull-request trigger. +# +# That also settles why this is not a job inside test-crates.yml, which runs on +# every PR to pre-release: a Qt build on a Windows runner is roughly fifteen +# minutes of a runner billing at twice the Linux rate, and Windows is +# explicitly not a supported QBZ platform. Gating every Linux contributor's PR +# on it would block work on a platform nobody promised. +# +# On a `v*` tag this runs beside release-windows.yml and answers a different +# question: the release job proves the artifact BUILDS and boots, this one +# proves the QML types resolve, the audits pass, the dependency graph is +# Slint-free and the tests are green. A tag that fails here should not ship. +# +# ── WHAT THIS CATCHES THAT NOTHING ELSE DOES ────────────────────────────── +# +# `cargo check` sees nothing of QML: a wrong type name or a property that does +# not exist compiles perfectly and fails at RUNTIME, and because the failing +# component hangs off AppShell the whole shell fails to instantiate -- the app +# opens to a GREY SCREEN. That happened during the port and reached the owner. +# `qmllint` finds it in seconds. The shared qmllint_gate.sh now gives Linux and +# Windows the same native-type metadata and import checks. +name: qt-gate-windows + +on: + workflow_dispatch: + inputs: + ref: + description: "Optional ref to gate (branch, tag or SHA). Empty = the default checkout." + required: false + type: string + push: + tags: + - "v*" + +concurrency: + group: qt-gate-windows-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # The WINDOWS pin, which is ahead of the Linux floor on purpose: the port + # needs Qt 6.9's ExpandedClientAreaHint for the custom title bar. + QT_VERSION: ${{ vars.QT_VERSION_WINDOWS || '6.9.3' }} + CARGO_TERM_COLOR: always + +jobs: + qt-gate-windows: + name: qt gate (windows) + runs-on: windows-2022 + timeout-minutes: 90 + steps: + # cxx-qt deep-copies include trees and qmlcachegen writes ~340 .cpp files + # under OUT_DIR; the paths get long enough to hit MAX_PATH without this. + - name: Enable long paths + shell: pwsh + run: | + Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' ` + -Name LongPathsEnabled -Value 1 + git config --system core.longpaths true + + - name: Checkout + uses: actions/checkout@v7 + with: + # Empty falls back to the default ref, which is what a tag push wants. + ref: ${{ inputs.ref || github.ref }} + + - name: Install Qt ${{ env.QT_VERSION }} + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: windows + target: desktop + arch: win64_msvc2022_64 + modules: qtshadertools + cache: true + + - name: MSVC dev environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + # Git for Windows also ships a POSIX `link.exe`; force rustc to use the + # MSVC linker selected above instead of trusting PATH ordering. + - name: Pin MSVC linker + shell: pwsh + run: | + $linker = Join-Path $env:VCToolsInstallDir "bin\Hostx64\x64\link.exe" + if (-not (Test-Path $linker)) { throw "MSVC linker not found at $linker" } + "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER=$linker" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "Cargo linker: $linker" + + # aws-lc-sys (via rustls) ASSEMBLES on Windows x86_64. Its shipped + # prebuilt objects are used only when nasm is ABSENT, and without either + # the build fails hard with "Missing dependency: nasm" -- after the whole + # compile, which makes it an expensive way to find out. + - name: NASM (aws-lc-sys) + uses: ilammy/setup-nasm@v1 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: crates -> target + shared-key: qt-gate-windows-${{ env.QT_VERSION }} + cache-on-failure: true + # Tags and the trusted integration branch may seed the cache. A + # manual dispatch on any feature/fork ref remains read-only. + save-if: ${{ startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/pre-release' }} + + # QML is not compile-checked. A bad type name or property is a grey + # screen at runtime, not an error here, so this runs FIRST: it is the + # cheapest gate and it catches the most expensive failure. + - name: qmllint (QML type check) + shell: bash + run: scripts/qml-audits/qmllint_gate.sh "$QT_ROOT_DIR" + + # The same five the Linux gate runs, and they need Qt's own + # plugins.qmltypes to resolve QtQuick types -- hence after the install. + - name: QML audits + shell: bash + env: + QBZ_QT_QML_DIR: ${{ env.QT_ROOT_DIR }}/qml + run: | + set -euo pipefail + for a in qml_resolution_audit qml_singleton_xref qml_eager_tab_audit qml_module_registration_audit qml_icon_bake_audit qml_on_prefixed_property_audit; do + echo "::group::$a" + python "scripts/qml-audits/$a.py" "$PWD/crates/qbz-qt" + echo "::endgroup::" + done + + - name: Slint-free dep-graph gate (qbz-qt) + shell: bash + run: | + set -euo pipefail + hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbz-qt -e normal \ + | grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true) + [ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; } + echo "dep graph is Slint-free" + + - name: cargo test + build (qbz-qt) + shell: bash + run: | + set -euo pipefail + python3 scripts/qt-cargo.py test --manifest-path crates/Cargo.toml -j 4 -p qbz-qt --no-fail-fast + python3 scripts/qt-cargo.py build --manifest-path crates/Cargo.toml -j 4 -p qbz-qt + + # The Linux gate isolates with private XDG dirs and a private session + # bus. Windows needs neither a bus (the single instance is a named mutex) + # nor XDG, but it DOES need its own %APPDATA%/%LOCALAPPDATA%: every store + # QBZ opens is derived from them, and a smoke that writes into the + # runner's real profile is a smoke that can pass because of what a + # previous run left behind. + - name: Offscreen smoke + shell: pwsh + run: | + $iso = Join-Path $env:RUNNER_TEMP "qbz-smoke" + New-Item -ItemType Directory -Force -Path "$iso\roaming", "$iso\local" | Out-Null + $env:APPDATA = "$iso\roaming" + $env:LOCALAPPDATA = "$iso\local" + $env:QT_QPA_PLATFORM = "offscreen" + $env:RUST_LOG = "info" + $exe = "crates\target\debug\qbz.exe" + $p = Start-Process -FilePath $exe -RedirectStandardError smoke.log ` + -RedirectStandardOutput smoke.out -PassThru + if (-not $p.WaitForExit(75000)) { $p.Kill(); Start-Sleep -Seconds 2 } + + $lines = (Get-Content smoke.log -ErrorAction SilentlyContinue | Measure-Object -Line).Lines + Write-Host "smoke log: $lines lines" + if ($lines -lt 10) { + Get-Content smoke.log -ErrorAction SilentlyContinue + Write-Host "::error::the app did not start" + exit 1 + } + + # The same complaint set the Linux gate uses, verbatim. + $pat = 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|non-existent property|failed to load component|is not installed' + $errs = Select-String -Path smoke.log -Pattern $pat | + Where-Object { $_.Line -notmatch 'propertyCache' } + if ($errs) { + $errs | Select-Object -First 20 | ForEach-Object { $_.Line } + Write-Host "::error::$($errs.Count) QML complaint(s)" + exit 1 + } + + if (-not (Select-String -Path smoke.log -Pattern 'QbzCore initialized' -Quiet)) { + Get-Content smoke.log -Tail 20 + Write-Host "::error::never reached QbzCore init" + exit 1 + } + Write-Host "smoke OK (0 QML complaints, core initialized)" + + - name: Upload the smoke log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: smoke-log-windows + path: smoke.log + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/redirect-pr-to-prerelease.yml b/.github/workflows/redirect-pr-to-prerelease.yml index 40a8f6d30..464244080 100644 --- a/.github/workflows/redirect-pr-to-prerelease.yml +++ b/.github/workflows/redirect-pr-to-prerelease.yml @@ -1,7 +1,10 @@ name: Redirect PRs to pre-release on: - pull_request: + # No contributor code is checked out or executed. pull_request_target gives + # this API-only workflow the write token it needs for PRs opened from forks; + # pull_request downgrades GITHUB_TOKEN to read-only for those PRs. + pull_request_target: branches: [main] types: [opened] diff --git a/.github/workflows/release-aur.yml b/.github/workflows/release-aur.yml index 6ef0bddf2..706641dd1 100644 --- a/.github/workflows/release-aur.yml +++ b/.github/workflows/release-aur.yml @@ -1,24 +1,28 @@ name: release-aur on: - # Trigger after release-linux completes (it uploads amd64 tarball) workflow_run: workflows: ["release-linux"] types: [completed] workflow_dispatch: inputs: version: - description: "Version to publish (e.g. 1.0.0)" + description: "Version to prepare (e.g. 2.1.0)" required: true type: string pkgrel: - description: "Package release number (bump for same-version rebuilds)" + description: "Package release number" required: false default: "1" type: string + publish: + description: "Push all four generated packages to AUR" + required: false + default: false + type: boolean concurrency: - group: aur-publish + group: aur-prepare-publish cancel-in-progress: false permissions: @@ -26,160 +30,268 @@ permissions: jobs: update-aur: - runs-on: ubuntu-latest - # Only run on manual dispatch, or successful stable tag releases (no -rc) - if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v') && !contains(github.event.workflow_run.head_branch, '-rc')) }} + runs-on: ubuntu-24.04 + # workflow_run prepares verified metadata only. Publishing is deliberately + # manual because the maintainer's AUR key requires an interactive passkey. + # Accept only a successful tag push produced inside this repository; the + # exact tag and main ancestry are checked again after checkout. + if: > + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.head_branch, 'v') && + !contains(github.event.workflow_run.head_branch, '-')) steps: - - name: Checkout + - name: Checkout app repo uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }} + fetch-depth: 0 - - name: Determine version - id: version + - name: Resolve and verify release + id: release env: EVENT_NAME: ${{ github.event_name }} - WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} DISPATCH_VERSION: ${{ inputs.version }} + DISPATCH_PUBLISH: ${{ inputs.publish }} + DISPATCH_PKGREL: ${{ inputs.pkgrel }} run: | - if [[ "$EVENT_NAME" == "workflow_run" ]]; then - # Extract version from the triggering workflow's head branch (tag) - REF="$WORKFLOW_HEAD_BRANCH" - if [[ ! "$REF" =~ ^v[0-9]+(\.[0-9]+){1,3}([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid workflow_run head branch for release: $REF" - exit 1 - fi - VERSION="${REF#v}" + set -euo pipefail + + if [[ "$EVENT_NAME" == workflow_run ]]; then + tag="$RUN_HEAD_BRANCH" + version="${tag#v}" + pkgrel=1 + should_publish=false else - VERSION="$DISPATCH_VERSION" + version="$DISPATCH_VERSION" + tag="v${version}" + pkgrel="${DISPATCH_PKGREL:-1}" + should_publish="${DISPATCH_PUBLISH:-false}" fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Detected version: $VERSION" - - name: Wait for all release assets - env: - VERSION: ${{ steps.version.outputs.version }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - echo "Waiting for release assets to be available..." - echo "Note: amd64 should be ready (release-linux completed), waiting for aarch64..." - MAX_WAIT=1800 - INTERVAL=30 - ELAPSED=0 - - while [ $ELAPSED -lt $MAX_WAIT ]; do - echo "Checking for release assets (${ELAPSED}s elapsed)..." - - # Check if both tarballs exist (follow redirects, GitHub returns 302) - AMD64_CODE=$(curl -sL -o /dev/null -w "%{http_code}" "https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz") - AARCH64_CODE=$(curl -sL -o /dev/null -w "%{http_code}" "https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz") - AMD64_EXISTS=$([[ "$AMD64_CODE" == "200" ]] && echo "true" || echo "false") - AARCH64_EXISTS=$([[ "$AARCH64_CODE" == "200" ]] && echo "true" || echo "false") - - if [ "$AMD64_EXISTS" = "true" ] && [ "$AARCH64_EXISTS" = "true" ]; then - echo "Both release assets are available!" - break - fi - - echo "Waiting for assets... (amd64: $AMD64_EXISTS, aarch64: $AARCH64_EXISTS)" - sleep $INTERVAL - ELAPSED=$((ELAPSED + INTERVAL)) - done - - if [ $ELAPSED -ge $MAX_WAIT ]; then - echo "Timeout waiting for release assets after 30 minutes" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid release version: $version" + exit 1 + fi + if [[ ! "$pkgrel" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Invalid pkgrel: $pkgrel" + exit 1 + fi + if [[ "$should_publish" == true && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::AUR publishing is limited to stable x.y.z releases" exit 1 fi - - name: Download release tarballs and get checksums + if [[ "$EVENT_NAME" == workflow_run || "$should_publish" == true ]]; then + git fetch origin main --tags + if ! git show-ref --verify --quiet "refs/tags/${tag}"; then + echo "::error::Release tag ${tag} does not exist" + exit 1 + fi + tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")" + if [[ "$EVENT_NAME" == workflow_run && "$tag_sha" != "$RUN_HEAD_SHA" ]]; then + echo "::error::workflow_run SHA does not match ${tag}" + exit 1 + fi + if ! git merge-base --is-ancestor "$tag_sha" origin/main; then + echo "::error::${tag} is not on main" + exit 1 + fi + fi + + { + echo "version=$version" + echo "tag=$tag" + echo "pkgrel=$pkgrel" + echo "should_publish=$should_publish" + } >> "$GITHUB_OUTPUT" + + - name: Wait for all release assets + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + assets=( + "qbz_${VERSION}_amd64.tar.gz" + "qbz_${VERSION}_aarch64.tar.gz" + "qbzd-${VERSION}-linux-amd64.tar.gz" + "qbzd-${VERSION}-linux-aarch64.tar.gz" + "qbz-${VERSION}-cargo-vendor.tar.xz" + ) + + for attempt in $(seq 1 60); do + missing=() + for asset in "${assets[@]}"; do + url="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/${asset}" + code="$(curl -sL -o /dev/null -w '%{http_code}' "$url")" + [[ "$code" == 200 ]] || missing+=("$asset") + done + if [[ ${#missing[@]} -eq 0 ]]; then + echo "All AUR assets are available" + break + fi + if [[ "$attempt" -eq 60 ]]; then + printf '::error::Timed out waiting for %s\n' "${missing[*]}" + exit 1 + fi + echo "Attempt ${attempt}/60; waiting for: ${missing[*]}" + sleep 30 + done + + - name: Download sources and calculate checksums id: checksums env: - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.release.outputs.version }} run: | - # Download x86_64 tarball - AMD64_URL="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz" - echo "Downloading: $AMD64_URL" - curl -fsSL -o /tmp/qbz_amd64.tar.gz "$AMD64_URL" - SHA256_AMD64=$(sha256sum /tmp/qbz_amd64.tar.gz | cut -d' ' -f1) - echo "sha256_amd64=$SHA256_AMD64" >> "$GITHUB_OUTPUT" - echo "x86_64 Checksum: $SHA256_AMD64" + set -euo pipefail + mkdir -p /tmp/aur-sources - # Download aarch64 tarball - AARCH64_URL="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz" - echo "Downloading: $AARCH64_URL" - curl -fsSL -o /tmp/qbz_aarch64.tar.gz "$AARCH64_URL" - SHA256_AARCH64=$(sha256sum /tmp/qbz_aarch64.tar.gz | cut -d' ' -f1) - echo "sha256_aarch64=$SHA256_AARCH64" >> "$GITHUB_OUTPUT" - echo "aarch64 Checksum: $SHA256_AARCH64" + download_hash() { + local output_name="$1" url="$2" file="$3" sum + curl -fsSL "$url" -o "/tmp/aur-sources/$file" + sum="$(sha256sum "/tmp/aur-sources/$file" | cut -d' ' -f1)" + echo "${output_name}=${sum}" >> "$GITHUB_OUTPUT" + echo "${file}: ${sum}" + } - - name: Setup SSH for AUR + base="https://github.com/vicrodh/qbz/releases/download/v${VERSION}" + download_hash qbz_amd64 "$base/qbz_${VERSION}_amd64.tar.gz" qbz-amd64.tar.gz + download_hash qbz_aarch64 "$base/qbz_${VERSION}_aarch64.tar.gz" qbz-aarch64.tar.gz + download_hash qbzd_amd64 "$base/qbzd-${VERSION}-linux-amd64.tar.gz" qbzd-amd64.tar.gz + download_hash qbzd_aarch64 "$base/qbzd-${VERSION}-linux-aarch64.tar.gz" qbzd-aarch64.tar.gz + download_hash vendor "$base/qbz-${VERSION}-cargo-vendor.tar.xz" vendor.tar.xz + download_hash source \ + "https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz" \ + source.tar.gz + + - name: Configure AUR SSH + if: steps.release.outputs.should_publish == 'true' env: AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} run: | - mkdir -p ~/.ssh - echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur - chmod 600 ~/.ssh/aur - echo "Host aur.archlinux.org" >> ~/.ssh/config - echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config - echo " User aur" >> ~/.ssh/config + set -euo pipefail + if [[ -z "$AUR_SSH_PRIVATE_KEY" ]]; then + echo "::error::AUR_SSH_PRIVATE_KEY is required for publishing" + exit 1 + fi + install -d -m 700 ~/.ssh + install -m 600 /dev/null ~/.ssh/aur + printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur + { + echo "Host aur.archlinux.org" + echo " IdentityFile ~/.ssh/aur" + echo " IdentitiesOnly yes" + echo " User aur" + } >> ~/.ssh/config ssh-keyscan -t ed25519 aur.archlinux.org >> ~/.ssh/known_hosts - - name: Clone AUR repo - run: | - git clone ssh://aur@aur.archlinux.org/qbz-bin.git /tmp/aur-qbz-bin - cd /tmp/aur-qbz-bin - # Ensure we are on master (recover from detached HEAD if remote branch was corrupted) - git checkout master 2>/dev/null || git checkout -b master - - - name: Determine pkgrel - id: pkgrel - run: | - if [[ "${{ github.event_name }}" == "release" ]]; then - echo "pkgrel=1" >> "$GITHUB_OUTPUT" - else - echo "pkgrel=${{ inputs.pkgrel || '1' }}" >> "$GITHUB_OUTPUT" - fi - - - name: Update PKGBUILD + - name: Clone AUR package repos env: - VERSION: ${{ steps.version.outputs.version }} - PKGREL: ${{ steps.pkgrel.outputs.pkgrel }} - SHA256_AMD64: ${{ steps.checksums.outputs.sha256_amd64 }} - SHA256_AARCH64: ${{ steps.checksums.outputs.sha256_aarch64 }} + SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }} run: | - cd /tmp/aur-qbz-bin + set -euo pipefail + for package in qbz-bin qbz qbzd-bin qbzd; do + destination="/tmp/aur-${package}" + if [[ "$SHOULD_PUBLISH" == true ]]; then + url="ssh://aur@aur.archlinux.org/${package}.git" + else + url="https://aur.archlinux.org/${package}.git" + fi - # Copy PKGBUILD from main repo (source of truth) - cp ${{ github.workspace }}/packaging/aur/PKGBUILD . + if ! git clone "$url" "$destination"; then + if [[ "$SHOULD_PUBLISH" == true ]]; then + echo "::error::Could not clone AUR repository for ${package}" + exit 1 + fi + # qbzd packages do not exist until their first AUR push. + mkdir -p "$destination" + git -C "$destination" init -b master + git -C "$destination" remote add origin "$url" + fi + git -C "$destination" checkout master 2>/dev/null || \ + git -C "$destination" checkout -b master + done - # Update version and pkgrel - sed -i "s/^pkgver=.*/pkgver=${VERSION}/" PKGBUILD - sed -i "s/^pkgrel=.*/pkgrel=${PKGREL}/" PKGBUILD - - # Update checksums for both architectures - sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${SHA256_AMD64}')/" PKGBUILD - sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${SHA256_AARCH64}')/" PKGBUILD - - echo "=== Updated PKGBUILD ===" - cat PKGBUILD - - - name: Generate .SRCINFO - run: | - cd /tmp/aur-qbz-bin - docker run --rm -v "$PWD":/pkg -w /pkg -e HOST_UID=$(id -u) -e HOST_GID=$(id -g) archlinux:latest bash -c " - pacman -Sy --noconfirm pacman-contrib sudo - useradd -m builder - chown -R builder:builder /pkg - sudo -u builder makepkg --printsrcinfo > .SRCINFO - chown -R \$HOST_UID:\$HOST_GID /pkg - " - cat .SRCINFO - - - name: Commit and push to AUR + - name: Stamp PKGBUILDs env: - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.release.outputs.version }} + PKGREL: ${{ steps.release.outputs.pkgrel }} + QBZ_AMD64: ${{ steps.checksums.outputs.qbz_amd64 }} + QBZ_AARCH64: ${{ steps.checksums.outputs.qbz_aarch64 }} + QBZD_AMD64: ${{ steps.checksums.outputs.qbzd_amd64 }} + QBZD_AARCH64: ${{ steps.checksums.outputs.qbzd_aarch64 }} + SOURCE_SHA: ${{ steps.checksums.outputs.source }} + VENDOR_SHA: ${{ steps.checksums.outputs.vendor }} run: | - cd /tmp/aur-qbz-bin - git config user.name "GitHub Actions" - git config user.email "actions@github.com" - git add PKGBUILD .SRCINFO - git commit -m "Update to v${VERSION}" - git push + set -euo pipefail + for package in qbz-bin qbz qbzd-bin qbzd; do + cp "packaging/aur/${package}/"* "/tmp/aur-${package}/" + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "/tmp/aur-${package}/PKGBUILD" + sed -i "s/^pkgrel=.*/pkgrel=${PKGREL}/" "/tmp/aur-${package}/PKGBUILD" + done + + sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${QBZ_AMD64}')/" /tmp/aur-qbz-bin/PKGBUILD + sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${QBZ_AARCH64}')/" /tmp/aur-qbz-bin/PKGBUILD + sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${QBZD_AMD64}')/" /tmp/aur-qbzd-bin/PKGBUILD + sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${QBZD_AARCH64}')/" /tmp/aur-qbzd-bin/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SOURCE_SHA}' '${VENDOR_SHA}')/" /tmp/aur-qbz/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SOURCE_SHA}' '${VENDOR_SHA}')/" /tmp/aur-qbzd/PKGBUILD + + - name: Generate and validate .SRCINFO files + run: | + set -euo pipefail + for package in qbz-bin qbz qbzd-bin qbzd; do + docker run --rm \ + -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ + -v "/tmp/aur-${package}:/pkg" archlinux:latest bash -euc ' + useradd -m builder + chown -R builder:builder /pkg + runuser -u builder -- bash -c \ + "cd /pkg && makepkg --printsrcinfo > .SRCINFO" + chown -R "${HOST_UID}:${HOST_GID}" /pkg + ' + done + + - name: Commit and push all AUR packages + if: steps.release.outputs.should_publish == 'true' + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + for package in qbz-bin qbz qbzd-bin qbzd; do + directory="/tmp/aur-${package}" + git -C "$directory" config user.name "GitHub Actions" + git -C "$directory" config user.email "actions@github.com" + files=(PKGBUILD .SRCINFO) + [[ -f "$directory/qbzd.install" ]] && files+=(qbzd.install) + git -C "$directory" add "${files[@]}" + if git -C "$directory" diff --cached --quiet; then + echo "${package}: no metadata changes" + continue + fi + git -C "$directory" commit -m "Update to v${VERSION}" + git -C "$directory" push origin master + done + + - name: Upload dry-run package metadata + if: steps.release.outputs.should_publish != 'true' + uses: actions/upload-artifact@v7 + with: + name: aur-four-package-metadata + retention-days: 7 + path: | + /tmp/aur-qbz-bin/PKGBUILD + /tmp/aur-qbz-bin/.SRCINFO + /tmp/aur-qbz/PKGBUILD + /tmp/aur-qbz/.SRCINFO + /tmp/aur-qbzd-bin/PKGBUILD + /tmp/aur-qbzd-bin/.SRCINFO + /tmp/aur-qbzd-bin/qbzd.install + /tmp/aur-qbzd/PKGBUILD + /tmp/aur-qbzd/.SRCINFO + /tmp/aur-qbzd/qbzd.install + if-no-files-found: error diff --git a/.github/workflows/release-flatpak.yml b/.github/workflows/release-flatpak.yml index 3f8e67d6e..825345bc3 100644 --- a/.github/workflows/release-flatpak.yml +++ b/.github/workflows/release-flatpak.yml @@ -1,14 +1,10 @@ -# Flatpak bundle for GitHub Releases — Slint binary (crates/ workspace). +# Flatpak bundle for GitHub Releases — Qt frontend. # -# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Builds -# packaging/flatpak/com.blitzfc.qbz.yml (network manifest) with -# flatpak-builder and attaches the .flatpak bundle to the Release. -# Flathub proper is a separate repo/manifest updated at tag time via -# scripts/update-flathub.sh (offline sources). -# -# The cargo build runs on the HOST's resources even though it happens -# inside the flatpak sandbox, so this runner needs the same swap headroom -# as release-linux.yml (qbz_ui rustc peak ~30 GB). +# Rewritten 2026-08-25 for the Slint -> Qt frontend switch: the bundle is a +# REPACK of the bare binary from the shared build-qt-linux.yml on the KDE +# runtime (org.kde.Platform 6.9 provides Qt). No cargo inside flatpak-builder, +# no swap ceremony. Same shape as the Flathub manifest, which consumes the +# published release tarball instead. name: release-flatpak @@ -16,7 +12,7 @@ on: workflow_dispatch: inputs: tag: - description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD, no Release publish." + description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD, no Release publish." required: false type: string push: @@ -28,15 +24,46 @@ concurrency: cancel-in-progress: false jobs: + build: + uses: ./.github/workflows/build-qt-linux.yml + with: + arch: x86_64 + source_ref: ${{ inputs.tag || github.ref }} + + # qbzd rides in the bundle too (same as deb/rpm). Slint-free, minutes-class. + build-qbzd: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag || github.ref }} + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates -> target + shared-key: qbzd-linux-x86_64 + cache-on-failure: true + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }} + - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev + - run: cargo build --release --manifest-path crates/Cargo.toml -p qbzd + - uses: actions/upload-artifact@v7 + with: + name: qbzd-linux-x86_64-flatpak + path: crates/target/release/qbzd + retention-days: 7 + build-flatpak: + needs: [build, build-qbzd] runs-on: ubuntu-24.04 - timeout-minutes: 300 + timeout-minutes: 60 permissions: contents: write steps: - name: Checkout uses: actions/checkout@v7 with: + ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 - name: Resolve release version @@ -65,6 +92,17 @@ jobs: exit 1 fi + - name: Assert Cargo.toml version matches requested tag + if: startsWith(github.ref, 'refs/tags/') || inputs.tag != '' + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + CARGO_VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = "\(.*\)"/\1/')" + if [[ "$CARGO_VERSION" != "$VERSION" ]]; then + echo "::error::crates/Cargo.toml workspace version ($CARGO_VERSION) != requested version ($VERSION)." + exit 1 + fi + # Keep the metainfo's top entry honest (version + date). - name: Sync metainfo release entry if: steps.release.outputs.version != '' @@ -86,35 +124,40 @@ jobs: p.write_text(text, encoding="utf-8") PY - - name: Free disk space and add swap + - name: Download bare binary + uses: actions/download-artifact@v8 + with: + name: qbz-linux-x86_64 + path: packaging/flatpak/payload + - name: Download qbzd + uses: actions/download-artifact@v8 + with: + name: qbzd-linux-x86_64-flatpak + path: packaging/flatpak/payload + + - name: Stage payload run: | - sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \ - /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \ - /usr/local/lib/node_modules || true - sudo docker system prune -af || true - if sudo fallocate -l 18G /swap_extra; then - sudo chmod 600 /swap_extra - sudo mkswap /swap_extra - sudo swapon /swap_extra - else - echo "::warning::could not allocate extra swap; build may OOM" - fi - free -h + set -euo pipefail + P=packaging/flatpak/payload + chmod +x "$P/qbz" "$P/qbzd" + cp packaging/flatpak/com.blitzfc.qbz.desktop "$P/qbz.desktop" + cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "$P/" + cp LICENSE "$P/LICENSE" + cp -r licenses "$P/licenses" + mkdir -p "$P/icons"; cp packaging/icons/{32x32,48x48,64x64,128x128,256x256,512x512}.png "$P/icons/" + ls -la "$P" - name: Install flatpak tooling run: | sudo apt-get update - sudo apt-get install -y flatpak flatpak-builder + sudo apt-get install -y flatpak flatpak-builder dbus-daemon flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo - name: Install runtimes run: | flatpak install --user -y --noninteractive flathub \ - org.gnome.Platform//49 \ - org.gnome.Sdk//49 - flatpak install --user -y --noninteractive flathub \ - org.freedesktop.Sdk.Extension.rust-stable//25.08 \ - org.freedesktop.Sdk.Extension.llvm20//25.08 + org.kde.Platform//6.9 \ + org.kde.Sdk//6.9 - name: Build Flatpak id: build_flatpak @@ -132,11 +175,31 @@ jobs: echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT" ls -la dist + # Observe flatpak run until the deadline, with a fresh sandbox profile + # and private bus. A crash/early exit after core init must still fail. + - name: Offscreen smoke (inside the sandbox) + run: | + set -euo pipefail + flatpak install --user -y --noninteractive --bundle "dist/${{ steps.build_flatpak.outputs.bundle_name }}" || \ + flatpak install --user -y --noninteractive "dist/${{ steps.build_flatpak.outputs.bundle_name }}" + python3 scripts/packaging/smoke_release.py com.blitzfc.qbz \ + --flatpak --seconds 60 --log smoke.log + + - name: Upload deployed startup evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: qbz-flatpak-smoke + path: smoke.log* + if-no-files-found: ignore + retention-days: 14 + - name: Upload Flatpak bundle uses: actions/upload-artifact@v7 with: name: qbz-flatpak path: dist/${{ steps.build_flatpak.outputs.bundle_name }} + retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }} - name: Publish GitHub Release Asset if: startsWith(github.ref, 'refs/tags/') diff --git a/.github/workflows/release-gentoo.yml b/.github/workflows/release-gentoo.yml index c12cfd85d..de973263d 100644 --- a/.github/workflows/release-gentoo.yml +++ b/.github/workflows/release-gentoo.yml @@ -1,10 +1,8 @@ name: release-gentoo -# The overlay repo (vicrodh/qbz-overlay) must NEVER be edited by hand: any -# ebuild change without a Manifest regeneration bricks emerge with a digest -# mismatch (issue #557 — the 2.0.0 RESTRICT edit was pushed manually and -# skipped the Manifest). Template changes between releases are published by -# re-running THIS workflow via workflow_dispatch with the released version. +# qbz-overlay is automation-owned because every ebuild or metadata change must +# regenerate its Manifest. workflow_dispatch is the supported way to publish +# a template-only fix for an already released version. on: workflow_run: @@ -13,9 +11,14 @@ on: workflow_dispatch: inputs: version: - description: "Version to publish (e.g. 1.2.0)" + description: "Version to prepare (e.g. 2.1.0)" required: true type: string + publish: + description: "Push all four packages and Manifests to qbz-overlay" + required: false + default: false + type: boolean concurrency: group: gentoo-overlay @@ -26,160 +29,206 @@ permissions: jobs: update-overlay: - runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v') && !contains(github.event.workflow_run.head_branch, '-rc')) }} + runs-on: ubuntu-24.04 + if: > + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.head_branch, 'v') && + !contains(github.event.workflow_run.head_branch, '-')) steps: - name: Checkout app repo uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }} + fetch-depth: 0 - - name: Determine version - id: version + - name: Resolve and verify release + id: release env: EVENT_NAME: ${{ github.event_name }} - WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} DISPATCH_VERSION: ${{ inputs.version }} + DISPATCH_PUBLISH: ${{ inputs.publish }} run: | - if [[ "$EVENT_NAME" == "workflow_run" ]]; then - REF="$WORKFLOW_HEAD_BRANCH" - if [[ ! "$REF" =~ ^v[0-9]+(\.[0-9]+){1,3}([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid tag: $REF" + set -euo pipefail + if [[ "$EVENT_NAME" == workflow_run ]]; then + tag="$RUN_HEAD_BRANCH" + version="${tag#v}" + should_publish=true + else + version="$DISPATCH_VERSION" + tag="v${version}" + should_publish="${DISPATCH_PUBLISH:-false}" + fi + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid release version: $version" + exit 1 + fi + if [[ "$should_publish" == true && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Overlay publishing is limited to stable x.y.z releases" + exit 1 + fi + + if [[ "$should_publish" == true ]]; then + git fetch origin main --tags + if ! git show-ref --verify --quiet "refs/tags/${tag}"; then + echo "::error::Release tag ${tag} does not exist" + exit 1 + fi + tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")" + if [[ "$EVENT_NAME" == workflow_run && "$tag_sha" != "$RUN_HEAD_SHA" ]]; then + echo "::error::workflow_run SHA does not match ${tag}" + exit 1 + fi + if ! git merge-base --is-ancestor "$tag_sha" origin/main; then + echo "::error::${tag} is not on main" exit 1 fi - VERSION="${REF#v}" - else - VERSION="$DISPATCH_VERSION" fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Version: $VERSION" + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT" - name: Clone overlay repo env: OVERLAY_PAT: ${{ secrets.APT_REPO_TOKEN }} + SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }} run: | - git clone "https://x-access-token:${OVERLAY_PAT}@github.com/vicrodh/qbz-overlay.git" /tmp/qbz-overlay - cd /tmp/qbz-overlay - git config user.name "GitHub Actions" - git config user.email "actions@github.com" + set -euo pipefail + if [[ "$SHOULD_PUBLISH" == true ]]; then + if [[ -z "$OVERLAY_PAT" ]]; then + echo "::error::APT_REPO_TOKEN is required for publishing" + exit 1 + fi + url="https://x-access-token:${OVERLAY_PAT}@github.com/vicrodh/qbz-overlay.git" + else + url="https://github.com/vicrodh/qbz-overlay.git" + fi + git clone "$url" /tmp/qbz-overlay + git -C /tmp/qbz-overlay config user.name "GitHub Actions" + git -C /tmp/qbz-overlay config user.email "actions@github.com" - - name: Update binary ebuild + - name: Stamp all ebuilds and metadata env: - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.release.outputs.version }} run: | - cd /tmp/qbz-overlay/media-sound/qbz-bin + set -euo pipefail + for package in qbz-bin qbz qbzd-bin qbzd; do + directory="/tmp/qbz-overlay/media-sound/${package}" + mkdir -p "$directory" + find "$directory" -maxdepth 1 -type f -name "${package}-*.ebuild" -delete + cp "packaging/gentoo/${package}.ebuild.template" \ + "${directory}/${package}-${VERSION}.ebuild" + cp "packaging/gentoo/${package}.metadata.xml" \ + "${directory}/metadata.xml" + done - # Remove old version ebuilds (keep metadata.xml) - rm -f qbz-bin-*.ebuild - - # Copy template from app repo and update version - cp "${{ github.workspace }}/packaging/gentoo/qbz-bin.ebuild.template" "qbz-bin-${VERSION}.ebuild" - - echo "=== qbz-bin-${VERSION}.ebuild ===" - cat "qbz-bin-${VERSION}.ebuild" - - - name: Update source ebuild + - name: Download distfiles env: - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.release.outputs.version }} run: | - cd /tmp/qbz-overlay/media-sound/qbz + set -euo pipefail + mkdir -p /tmp/qbz-distfiles + declare -A urls=( + ["qbz_${VERSION}_amd64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz" + ["qbz_${VERSION}_aarch64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz" + ["qbzd-${VERSION}-linux-amd64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbzd-${VERSION}-linux-amd64.tar.gz" + ["qbzd-${VERSION}-linux-aarch64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbzd-${VERSION}-linux-aarch64.tar.gz" + ["qbz-${VERSION}-cargo-vendor.tar.xz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz-${VERSION}-cargo-vendor.tar.xz" + ["qbz-${VERSION}.tar.gz"]="https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz" + ) - # Remove old version ebuilds (keep metadata.xml) - rm -f qbz-*.ebuild - - # Copy template from app repo and update version - cp "${{ github.workspace }}/packaging/gentoo/qbz.ebuild.template" "qbz-${VERSION}.ebuild" - - echo "=== qbz-${VERSION}.ebuild ===" - cat "qbz-${VERSION}.ebuild" - - - name: Generate Manifests - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - # Helper: compute BLAKE2B and SHA512 for a file, output Manifest line - manifest_line() { - local type="$1" name="$2" file="$3" - local size blake2b sha512 - size=$(stat -c%s "$file") - blake2b=$(b2sum "$file" | awk '{print $1}') - sha512=$(sha512sum "$file" | awk '{print $1}') - echo "${type} ${name} ${size} BLAKE2B ${blake2b} SHA512 ${sha512}" - } - - # --- qbz-bin Manifest --- - cd /tmp/qbz-overlay/media-sound/qbz-bin - MANIFEST="" - - # Download DIST files and hash them. amd64 is REQUIRED: publishing a - # Manifest without its DIST entry bricks emerge for every amd64 user - # (digest missing), which is worse than failing this job. A short - # retry absorbs release-asset propagation lag right after the tag. - for arch_pair in "amd64:amd64" "aarch64:arm64"; do - arch="${arch_pair%%:*}" - dist_name="qbz_${VERSION}_${arch}.tar.gz" - url="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/${dist_name}" - echo "Downloading ${dist_name}..." - ok=0 - for attempt in 1 2 3; do - if curl -sLf "$url" -o "/tmp/${dist_name}"; then - ok=1 + for filename in "${!urls[@]}"; do + ok=false + for attempt in $(seq 1 60); do + if curl -fsSL "${urls[$filename]}" -o "/tmp/qbz-distfiles/${filename}"; then + ok=true break fi - echo "Attempt ${attempt} failed for ${dist_name}, retrying in 30s..." + echo "Attempt ${attempt}/60 failed for ${filename}" sleep 30 done - if [[ "$ok" == 1 ]]; then - MANIFEST+="$(manifest_line DIST "$dist_name" "/tmp/${dist_name}")"$'\n' - elif [[ "$arch" == "amd64" ]]; then - echo "ERROR: required asset ${dist_name} not found at ${url}" + if [[ "$ok" != true ]]; then + echo "::error::Required distfile unavailable: ${filename}" exit 1 - else - echo "WARNING: ${dist_name} not found at ${url}, skipping" fi done - # Hash the ebuild itself - MANIFEST+="$(manifest_line EBUILD "qbz-bin-${VERSION}.ebuild" "qbz-bin-${VERSION}.ebuild")"$'\n' + - name: Generate all Manifests + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + manifest_line() { + local type="$1" name="$2" file="$3" size blake2b sha512 + size="$(stat -c%s "$file")" + blake2b="$(b2sum "$file" | awk '{print $1}')" + sha512="$(sha512sum "$file" | awk '{print $1}')" + printf '%s %s %s BLAKE2B %s SHA512 %s\n' \ + "$type" "$name" "$size" "$blake2b" "$sha512" + } - # Hash metadata.xml - MANIFEST+="$(manifest_line MISC "metadata.xml" "metadata.xml")"$'\n' + write_manifest() { + local package="$1" + shift + local directory="/tmp/qbz-overlay/media-sound/${package}" + { + local distfile + for distfile in "$@"; do + manifest_line DIST "$distfile" "/tmp/qbz-distfiles/${distfile}" + done + manifest_line EBUILD "${package}-${VERSION}.ebuild" \ + "${directory}/${package}-${VERSION}.ebuild" + manifest_line MISC metadata.xml "${directory}/metadata.xml" + } > "${directory}/Manifest" + } - echo "$MANIFEST" > Manifest - echo "=== qbz-bin Manifest ===" - cat Manifest + write_manifest qbz-bin \ + "qbz_${VERSION}_amd64.tar.gz" \ + "qbz_${VERSION}_aarch64.tar.gz" + write_manifest qbz \ + "qbz-${VERSION}.tar.gz" \ + "qbz-${VERSION}-cargo-vendor.tar.xz" + write_manifest qbzd-bin \ + "qbzd-${VERSION}-linux-amd64.tar.gz" \ + "qbzd-${VERSION}-linux-aarch64.tar.gz" + write_manifest qbzd \ + "qbz-${VERSION}.tar.gz" \ + "qbz-${VERSION}-cargo-vendor.tar.xz" - # --- qbz (source) Manifest --- - cd /tmp/qbz-overlay/media-sound/qbz - MANIFEST="" - - # Download source tarball - dist_name="qbz-${VERSION}.tar.gz" - url="https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz" - echo "Downloading source tarball..." - if curl -sLf "$url" -o "/tmp/${dist_name}"; then - MANIFEST+="$(manifest_line DIST "$dist_name" "/tmp/${dist_name}")"$'\n' - else - echo "WARNING: source tarball not found, skipping" - fi - - # Hash the ebuild - MANIFEST+="$(manifest_line EBUILD "qbz-${VERSION}.ebuild" "qbz-${VERSION}.ebuild")"$'\n' - - # Hash metadata.xml - MANIFEST+="$(manifest_line MISC "metadata.xml" "metadata.xml")"$'\n' - - echo "$MANIFEST" > Manifest - echo "=== qbz Manifest ===" - cat Manifest + - name: Validate generated overlay metadata + run: | + set -euo pipefail + for package in qbz-bin qbz qbzd-bin qbzd; do + directory="/tmp/qbz-overlay/media-sound/${package}" + bash -n "${directory}"/*.ebuild + xmllint --noout "${directory}/metadata.xml" + test "$(wc -l < "${directory}/Manifest")" -ge 4 + done - name: Commit and push + if: steps.release.outputs.should_publish == 'true' env: - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.release.outputs.version }} run: | - cd /tmp/qbz-overlay - git add -A - if git diff --cached --quiet; then - echo "No changes to commit" + set -euo pipefail + git -C /tmp/qbz-overlay add -A + if git -C /tmp/qbz-overlay diff --cached --quiet; then + echo "No overlay changes to publish" exit 0 fi - git commit -m "Update to v${VERSION}" - git push + git -C /tmp/qbz-overlay commit -m "Update to v${VERSION}" + git -C /tmp/qbz-overlay push + + - name: Upload dry-run overlay metadata + if: steps.release.outputs.should_publish != 'true' + uses: actions/upload-artifact@v7 + with: + name: gentoo-four-package-dry-run + retention-days: 7 + path: /tmp/qbz-overlay/media-sound/ + if-no-files-found: error diff --git a/.github/workflows/release-linux-aarch64.yml b/.github/workflows/release-linux-aarch64.yml index 2d035096b..2c47d72ba 100644 --- a/.github/workflows/release-linux-aarch64.yml +++ b/.github/workflows/release-linux-aarch64.yml @@ -1,14 +1,28 @@ -# Linux aarch64 release — Slint binary (crates/ workspace). +# Linux aarch64 release — Qt frontend (crates/ workspace, package `qbz-qt`, +# binary `qbz`). # -# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Same -# build-once-package-many shape as release-linux.yml (read that file's -# header); differences: native arm64 runner, no x86 target-features, its -# own rust-cache key (this workflow is the only writer), and it ATTACHES -# to the Release that release-linux.yml creates. +# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Pattern is still +# build once, package many — but the build is the shared build-qt-linux.yml +# (workflow_call) and it yields TWO payloads: +# * the bare binary -> qbz_${VERSION}_arm64.tar.gz (AUR / Gentoo / Flathub +# repack: ecosystems that provide Qt >= 6.8 themselves) +# * the AppDir (Qt bundled, self-contained) -> deb / rpm / AppImage, so +# distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working. # -# CONTRACT: keeps publishing qbz_${VERSION}_aarch64.tar.gz, -# QBZ_${VERSION}_arm64.deb, QBZ-${VERSION}-1.aarch64.rpm, -# QBZ_${VERSION}_aarch64.AppImage — AUR/Gentoo/APT/website names. +# CONTRACT (do not break): the release must keep publishing +# qbz_${VERSION}_arm64.tar.gz (dir qbz_${VERSION}_arm64/{qbz, qbzd, LICENSE, licenses/, qbz.desktop, +# com.blitzfc.qbz.metainfo.xml, qbzd.service, +# completions/, icons/hicolor/...}) +# QBZ_${VERSION}_arm64.deb / QBZ-${VERSION}-1.aarch64.rpm / QBZ_${VERSION}_arm64.AppImage +# qbzd_${VERSION}_arm64.deb / qbzd-${VERSION}-1.aarch64.rpm / qbzd-${VERSION}-linux-arm64.tar.gz +# — release-aur.yml, release-gentoo.yml, qbz-apt, qbz-rpm, the updater +# manifest and the website consume these names. +# +# release-linux.yml is this file with the four ARCH_* values changed. +# Keep them structurally identical. +# +# Triggers: version tags (cut from main — enforced by the ancestry guard) +# + manual dispatch (guard skipped, for testing on any branch). name: "Linux ARM64 (aarch64) Build" @@ -16,7 +30,7 @@ on: workflow_dispatch: inputs: tag: - description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD with the Cargo.toml version, no Release publish." + description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD with the Cargo.toml version, no Release publish." required: false type: string push: @@ -27,18 +41,23 @@ concurrency: group: release-linux-aarch64-${{ github.ref }} cancel-in-progress: false +env: + ARCH: aarch64 # build-qt-linux input + artifact suffix + DEB_ARCH: arm64 # QBZ_${V}_arm64.deb, qbz_${V}_arm64.tar.gz + RPM_ARCH: aarch64 # QBZ-${V}-1.aarch64.rpm + APPIMAGE_ARCH: aarch64 # QBZ_${V}_aarch64.AppImage + jobs: - build: + # Release bookkeeping first so a bad tag fails in seconds, not after a build. + version: runs-on: ubuntu-24.04-arm - timeout-minutes: 300 - continue-on-error: true outputs: version: ${{ steps.release.outputs.version }} prerelease: ${{ steps.release.outputs.prerelease }} steps: - - name: Checkout - uses: actions/checkout@v7 + - uses: actions/checkout@v7 with: + ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 - name: Resolve release version @@ -51,6 +70,7 @@ jobs: VERSION="${{ inputs.tag }}" VERSION="${VERSION#v}" else + # Manual dispatch without a tag: test build at the Cargo.toml version. VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')" fi PRERELEASE=false @@ -59,103 +79,72 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" + echo "Building version $VERSION (prerelease=$PRERELEASE)" + # Release tags must be cut from main. A tag on any other commit fires + # this workflow too (tags are not branch-scoped in Actions), so verify + # ancestry instead of trusting convention. Manual dispatch skips this. - name: Verify tag is on main if: startsWith(github.ref, 'refs/tags/') run: | git fetch origin main if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then - echo "::error::Tag ${GITHUB_REF#refs/tags/} is not on main. Releases are cut from main only." + echo "::error::Tag ${GITHUB_REF#refs/tags/} points to a commit that is NOT on main. Releases are cut from main only." exit 1 fi - - name: Free disk space and add swap - run: | - sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \ - /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \ - /usr/local/lib/node_modules || true - sudo docker system prune -af || true - if sudo fallocate -l 18G /swap_extra; then - sudo chmod 600 /swap_extra - sudo mkswap /swap_extra - sudo swapon /swap_extra - else - echo "::warning::could not allocate extra swap; build may OOM" - fi - free -h - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - mold nasm \ - libasound2-dev libjack-jackd2-dev \ - libfontconfig1-dev libfreetype-dev \ - libxkbcommon-dev libwayland-dev libxcb1-dev \ - libgl1-mesa-dev libegl1-mesa-dev \ - libdbus-1-dev libssl-dev \ - libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \ - libjpeg-dev zlib1g-dev libgumbo-dev - - # Pinned nightly: matches build-slint.yml / release-linux.yml. - - name: Setup Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: nightly-2026-06-23 - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - workspaces: crates -> target - shared-key: slint-linux-aarch64 - cache-on-failure: true - # This workflow is the only aarch64 builder, so it owns its cache. - save-if: true - - - name: Build (release, MIN memory tier) + # The binary embeds the workspace version (About, diagnostics); a + # mismatched tag would ship a binary that reports the wrong version. + - name: Assert Cargo.toml version matches tag + if: startsWith(github.ref, 'refs/tags/') || inputs.tag != '' env: - CARGO_INCREMENTAL: "0" - CARGO_BUILD_JOBS: "1" - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256" - CARGO_PROFILE_RELEASE_OPT_LEVEL: "2" - CARGO_PROFILE_RELEASE_STRIP: "none" - # No x86 target-features here (aarch64 baseline has NEON). - RUSTFLAGS: "-C link-arg=-fuse-ld=mold -Z threads=1" + VERSION: ${{ steps.release.outputs.version }} run: | - cargo build --release --manifest-path crates/Cargo.toml -p qbz - cp crates/target/release/qbz qbz-unstripped - strip -o qbz crates/target/release/qbz - ls -la qbz qbz-unstripped + CARGO_VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')" + if [[ "$CARGO_VERSION" != "$VERSION" ]]; then + echo "::error::crates/Cargo.toml workspace version ($CARGO_VERSION) != tag version ($VERSION). Bump it before tagging (see RELEASE_CHECKLIST)." + exit 1 + fi - - name: Upload binary - uses: actions/upload-artifact@v7 - with: - name: qbz-linux-aarch64 - path: qbz - if-no-files-found: error + # The media-widget icon invariant (DesktopEntry == installed .desktop + # basename, Icon= installed) — regressed more than once, so it is a + # gate at the recipe level here and on the built packages below. + - name: Gate — desktop entry / icon invariant (recipes) + run: scripts/packaging/desktop_entry_check.sh - - name: Upload unstripped binary (symbolication) - uses: actions/upload-artifact@v7 - with: - name: qbz-linux-aarch64-unstripped - path: qbz-unstripped - retention-days: 90 + # No workflow may build the frozen Slint binary: same bin name, same + # target dir, last build wins the file — a smoke against "the wrong + # qbz" is silent. + - name: Gate — no workflow builds `-p qbz` + run: | + set -euo pipefail + hits=$(grep -rnE 'cargo (build|check|test|run|install).* -p qbz([^-a-z]|$)' .github/workflows/ || true) + [ -z "$hits" ] || { echo "::error::a workflow still builds the Slint crate:"; echo "$hits"; exit 1; } - # T15: aarch64 mirror of release-linux.yml's build-qbzd job — see that - # file's header comment for the glibc-floor rationale. Uses the native - # ubuntu-22.04-arm runner label rather than cross-compiling. - # - # Fallback note (NOT implemented — flagged for pre-flight per the T15 - # brief): if ubuntu-22.04-arm is ever unavailable to this repo, cross-build - # instead via the existing Cross.toml pattern (crates/Cross.toml, - # scripts/build-aarch64-linux.sh's CROSS mode) targeting a 2.35-floor - # image; the objdump gate below stays the acceptance test either way. + build: + needs: version + uses: ./.github/workflows/build-qt-linux.yml + with: + arch: aarch64 + source_ref: ${{ inputs.tag || github.ref }} + + # qbzd is minutes-class and Slint-free. Runs on ubuntu-22.04 so the shipped + # binary's glibc floor stays <= Pi OS bookworm's 2.36 (the honest-Pi-story + # gate); the objdump step verifies that empirically. build-qbzd: runs-on: ubuntu-22.04-arm timeout-minutes: 30 steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag || github.ref }} - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates -> target + shared-key: qbzd-linux-aarch64 + cache-on-failure: true + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }} - name: system deps run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev - name: slint-free dep-graph gate @@ -174,37 +163,45 @@ jobs: echo "glibc floor: $floor" highest=$(printf '%s\n' "$floor" "GLIBC_2.35" | sort -V | tail -1) [ "$highest" = "GLIBC_2.35" ] || { echo "::error::floor $floor exceeds 2.35"; exit 1; } - # NOTE: the T15 brief's literal YAML names this artifact - # `qbzd-linux-arm64`, but T14 (39eb12fc, already merged into this - # branch) wired the package job's Download-qbzd step below to expect - # `qbzd-linux-aarch64` — matching what already landed wins over the - # brief text (see task-15-report.md for the full note). - uses: actions/upload-artifact@v7 - with: { name: qbzd-linux-aarch64, path: crates/target/release/qbzd } + with: + name: qbzd-linux-aarch64 + path: crates/target/release/qbzd + retention-days: 7 package: - needs: [build, build-qbzd] + needs: [version, build, build-qbzd] runs-on: ubuntu-24.04-arm permissions: contents: write env: - VERSION: ${{ needs.build.outputs.version }} + VERSION: ${{ needs.version.outputs.version }} steps: - name: Checkout uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag || github.ref }} - - name: Download binary + - name: Download bare binary uses: actions/download-artifact@v8 with: - name: qbz-linux-aarch64 + name: qbz-linux-${{ env.ARCH }} + + - name: Download AppDir + uses: actions/download-artifact@v8 + with: + name: qbz-appdir-${{ env.ARCH }} - # T14: the slint-free qbzd binary, produced by T15's build-qbzd job — see - # release-linux.yml's matching step for the full rationale (one - # artifact feeds nfpm, the main tarball, and the standalone tarball). - name: Download qbzd binary uses: actions/download-artifact@v8 with: - name: qbzd-linux-aarch64 + name: qbzd-linux-${{ env.ARCH }} + + - name: Unpack AppDir + run: | + tar -xf appdir.tar + chmod +x qbz qbzd + ls AppDir; ls AppDir/usr/plugins/platforms - name: Install nfpm run: | @@ -214,37 +211,55 @@ jobs: sudo apt-get install -y nfpm nfpm --version + # deb/rpm carry the AppDir tree under /opt/qbz (bin, lib, plugins, qml, + # qt.conf) and a /usr/bin/qbz symlink. RPATH ($ORIGIN/../lib, patched by + # linuxdeploy) resolves through /proc/self/exe, so the symlink needs no + # wrapper — and a wrapper exporting LD_LIBRARY_PATH would leak our Qt + # into every process qbz spawns (the login browser, xdg-open). - name: Build deb + rpm (nfpm) env: QBZ_VERSION: ${{ env.VERSION }} - QBZ_ARCH: arm64 - QBZ_BINARY: ./qbz + QBZ_ARCH: ${{ env.DEB_ARCH }} run: | - chmod +x qbz - chmod +x qbzd mkdir -p dist + ./qbzd completions bash > qbzd.bash + ./qbzd completions zsh > qbzd.zsh + ./qbzd completions fish > qbzd.fish nfpm package -f packaging/nfpm/nfpm.yaml -p deb \ - -t "dist/QBZ_${VERSION}_arm64.deb" + -t "dist/QBZ_${VERSION}_${DEB_ARCH}.deb" nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \ - -t "dist/QBZ-${VERSION}-1.aarch64.rpm" + -t "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm" + # Standalone qbzd packages (daemon-only), same staged ./qbzd. + nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p deb \ + -t "dist/qbzd_${VERSION}_${DEB_ARCH}.deb" + nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p rpm \ + -t "dist/qbzd-${VERSION}-1.${RPM_ARCH}.rpm" + ls -la dist + + # The same invariant on the ARTIFACTS: the deb/rpm must install + # /usr/share/applications/.desktop and its Icon=. + - name: Gate — desktop entry / icon invariant (packages) + run: | + sudo apt-get install -y --no-install-recommends rpm + scripts/packaging/desktop_entry_check.sh \ + --deb "dist/QBZ_${VERSION}_${DEB_ARCH}.deb" \ + --rpm "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm" \ + --appdir AppDir - name: Build AppImage run: | - APPDIR="QBZ.AppDir" - mkdir -p "$APPDIR/usr/bin" - cp qbz "$APPDIR/usr/bin/qbz" - cp packaging/linux/qbz.desktop "$APPDIR/qbz.desktop" - cp packaging/icons/256x256.png "$APPDIR/qbz.png" - cp packaging/icons/256x256.png "$APPDIR/.DirIcon" - ln -s usr/bin/qbz "$APPDIR/AppRun" - curl -fsSL -o appimagetool \ - https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-aarch64.AppImage + "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage" chmod +x appimagetool - ARCH=aarch64 ./appimagetool --appimage-extract-and-run "$APPDIR" \ - "dist/QBZ_${VERSION}_aarch64.AppImage" + ARCH="${ARCH}" ./appimagetool --appimage-extract-and-run AppDir \ + "dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage" + ls -la dist - # Tauri-updater bridge — see release-linux.yml for the rationale. + # Tauri-updater bridge: v1.2.x AppImage installs poll latest.json and + # verify artifacts with the Tauri minisign pubkey baked into the old + # binaries. Signing with the SAME private key lets them auto-update + # straight into this AppImage. release-updater-manifest.yml collects + # the .sig. The app itself has no auto-updater. - name: Sign AppImage (Tauri-updater bridge) if: startsWith(github.ref, 'refs/tags/') env: @@ -254,14 +269,16 @@ jobs: npx --yes @tauri-apps/cli@^2 signer sign \ --private-key "$TAURI_SIGNING_PRIVATE_KEY" \ --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \ - "dist/QBZ_${VERSION}_aarch64.AppImage" + "dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage" ls -la dist + # The BARE binary. Its consumers (AUR, Gentoo, Flathub) provide Qt. - name: Build AUR/Gentoo tarball run: | - # CONTRACT: same name + internal layout as the Tauri-era tarball. - TARBALL_DIR="qbz_${VERSION}_aarch64" - mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256}/apps + # CONTRACT: release-aur.yml, the Gentoo binary ebuild, qbz-apt and + # the website hard-code this name and internal layout. + TARBALL_DIR="qbz_${VERSION}_${DEB_ARCH}" + mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256,512x512}/apps cp qbz "tarball-staging/${TARBALL_DIR}/qbz" chmod +x "tarball-staging/${TARBALL_DIR}/qbz" cp packaging/icons/32x32.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/32x32/apps/qbz.png" @@ -269,57 +286,52 @@ jobs: cp packaging/icons/64x64.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/64x64/apps/qbz.png" cp packaging/icons/128x128.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/128x128/apps/qbz.png" cp packaging/icons/256x256.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/256x256/apps/qbz.png" + cp packaging/icons/512x512.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/512x512/apps/qbz.png" cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop" - # Additive (T14) — see release-linux.yml's matching block for the - # layout-contract rationale. + cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml" + cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE" + cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses" cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd" chmod +x "tarball-staging/${TARBALL_DIR}/qbzd" cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service" mkdir -p "tarball-staging/${TARBALL_DIR}/completions" - ./qbzd completions bash > "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash" - ./qbzd completions zsh > "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh" - ./qbzd completions fish > "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish" - tar -czvf "dist/qbz_${VERSION}_aarch64.tar.gz" -C tarball-staging "${TARBALL_DIR}" + cp qbzd.bash "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash" + cp qbzd.zsh "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh" + cp qbzd.fish "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish" + tar -czvf "dist/qbz_${VERSION}_${DEB_ARCH}.tar.gz" -C tarball-staging "${TARBALL_DIR}" ls -la dist - # T15 Step 2b (OD3, G0) — see release-linux.yml's matching step for - # the rationale. Never touches snap/Flathub/AppImage/dmg. - name: qbzd-tarball run: | - QBZD_DIR="qbzd-${VERSION}-linux-aarch64" + QBZD_DIR="qbzd-${VERSION}-linux-${DEB_ARCH}" mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions" cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd" chmod +x "qbzd-tarball-staging/${QBZD_DIR}/qbzd" cp crates/qbzd/service/qbzd.service "qbzd-tarball-staging/${QBZD_DIR}/qbzd.service" - ./qbzd completions bash > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash" - ./qbzd completions zsh > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh" - ./qbzd completions fish > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish" + cp qbzd.bash "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash" + cp qbzd.zsh "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh" + cp qbzd.fish "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish" cp packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md" - tar -czvf "dist/qbzd-${VERSION}-linux-aarch64.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}" + cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE" + cp -r licenses "qbzd-tarball-staging/${QBZD_DIR}/licenses" + tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}" ls -la dist - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: qbz-linux-aarch64-packages + name: qbz-linux-packages-${{ env.ARCH }} path: dist/* + retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }} - # Attaches to the Release created by release-linux.yml. - name: Publish GitHub Release Assets if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v3 with: files: dist/* - prerelease: ${{ needs.build.outputs.prerelease }} - make_latest: ${{ needs.build.outputs.prerelease == 'true' && 'false' || 'true' }} + prerelease: ${{ needs.version.outputs.prerelease }} + make_latest: ${{ needs.version.outputs.prerelease == 'true' && 'false' || 'true' }} - # #648: the apt dispatch MUST also fire from here — this workflow is the - # slow one (native arm64 build), so release-linux.yml's dispatch ran - # hours earlier, when the Release still held only the x86_64 .deb; the - # arm64 .deb landed after and nothing re-indexed (apt arm64 stuck at - # 1.2.15). update-repo.yml is idempotent (re-downloads every *.deb, - # overwrites the pool, rebuilds both indices), so a second run simply - # completes whichever arch the first one missed. - name: Trigger APT repo update if: startsWith(github.ref, 'refs/tags/') uses: peter-evans/repository-dispatch@v4 @@ -328,3 +340,14 @@ jobs: repository: vicrodh/qbz-apt event-type: new-release client-payload: '{"tag": "${{ github.ref_name }}"}' + + # Our own signed RPM repo. This is intentionally required: a bad token + # must not leave the stable RPM channel stale without failing release CI. + - name: Trigger RPM repo update + if: startsWith(github.ref, 'refs/tags/') + uses: peter-evans/repository-dispatch@v4 + with: + token: ${{ secrets.APT_REPO_TOKEN }} + repository: vicrodh/qbz-rpm + event-type: new-release + client-payload: '{"tag": "${{ github.ref_name }}"}' diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 1bb546403..e7d7a3efc 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -1,17 +1,26 @@ -# Linux x86_64 release — Slint binary (crates/ workspace). +# Linux x86_64 release — Qt frontend (crates/ workspace, package `qbz-qt`, +# binary `qbz`). # -# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Pattern: -# build once, package many. The build job produces the binary with the -# proven public-runner recipe from build-slint.yml (16 GB RAM + 18 GB extra -# swap covers the ~30 GB qbz_ui rustc peak, MIN memory tier, mold, pinned -# nightly); the package job turns it into deb/rpm (nfpm), AppImage, and the -# AUR/Gentoo/APT tarball, then creates the GitHub Release. +# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Pattern is still +# build once, package many — but the build is the shared build-qt-linux.yml +# (workflow_call) and it yields TWO payloads: +# * the bare binary -> qbz_${VERSION}_amd64.tar.gz (AUR / Gentoo / Flathub +# repack: ecosystems that provide Qt >= 6.8 themselves) +# * the AppDir (Qt bundled, self-contained) -> deb / rpm / AppImage, so +# distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working. # # CONTRACT (do not break): the release must keep publishing -# qbz_${VERSION}_amd64.tar.gz (dir qbz_${VERSION}_amd64/{qbz, qbz.desktop, icons/hicolor/...}) +# qbz_${VERSION}_amd64.tar.gz (dir qbz_${VERSION}_amd64/{qbz, qbzd, LICENSE, licenses/, qbz.desktop, +# com.blitzfc.qbz.metainfo.xml, qbzd.service, +# completions/, icons/hicolor/...}) # QBZ_${VERSION}_amd64.deb / QBZ-${VERSION}-1.x86_64.rpm / QBZ_${VERSION}_amd64.AppImage -# — release-aur.yml, release-gentoo.yml, qbz-apt and the website consume -# these names. +# qbzd_${VERSION}_amd64.deb / qbzd-${VERSION}-1.x86_64.rpm / qbzd-${VERSION}-linux-amd64.tar.gz +# qbz-${VERSION}-cargo-vendor.tar.xz (architecture-neutral, x86_64 workflow only) +# — release-aur.yml, release-gentoo.yml, qbz-apt, qbz-rpm, the updater +# manifest and the website consume these names. +# +# release-linux-aarch64.yml is this file with the four ARCH_* values changed, +# except that x86_64 publishes the architecture-neutral Cargo vendor asset. # # Triggers: version tags (cut from main — enforced by the ancestry guard) # + manual dispatch (guard skipped, for testing on any branch). @@ -22,7 +31,7 @@ on: workflow_dispatch: inputs: tag: - description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD with the Cargo.toml version, no Release publish." + description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD with the Cargo.toml version, no Release publish." required: false type: string push: @@ -33,17 +42,23 @@ concurrency: group: release-linux-${{ github.ref }} cancel-in-progress: false +env: + ARCH: x86_64 # build-qt-linux input + artifact suffix + DEB_ARCH: amd64 # QBZ_${V}_amd64.deb, qbz_${V}_amd64.tar.gz + RPM_ARCH: x86_64 # QBZ-${V}-1.x86_64.rpm + APPIMAGE_ARCH: amd64 # QBZ_${V}_amd64.AppImage + jobs: - build: + # Release bookkeeping first so a bad tag fails in seconds, not after a build. + version: runs-on: ubuntu-24.04 - timeout-minutes: 300 outputs: version: ${{ steps.release.outputs.version }} prerelease: ${{ steps.release.outputs.prerelease }} steps: - - name: Checkout - uses: actions/checkout@v7 + - uses: actions/checkout@v7 with: + ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 - name: Resolve release version @@ -69,8 +84,7 @@ jobs: # Release tags must be cut from main. A tag on any other commit fires # this workflow too (tags are not branch-scoped in Actions), so verify - # ancestry instead of trusting convention. Manual dispatch skips this - # (that is the testing path). + # ancestry instead of trusting convention. Manual dispatch skips this. - name: Verify tag is on main if: startsWith(github.ref, 'refs/tags/') run: | @@ -80,11 +94,10 @@ jobs: exit 1 fi - # Fail fast if the version bump was forgotten: the binary embeds the - # workspace version (About dialog, diagnostics), so a mismatched tag - # would ship a binary that reports the wrong version. + # The binary embeds the workspace version (About, diagnostics); a + # mismatched tag would ship a binary that reports the wrong version. - name: Assert Cargo.toml version matches tag - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') || inputs.tag != '' env: VERSION: ${{ steps.release.outputs.version }} run: | @@ -94,100 +107,45 @@ jobs: exit 1 fi - # The qbz_ui rustc needs ~30 GB (RSS+swap). Public runner: 16 GB RAM - # + ~4 GB stock swap; free disk by dropping preinstalled toolchains, - # then add 18 GB swap -> ~38 GB total. Same recipe as build-slint.yml. - - name: Free disk space and add swap + # The media-widget icon invariant (DesktopEntry == installed .desktop + # basename, Icon= installed) — regressed more than once, so it is a + # gate at the recipe level here and on the built packages below. + - name: Gate — desktop entry / icon invariant (recipes) + run: scripts/packaging/desktop_entry_check.sh + + # No workflow may build the frozen Slint binary: same bin name, same + # target dir, last build wins the file — a smoke against "the wrong + # qbz" is silent. + - name: Gate — no workflow builds `-p qbz` run: | - sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \ - /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \ - /usr/local/lib/node_modules || true - sudo docker system prune -af || true - if sudo fallocate -l 18G /swap_extra; then - sudo chmod 600 /swap_extra - sudo mkswap /swap_extra - sudo swapon /swap_extra - else - echo "::warning::could not allocate extra swap; build may OOM" - fi - free -h + set -euo pipefail + hits=$(grep -rnE 'cargo (build|check|test|run|install).* -p qbz([^-a-z]|$)' .github/workflows/ || true) + [ -z "$hits" ] || { echo "::error::a workflow still builds the Slint crate:"; echo "$hits"; exit 1; } - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - mold nasm \ - libasound2-dev libjack-jackd2-dev \ - libfontconfig1-dev libfreetype-dev \ - libxkbcommon-dev libwayland-dev libxcb1-dev \ - libgl1-mesa-dev libegl1-mesa-dev \ - libdbus-1-dev libssl-dev \ - libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \ - libjpeg-dev zlib1g-dev libgumbo-dev + build: + needs: version + uses: ./.github/workflows/build-qt-linux.yml + with: + arch: x86_64 + source_ref: ${{ inputs.tag || github.ref }} - # Pinned nightly: matches build-slint.yml (same measured memory - # behavior, -Z threads support). Bump both files together. - - name: Setup Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: nightly-2026-06-23 - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - workspaces: crates -> target - shared-key: slint-linux - cache-on-failure: true - # Own the amd64 `slint-linux` cache: save on main so pre-flights warm - # it and tag/PR runs restore it. build-slint.yml no longer runs, so - # deferring to it (save-if: false) left the amd64 dep cache EMPTY and - # every release/snap amd64 build fully cold. Mirrors release-linux-aarch64. - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Build (release, MIN memory tier) - env: - CARGO_INCREMENTAL: "0" - CARGO_BUILD_JOBS: "1" - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256" - CARGO_PROFILE_RELEASE_OPT_LEVEL: "2" - # Keep symbols in this build: the unstripped binary is archived for - # crash symbolication, then stripped for every shipped artifact. - CARGO_PROFILE_RELEASE_STRIP: "none" - RUSTFLAGS: "-C link-arg=-fuse-ld=mold -Z threads=1" - run: | - cargo build --release --manifest-path crates/Cargo.toml -p qbz - cp crates/target/release/qbz qbz-unstripped - strip -o qbz crates/target/release/qbz - ls -la qbz qbz-unstripped - - - name: Upload binary - uses: actions/upload-artifact@v7 - with: - name: qbz-linux-x86_64 - path: qbz - if-no-files-found: error - - # Symbolication artifact: panic backtraces in shipped binaries are - # address-only (profile strips symbols); this copy resolves them. - - name: Upload unstripped binary (symbolication) - uses: actions/upload-artifact@v7 - with: - name: qbz-linux-x86_64-unstripped - path: qbz-unstripped - retention-days: 90 - - # T15: qbzd is minutes-class (no qbz_ui compilation unit), so none of the - # desktop build's swap/tier ceremony applies. Runs on ubuntu-22.04 (not - # 24.04 like the desktop `build` job above) so the shipped binary's glibc - # floor stays <= Pi OS bookworm's 2.36 (D13, the honest-Pi-story gate) — - # the objdump step below verifies that empirically rather than trusting - # the runner's advertised glibc version. + # qbzd is minutes-class and Slint-free. Runs on ubuntu-22.04 so the shipped + # binary's glibc floor stays <= Pi OS bookworm's 2.36 (the honest-Pi-story + # gate); the objdump step verifies that empirically. build-qbzd: runs-on: ubuntu-22.04 timeout-minutes: 30 steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag || github.ref }} - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates -> target + shared-key: qbzd-linux-x86_64 + cache-on-failure: true + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }} - name: system deps run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev - name: slint-free dep-graph gate @@ -206,40 +164,48 @@ jobs: echo "glibc floor: $floor" highest=$(printf '%s\n' "$floor" "GLIBC_2.35" | sort -V | tail -1) [ "$highest" = "GLIBC_2.35" ] || { echo "::error::floor $floor exceeds 2.35"; exit 1; } - # NOTE: the T15 brief's literal YAML names this artifact - # `qbzd-linux-amd64`, but T14 (39eb12fc, already merged into this - # branch) wired the package job's Download-qbzd step below to expect - # `qbzd-linux-x86_64` — matching what already landed wins over the - # brief text (see task-15-report.md for the full note). - uses: actions/upload-artifact@v7 - with: { name: qbzd-linux-x86_64, path: crates/target/release/qbzd } + with: + name: qbzd-linux-x86_64 + path: crates/target/release/qbzd + retention-days: 7 package: - needs: [build, build-qbzd] + needs: [version, build, build-qbzd] runs-on: ubuntu-24.04 permissions: contents: write env: - VERSION: ${{ needs.build.outputs.version }} + VERSION: ${{ needs.version.outputs.version }} steps: - name: Checkout uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag || github.ref }} - - name: Download binary + - name: Install Rust toolchain for source-package vendor archive + uses: dtolnay/rust-toolchain@stable + + - name: Download bare binary uses: actions/download-artifact@v8 with: - name: qbz-linux-x86_64 + name: qbz-linux-${{ env.ARCH }} + + - name: Download AppDir + uses: actions/download-artifact@v8 + with: + name: qbz-appdir-${{ env.ARCH }} - # T14: the slint-free qbzd binary, produced by T15's build-qbzd job - # (a separate ubuntu-22.04 job, not an extension of `build` above — - # see that job for the glibc-floor rationale). This ONE artifact is - # consumed for deb/rpm (nfpm), the main AUR/Gentoo tarball below, AND - # the standalone qbzd tarball (T15 Step 2b) further down — all three - # ship the same 2.35-glibc-floor binary. - name: Download qbzd binary uses: actions/download-artifact@v8 with: - name: qbzd-linux-x86_64 + name: qbzd-linux-${{ env.ARCH }} + + - name: Unpack AppDir + run: | + tar -xf appdir.tar + chmod +x qbz qbzd + ls AppDir; ls AppDir/usr/plugins/platforms - name: Install nfpm run: | @@ -249,46 +215,55 @@ jobs: sudo apt-get install -y nfpm nfpm --version + # deb/rpm carry the AppDir tree under /opt/qbz (bin, lib, plugins, qml, + # qt.conf) and a /usr/bin/qbz symlink. RPATH ($ORIGIN/../lib, patched by + # linuxdeploy) resolves through /proc/self/exe, so the symlink needs no + # wrapper — and a wrapper exporting LD_LIBRARY_PATH would leak our Qt + # into every process qbz spawns (the login browser, xdg-open). - name: Build deb + rpm (nfpm) env: QBZ_VERSION: ${{ env.VERSION }} - QBZ_ARCH: amd64 - QBZ_BINARY: ./qbz + QBZ_ARCH: ${{ env.DEB_ARCH }} run: | - chmod +x qbz - chmod +x qbzd mkdir -p dist + ./qbzd completions bash > qbzd.bash + ./qbzd completions zsh > qbzd.zsh + ./qbzd completions fish > qbzd.fish nfpm package -f packaging/nfpm/nfpm.yaml -p deb \ - -t "dist/QBZ_${VERSION}_amd64.deb" + -t "dist/QBZ_${VERSION}_${DEB_ARCH}.deb" nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \ - -t "dist/QBZ-${VERSION}-1.x86_64.rpm" + -t "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm" + # Standalone qbzd packages (daemon-only), same staged ./qbzd. + nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p deb \ + -t "dist/qbzd_${VERSION}_${DEB_ARCH}.deb" + nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p rpm \ + -t "dist/qbzd-${VERSION}-1.${RPM_ARCH}.rpm" + ls -la dist + + # The same invariant on the ARTIFACTS: the deb/rpm must install + # /usr/share/applications/.desktop and its Icon=. + - name: Gate — desktop entry / icon invariant (packages) + run: | + sudo apt-get install -y --no-install-recommends rpm + scripts/packaging/desktop_entry_check.sh \ + --deb "dist/QBZ_${VERSION}_${DEB_ARCH}.deb" \ + --rpm "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm" \ + --appdir AppDir - name: Build AppImage run: | - # Minimal AppDir: the measured runtime deps (alsa/fontconfig/ - # freetype/png/bz2/expat/zlib) are base-system libraries on every - # target distro, so nothing is bundled beyond the binary. - APPDIR="QBZ.AppDir" - mkdir -p "$APPDIR/usr/bin" - cp qbz "$APPDIR/usr/bin/qbz" - cp packaging/linux/qbz.desktop "$APPDIR/qbz.desktop" - cp packaging/icons/256x256.png "$APPDIR/qbz.png" - cp packaging/icons/256x256.png "$APPDIR/.DirIcon" - ln -s usr/bin/qbz "$APPDIR/AppRun" - curl -fsSL -o appimagetool \ - https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage" chmod +x appimagetool - ARCH=x86_64 ./appimagetool --appimage-extract-and-run "$APPDIR" \ - "dist/QBZ_${VERSION}_amd64.AppImage" + ARCH="${ARCH}" ./appimagetool --appimage-extract-and-run AppDir \ + "dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage" + ls -la dist # Tauri-updater bridge: v1.2.x AppImage installs poll latest.json and # verify artifacts with the Tauri minisign pubkey baked into the old - # binaries. Signing the Slint AppImage with the SAME private key lets - # them auto-update straight into v2 (the updater just swaps the - # AppImage file). release-updater-manifest.yml collects the .sig into - # latest.json. The Slint app itself has no auto-updater — this exists - # only to migrate old installs. + # binaries. Signing with the SAME private key lets them auto-update + # straight into this AppImage. release-updater-manifest.yml collects + # the .sig. The app itself has no auto-updater. - name: Sign AppImage (Tauri-updater bridge) if: startsWith(github.ref, 'refs/tags/') env: @@ -298,15 +273,16 @@ jobs: npx --yes @tauri-apps/cli@^2 signer sign \ --private-key "$TAURI_SIGNING_PRIVATE_KEY" \ --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \ - "dist/QBZ_${VERSION}_amd64.AppImage" + "dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage" ls -la dist + # The BARE binary. Its consumers (AUR, Gentoo, Flathub) provide Qt. - name: Build AUR/Gentoo tarball run: | # CONTRACT: release-aur.yml, the Gentoo binary ebuild, qbz-apt and # the website hard-code this name and internal layout. - TARBALL_DIR="qbz_${VERSION}_amd64" - mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256}/apps + TARBALL_DIR="qbz_${VERSION}_${DEB_ARCH}" + mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256,512x512}/apps cp qbz "tarball-staging/${TARBALL_DIR}/qbz" chmod +x "tarball-staging/${TARBALL_DIR}/qbz" cp packaging/icons/32x32.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/32x32/apps/qbz.png" @@ -314,55 +290,57 @@ jobs: cp packaging/icons/64x64.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/64x64/apps/qbz.png" cp packaging/icons/128x128.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/128x128/apps/qbz.png" cp packaging/icons/256x256.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/256x256/apps/qbz.png" + cp packaging/icons/512x512.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/512x512/apps/qbz.png" cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop" - # Additive (T14): qbzd + its user unit + shell completions. Every - # entry above is untouched — the layout contract (header lines - # 10-14) forbids renaming/moving existing entries. AUR/Gentoo/flake - # install-line updates to pick these up are P1 (T22, per D13); the - # files are inert there until then. + cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml" + cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE" + cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses" cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd" chmod +x "tarball-staging/${TARBALL_DIR}/qbzd" cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service" mkdir -p "tarball-staging/${TARBALL_DIR}/completions" - ./qbzd completions bash > "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash" - ./qbzd completions zsh > "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh" - ./qbzd completions fish > "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish" - tar -czvf "dist/qbz_${VERSION}_amd64.tar.gz" -C tarball-staging "${TARBALL_DIR}" + cp qbzd.bash "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash" + cp qbzd.zsh "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh" + cp qbzd.fish "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish" + tar -czvf "dist/qbz_${VERSION}_${DEB_ARCH}.tar.gz" -C tarball-staging "${TARBALL_DIR}" ls -la dist - # T15 Step 2b (OD3, G0): the standalone qbzd tarball — the independent - # daemon download the owner asked for, separate from the main qbz - # tarball above. Never touches snap/Flathub/AppImage/dmg (owner - # constraint) — this only adds one more file into dist/, which the - # existing Upload-artifacts and Publish-Release-Assets steps below - # already pick up unmodified via their dist/* globs. - name: qbzd-tarball run: | - QBZD_DIR="qbzd-${VERSION}-linux-amd64" + QBZD_DIR="qbzd-${VERSION}-linux-${DEB_ARCH}" mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions" cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd" chmod +x "qbzd-tarball-staging/${QBZD_DIR}/qbzd" cp crates/qbzd/service/qbzd.service "qbzd-tarball-staging/${QBZD_DIR}/qbzd.service" - ./qbzd completions bash > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash" - ./qbzd completions zsh > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh" - ./qbzd completions fish > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish" + cp qbzd.bash "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash" + cp qbzd.zsh "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh" + cp qbzd.fish "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish" cp packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md" - tar -czvf "dist/qbzd-${VERSION}-linux-amd64.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}" + cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE" + cp -r licenses "qbzd-tarball-staging/${QBZD_DIR}/licenses" + tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}" ls -la dist + # One architecture-neutral, locked dependency archive serves the qbz + # and qbzd source packages in AUR and Gentoo. It is generated only here, + # never duplicated by the arm64 release workflow. + - name: Build Cargo vendor archive for source packages + run: scripts/packaging/cargo_vendor_release.sh "$VERSION" dist + - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: qbz-linux-packages + name: qbz-linux-packages-${{ env.ARCH }} path: dist/* + retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }} - name: Publish GitHub Release Assets if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v3 with: files: dist/* - prerelease: ${{ needs.build.outputs.prerelease }} - make_latest: ${{ needs.build.outputs.prerelease == 'true' && 'false' || 'true' }} + prerelease: ${{ needs.version.outputs.prerelease }} + make_latest: ${{ needs.version.outputs.prerelease == 'true' && 'false' || 'true' }} - name: Trigger APT repo update if: startsWith(github.ref, 'refs/tags/') @@ -372,3 +350,14 @@ jobs: repository: vicrodh/qbz-apt event-type: new-release client-payload: '{"tag": "${{ github.ref_name }}"}' + + # Our own signed RPM repo. This is intentionally required: a bad token + # must not leave the stable RPM channel stale without failing release CI. + - name: Trigger RPM repo update + if: startsWith(github.ref, 'refs/tags/') + uses: peter-evans/repository-dispatch@v4 + with: + token: ${{ secrets.APT_REPO_TOKEN }} + repository: vicrodh/qbz-rpm + event-type: new-release + client-payload: '{"tag": "${{ github.ref_name }}"}' diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index edc981aa6..556890646 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -1,16 +1,26 @@ -# macOS release — Slint binary (crates/ workspace). +# macOS release — Qt frontend (crates/ workspace, package `qbz-qt`, binary `qbz`). # -# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Builds the -# qbz binary per target, assembles a minimal QBZ.app bundle (Info.plist + -# icns + binary), ad-hoc signs it (same trust level as the unsigned Tauri -# builds), and packs a dmg with hdiutil. No Tauri updater: the .app.tar.gz -# + .sig updater assets are gone, dmg is the distribution. +# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Recipe measured on +# the Mac mini M2 (qbz-nix-docs/cicd/2026-08-25-qt-workflows-contracts/ V5b): +# * Qt from aqt (official, UNIVERSAL frameworks, min macOS 12) — NOT Homebrew: +# Homebrew splits Qt across formulae and macdeployqt cannot resolve +# @rpath/QtQml.framework in that layout (600 rpath errors, the .app dies +# with "no Qt platform plugin"). Homebrew bottles also target the runner's +# own macOS and would silently raise LSMinimumSystemVersion. +# * `macdeployqt -qmldir` is MANDATORY: a Qt binary is not self-contained. +# The Slint workflow copied the bare Mach-O into Contents/MacOS; a Qt +# .app assembled that way dies on launch on every machine that is not +# the build box. +# * CXXFLAGS=-include arm_acle.h (arm64 only): Qt 6.9.x's qyieldcpu.h calls +# __yield() without ; Apple clang 21 makes it a hard error. +# Harmless where the compiler does not care. Drop when the pin has the fix. +# * MACOSX_DEPLOYMENT_TARGET=12.0 -> `minos 12.0` in the Mach-O (rustc's +# default is 11.0, cc-rs otherwise picks the host SDK). +# * The smoke gate runs the BUNDLE offscreen with the Qt install removed. # -# MEMORY NOTE: hosted macOS runners have ~7 GB RAM; the qbz_ui rustc peaks -# ~30 GB on Linux. macOS swaps dynamically to SSD so the build may survive -# (slowly) — if it proves unreliable, point runs-on at a self-hosted runner -# on the M2 Mini instead. continue-on-error keeps a mac failure from -# blocking the Linux release. +# macOS pins 6.9 (not the Linux 6.8 LTS): the traffic-lights fix uses +# Window paddings + SafeArea (Qt 6.9), and Qt is always bundled here so a +# higher floor costs no reach. `vars.QT_VERSION_MACOS` overrides. name: release-macos @@ -18,7 +28,7 @@ on: workflow_dispatch: inputs: tag: - description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD with the Cargo.toml version, no Release publish." + description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD with the Cargo.toml version, no Release publish." required: false type: string push: @@ -29,27 +39,39 @@ concurrency: group: release-macos-${{ github.ref }} cancel-in-progress: false +env: + QT_VERSION: ${{ vars.QT_VERSION_MACOS || '6.9.3' }} + jobs: build-macos: strategy: + fail-fast: false matrix: include: - target: aarch64-apple-darwin + runner: macos-latest artifact: qbz-macos-aarch64 arch: aarch64 + # See header: Qt 6.9 header bug on Apple clang 21 (arm64 code path). + cxxflags: "-include arm_acle.h" - target: x86_64-apple-darwin + # macos-latest is arm64; Intel needs an Intel runner (arm_acle.h + # is ARM-only, so no CXXFLAGS here). macos-26-intel so both legs + # carry Xcode 26's actool for the Icon Composer app icon. + runner: macos-26-intel artifact: qbz-macos-x86_64 # Historical dmg naming: intel assets ship as "x64". arch: x64 - runs-on: macos-latest - timeout-minutes: 360 - continue-on-error: true + cxxflags: "" + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 permissions: contents: write steps: - name: Checkout uses: actions/checkout@v7 with: + ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 - name: Resolve release version @@ -81,7 +103,7 @@ jobs: fi - name: Assert Cargo.toml version matches tag - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') || inputs.tag != '' env: VERSION: ${{ steps.release.outputs.version }} run: | @@ -91,35 +113,48 @@ jobs: exit 1 fi - # Same pinned nightly as the Linux builders (-Z threads support, - # consistent measured behavior). Bump all release workflows together. - - name: Setup Rust - uses: dtolnay/rust-toolchain@master + # Without qtshadertools: the committed .qsb ship (see build-qt-linux.yml). + - name: Install Qt ${{ env.QT_VERSION }} (universal) + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: mac + target: desktop + arch: clang_64 + cache: true + + - name: Setup Rust (stable) + uses: dtolnay/rust-toolchain@stable with: - toolchain: nightly-2026-06-23 targets: ${{ matrix.target }} - name: Rust cache uses: Swatinem/rust-cache@v2 with: workspaces: crates -> target - shared-key: slint-macos-${{ matrix.target }} + shared-key: qt-macos-${{ matrix.target }}-${{ env.QT_VERSION }} cache-on-failure: true - save-if: true + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }} - # MIN memory tier — the 7 GB hosted runner relies on macOS dynamic - # swap for the qbz_ui rustc peak. Binary is stripped by the workspace - # profile (strip = "symbols"), which rustc applies correctly to Mach-O. - - name: Build (release, MIN memory tier) + # NEVER `-p qbz` (the frozen Slint binary shares the bin name). + - name: Build (release) env: CARGO_INCREMENTAL: "0" - CARGO_BUILD_JOBS: "1" - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256" - CARGO_PROFILE_RELEASE_OPT_LEVEL: "2" - RUSTFLAGS: "-Z threads=1" + CMAKE_PREFIX_PATH: ${{ env.QT_ROOT_DIR }} + QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake + CXXFLAGS: ${{ matrix.cxxflags }} + MACOSX_DEPLOYMENT_TARGET: "12.0" run: | - cargo build --release --manifest-path crates/Cargo.toml \ - -p qbz --target ${{ matrix.target }} + python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml \ + -p qbz-qt --target ${{ matrix.target }} + otool -l "crates/target/${{ matrix.target }}/release/qbz" | grep -A4 LC_BUILD_VERSION | grep minos + + - name: Slint-free dep-graph gate (qbz-qt) + run: | + set -euo pipefail + hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbz-qt -e normal \ + | grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true) + [ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; } - name: Assemble QBZ.app env: @@ -131,6 +166,32 @@ jobs: cp "crates/target/${{ matrix.target }}/release/qbz" "$APP/Contents/MacOS/qbz" chmod +x "$APP/Contents/MacOS/qbz" cp packaging/icons/icon.icns "$APP/Contents/Resources/icon.icns" + cp LICENSE "$APP/Contents/Resources/LICENSE.txt" + cp -r licenses "$APP/Contents/Resources/licenses" + # The Liquid Glass app icon (packaging/icons/QBZ.icon, Icon Composer + # source, issue #712): compiled by Xcode 26's actool into + # Assets.car + CFBundleIconName=QBZ. macOS 26 renders the layered + # icon; older macOS and a runner without Xcode 26 fall back to + # icon.icns via CFBundleIconFile, which is always written. + ICON_NAME_KEY="" + if xcrun --find actool >/dev/null 2>&1 \ + && [[ "$(xcodebuild -version | head -1 | grep -oE '[0-9]+' | head -1)" -ge 26 ]]; then + xcrun actool packaging/icons/QBZ.icon \ + --compile "$APP/Contents/Resources" \ + --app-icon QBZ \ + --platform macosx --minimum-deployment-target 12.0 \ + --include-all-app-icons \ + --output-partial-info-plist icon-partial.plist \ + --errors --warnings --notices 2>&1 | tee actool.log + test -f "$APP/Contents/Resources/Assets.car" \ + || { echo "::error::actool produced no Assets.car"; exit 1; } + /usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' icon-partial.plist \ + || { echo "::error::actool partial plist has no CFBundleIconName"; exit 1; } + ICON_NAME_KEY='CFBundleIconNameQBZ' + echo "app icon: Icon Composer source compiled (Assets.car)" + else + echo "::warning::Xcode 26 actool not available — shipping icon.icns only" + fi cat > "$APP/Contents/Info.plist" < @@ -142,9 +203,10 @@ jobs: CFBundleDisplayNameQBZ CFBundleExecutableqbz CFBundleIconFileicon + ${ICON_NAME_KEY} CFBundleShortVersionString${VERSION} CFBundleVersion${VERSION} - LSMinimumSystemVersion11.0 + LSMinimumSystemVersion12.0 NSHighResolutionCapable LSApplicationCategoryTypepublic.app-category.music NSSupportsAutomaticGraphicsSwitching @@ -159,10 +221,75 @@ jobs: PLIST - # Ad-hoc signature: required for arm64 Macs to launch at all; - # matches the trust level of the previous unsigned Tauri builds. - codesign --force --deep -s - "$APP" - codesign --verify --verbose "$APP" + + # Bundle the Qt frameworks, the cocoa platform plugin and the QML + # modules the app imports (Effects, labs.qmlmodels, Controls, ...). + # Measured: 21 s, ~225 MB. The optional sqldrivers plugins (iodbc, + # mimer, libpq) are the only deploy "errors" and nothing uses Qt SQL. + - name: macdeployqt + run: | + set -euo pipefail + APP="dist/QBZ.app" + # Preserve the deployer's exit status. The old `... | grep ERROR || + # true` made every macdeployqt failure look successful because the + # final `true` swallowed the whole pipeline's status. + set +e + "$QT_ROOT_DIR/bin/macdeployqt" "$APP" -qmldir=crates/qbz-qt/qml -verbose=1 2>&1 | tee macdeploy.log + deploy_status=${PIPESTATUS[0]} + set -e + grep -E 'ERROR' macdeploy.log || true + if (( deploy_status != 0 )); then + echo "::error::macdeployqt failed with exit code $deploy_status" + exit "$deploy_status" + fi + rm -rf "$APP/Contents/PlugIns/sqldrivers" + test -f "$APP/Contents/PlugIns/platforms/libqcocoa.dylib" + test -d "$APP/Contents/Resources/qml/QtQuick/Effects" + test -f "$APP/Contents/Resources/qt.conf" + # Every Qt reference must now be bundle-relative. Qt 6.9's + # macdeployqt leaves the install names as @rpath/Qt*.framework and + # resolves them through an LC_RPATH of @executable_path/../Frameworks + # (measured 2026-08-29, macos-26 runner) — so the assert is: no + # ABSOLUTE Qt path survives, and that rpath is present. The offscreen + # smoke below (Qt install hidden) is the end-to-end proof. + if otool -L "$APP/Contents/MacOS/qbz" | grep -E 'Qt.*\.framework' | grep -Eq '^\s*/'; then + otool -L "$APP/Contents/MacOS/qbz"; echo "::error::binary still links Qt by absolute path outside the bundle"; exit 1 + fi + if ! otool -l "$APP/Contents/MacOS/qbz" | grep -A2 LC_RPATH | grep -Eq '@(executable|loader)_path/\.\./Frameworks'; then + otool -l "$APP/Contents/MacOS/qbz" | grep -A2 LC_RPATH; echo "::error::no bundle-relative LC_RPATH for the Qt frameworks"; exit 1 + fi + # offscreen platform plugin: only for the smoke below (macdeployqt + # deploys cocoa alone). Removed again before signing/packing. + cp "$QT_ROOT_DIR/plugins/platforms/libqoffscreen.dylib" "$APP/Contents/PlugIns/platforms/" + du -sh "$APP" + + # The bundle must initialize AND survive without the Qt SDK. The helper + # uses a private native macOS home (XDG alone is ineffective), restores + # the SDK on failure/cancellation and reaps the observed process group. + - name: Offscreen smoke (the bundle that ships) + run: | + set -euo pipefail + APP="dist/QBZ.app" + python3 scripts/packaging/smoke_release.py "$APP/Contents/MacOS/qbz" \ + --hide-qt "$QT_ROOT_DIR" --seconds 45 --log smoke.log + rm -f "$APP/Contents/PlugIns/platforms/libqoffscreen.dylib" + + - name: Upload deployed startup evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: qbz-macos-smoke-${{ matrix.arch }} + path: smoke.log* + if-no-files-found: ignore + retention-days: 14 + + # Ad-hoc signature: required for arm64 Macs to launch at all. AFTER + # macdeployqt (it rewrites install names) and after the smoke plugin + # is gone, so the seal matches what ships. + - name: Sign + run: | + codesign --force --deep -s - "dist/QBZ.app" + codesign --verify --verbose "dist/QBZ.app" - name: Build dmg env: @@ -174,10 +301,9 @@ jobs: ls -la dist # Tauri-updater bridge: v1.2.x installs poll latest.json and verify - # with the Tauri minisign pubkey baked into the old binaries. The - # updater expects QBZ_${ARCH}.app.tar.gz — keep producing it, signed - # with the SAME key, so old installs update straight into the Slint - # .app. release-updater-manifest.yml collects the .sig files. + # with the Tauri minisign pubkey baked into the old binaries. Keep + # producing QBZ_${ARCH}.app.tar.gz signed with the SAME key so old + # installs update straight into this .app. - name: Package .app.tar.gz + sign (Tauri-updater bridge) if: startsWith(github.ref, 'refs/tags/') env: @@ -196,13 +322,13 @@ jobs: uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact }} + retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }} path: | dist/*.dmg dist/*.app.tar.gz dist/*.app.tar.gz.sig - # Attaches to the Release created by release-linux.yml; same - # prerelease logic (the old workflow hardcoded prerelease: true). + # Attaches to the Release created by release-linux.yml. - name: Publish GitHub Release Assets if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v3 diff --git a/.github/workflows/release-snap.yml b/.github/workflows/release-snap.yml index e28556373..c8d66dbd9 100644 --- a/.github/workflows/release-snap.yml +++ b/.github/workflows/release-snap.yml @@ -1,27 +1,21 @@ -name: Release Snap (Slint, all-hosted) +name: release-snap -# All-hosted Snap pipeline for the native (Slint) qbz — BOTH arches. NOTHING -# runs on the dev box, so the lack of systemd/snapd there does not matter. Two -# independent tracks, each = build-binary -> dump-pack -> publish: -# amd64: build-binary (ubuntu-24.04, MIN-tier + 18 GB swap) -> snap -# (packaging/snap-slint/snapcraft.yaml, plugin: dump). -# arm64: build-binary-arm64 (native ubuntu-24.04-arm, free for public repos, -# reuses release-linux-aarch64.yml's cargo cache) -> snap-arm64 -# (packaging/snap-slint/snapcraft-arm64.yaml, plugin: dump). -# The heavy compile is on free public runners; the snap pack (dump, no compile) -# runs in seconds on a runner that has snapd. This CI upload is the SOLE publish -# path — no Launchpad / "Build from GitHub" is used (the root snapcraft.yaml is -# reference/local-dev only and is removed from the CI working copy below). +# All-hosted Snap pipeline — BOTH arches. Each track = build (the shared +# build-qt-linux.yml, which yields the self-contained AppDir) -> dump-pack -> +# publish. No compilation in snapcraft; the pack runs in seconds on a runner +# with snapd. This CI upload is the SOLE publish path: the Launchpad / +# "Build from GitHub" source recipe (root snapcraft.yaml) is DELETED — core24 +# has Qt 6.4.2 and the tree needs 6.8, so a source build there cannot even +# resolve its dependencies (it OOM'd on their amd64 fleet in the Slint era +# anyway). # -# NOTE: build-binary* duplicates the deb/rpm builders' compile. When the release -# pipeline is unified (build-once -> fan out to deb/rpm/appimage/snap/flatpak), -# these jobs become `download-artifact` of the shared per-arch binaries instead. +# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. on: workflow_dispatch: inputs: tag: - description: "Release tag to package (e.g. v1.2.16). Defaults to the pushed tag." + description: "Release tag to package (e.g. v2.1.0). Defaults to the pushed tag." required: false type: string channel: @@ -30,6 +24,11 @@ on: default: edge type: choice options: [edge, beta, candidate, stable] + publish: + description: "Publish to the selected Snap Store channel" + required: false + default: false + type: boolean push: tags: - "v*" @@ -40,186 +39,133 @@ concurrency: group: release-snap-${{ github.ref }} cancel-in-progress: false +env: + # snapcore/action-{build,publish}@v1 still declares node20. Hosted runners + # already execute them successfully on Node 24; make that migration explicit + # before the Node 20 runtime is removed on 2026-09-16. + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: - build-binary: + version: runs-on: ubuntu-24.04 - timeout-minutes: 180 + outputs: + version: ${{ steps.release.outputs.version }} steps: - uses: actions/checkout@v7 - - - name: Free disk + swap (sized for the ~30 GB qbz_ui peak) - run: | - sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \ - /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \ - /usr/local/lib/node_modules || true - sudo docker image prune --all --force || true - ( sudo fallocate -l 18G /swap_extra \ - && sudo chmod 600 /swap_extra \ - && sudo mkswap /swap_extra \ - && sudo swapon /swap_extra ) || echo "extra swap skipped (non-fatal)" - free -h; df -h / - - - name: System dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config cmake clang libclang-dev mold nasm \ - libasound2-dev libjack-jackd2-dev \ - libfontconfig1-dev libfreetype-dev \ - libxkbcommon-dev libwayland-dev libxcb1-dev \ - libgl1-mesa-dev libegl1-mesa-dev \ - libdbus-1-dev libssl-dev \ - libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \ - libjpeg-dev zlib1g-dev libgumbo-dev - - - name: Rust toolchain (pinned dated nightly) - uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-06-23 + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 - - name: Cargo cache (Swatinem/rust-cache) - uses: Swatinem/rust-cache@v2 - with: - workspaces: crates -> target - shared-key: slint-linux - cache-on-failure: true - # share the SAME cache as build-slint.yml (same shared-key) so the - # heavy qbz_ui rlib is reused across both workflows. - save-if: false - - - name: Build qbz (memory-tuned MIN tier, swap-backed) + - name: Resolve and validate package version + id: release env: - CARGO_INCREMENTAL: '0' - CARGO_BUILD_JOBS: '1' - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256' - CARGO_PROFILE_RELEASE_OPT_LEVEL: '2' - RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1' + DISPATCH_TAG: ${{ inputs.tag }} + DISPATCH_PUBLISH: ${{ inputs.publish }} run: | - cargo build --release --manifest-path crates/Cargo.toml -p qbz - strip crates/target/release/qbz || true - ls -lh crates/target/release/qbz + set -euo pipefail + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + elif [[ -n "${{ inputs.tag }}" ]]; then + VERSION="${{ inputs.tag }}" + VERSION="${VERSION#v}" + else + VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = "\(.*\)"/\1/')" + fi + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid Snap version: $VERSION" + exit 1 + fi + CARGO_VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = "\(.*\)"/\1/')" + if [[ "$CARGO_VERSION" != "$VERSION" ]]; then + echo "::error::crates/Cargo.toml workspace version ($CARGO_VERSION) != requested version ($VERSION)." + exit 1 + fi + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$DISPATCH_PUBLISH" == true && -z "$DISPATCH_TAG" ]]; then + echo "::error::Manual publishing requires an explicit release tag" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Upload binary artifact - uses: actions/upload-artifact@v7 - with: - name: qbz-bin-amd64 - path: crates/target/release/qbz - if-no-files-found: error - - build-binary-arm64: - # Native arm64 build (mirrors release-linux-aarch64.yml). Reuses that - # workflow's cargo cache (shared-key slint-linux-aarch64) so it does not - # recompile qbz_ui from cold when the aarch64 build already ran. - runs-on: ubuntu-24.04-arm - timeout-minutes: 180 - steps: - - uses: actions/checkout@v7 - - - name: Free disk + swap (sized for the ~30 GB qbz_ui peak) - run: | - sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \ - /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \ - /usr/local/lib/node_modules || true - sudo docker image prune --all --force || true - ( sudo fallocate -l 18G /swap_extra \ - && sudo chmod 600 /swap_extra \ - && sudo mkswap /swap_extra \ - && sudo swapon /swap_extra ) || echo "extra swap skipped (non-fatal)" - free -h; df -h / - - - name: System dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config cmake clang libclang-dev mold nasm \ - libasound2-dev libjack-jackd2-dev \ - libfontconfig1-dev libfreetype-dev \ - libxkbcommon-dev libwayland-dev libxcb1-dev \ - libgl1-mesa-dev libegl1-mesa-dev \ - libdbus-1-dev libssl-dev \ - libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \ - libjpeg-dev zlib1g-dev libgumbo-dev - - - name: Rust toolchain (pinned dated nightly) - uses: dtolnay/rust-toolchain@master - with: - toolchain: nightly-2026-06-23 - - - name: Cargo cache (Swatinem/rust-cache) - uses: Swatinem/rust-cache@v2 - with: - workspaces: crates -> target - shared-key: slint-linux-aarch64 - cache-on-failure: true - # reuse release-linux-aarch64.yml's cache (same shared-key); do not - # clobber it from here. - save-if: false - - - name: Build qbz (memory-tuned MIN tier, swap-backed) + - name: Verify tag is on main + if: startsWith(github.ref, 'refs/tags/') || inputs.publish env: - CARGO_INCREMENTAL: '0' - CARGO_BUILD_JOBS: '1' - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256' - CARGO_PROFILE_RELEASE_OPT_LEVEL: '2' - # No x86 target-features here (aarch64 baseline has NEON). - RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1' + VERSION: ${{ steps.release.outputs.version }} run: | - cargo build --release --manifest-path crates/Cargo.toml -p qbz - strip crates/target/release/qbz || true - ls -lh crates/target/release/qbz + set -euo pipefail + tag="v${VERSION}" + git fetch origin main --tags + if ! git show-ref --verify --quiet "refs/tags/${tag}"; then + echo "::error::Release tag ${tag} does not exist." + exit 1 + fi + tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")" + checkout_sha="$(git rev-parse HEAD)" + if [[ "$tag_sha" != "$checkout_sha" ]]; then + echo "::error::Checked-out commit does not match ${tag}." + exit 1 + fi + if ! git merge-base --is-ancestor "$tag_sha" origin/main; then + echo "::error::Tag ${tag} is not on main. Releases are cut from main only." + exit 1 + fi - - name: Upload binary artifact - uses: actions/upload-artifact@v7 - with: - name: qbz-bin-arm64 - path: crates/target/release/qbz - if-no-files-found: error + build-amd64: + needs: version + uses: ./.github/workflows/build-qt-linux.yml + with: + arch: x86_64 + source_ref: ${{ inputs.tag || github.ref }} + + build-arm64: + needs: version + uses: ./.github/workflows/build-qt-linux.yml + with: + arch: aarch64 + source_ref: ${{ inputs.tag || github.ref }} snap: - needs: build-binary + needs: [version, build-amd64] runs-on: ubuntu-24.04 - # 30 min was too tight: the v2.0.0 tag run got axed mid-snapcraft (LXD - # setup + platform snaps + pack ran past it). The job does no compilation, - # but snapcraft's container bootstrap alone is highly variable on public - # runners — give it real headroom. + # No compilation here; the headroom is for snapcraft's LXD bootstrap, + # which is highly variable on hosted runners (a 30 min cap got the + # v2.0.0 run axed mid-pack). timeout-minutes: 90 # Credentials at job level so the publish step can gate on them: an empty / - # unset secret SKIPS publish instead of failing the whole run (the snap is - # still built and uploaded as an artifact). + # unset secret SKIPS publish instead of failing the whole run. env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + VERSION: ${{ needs.version.outputs.version }} steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag || github.ref }} - - name: Download prebuilt binary + - name: Download AppDir uses: actions/download-artifact@v8 with: - name: qbz-bin-amd64 - path: dl + name: qbz-appdir-x86_64 - - name: Stage snap payload (binary + desktop + icon) and recipe + - name: Stage snap payload (AppDir tree + desktop + icon) and recipe run: | set -eux - TAG="${{ github.event.inputs.tag || github.ref_name }}" - VER="${TAG#v}" + VER="$VERSION" + tar -xf appdir.tar mkdir -p snap-payload - install -m755 dl/qbz snap-payload/qbz + cp -a AppDir/usr/bin AppDir/usr/lib AppDir/usr/plugins AppDir/usr/qml snap-payload/ cp packaging/snap/snap/gui/qbz.desktop snap-payload/qbz.desktop cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png - # Place the dump recipe where snapcraft looks for it, and stamp version. + cp LICENSE snap-payload/LICENSE + cp -r licenses snap-payload/licenses + test -f snap-payload/licenses/Apache-2.0-dst-decoder.txt mkdir -p snap - cp packaging/snap-slint/snapcraft.yaml snap/snapcraft.yaml + cp packaging/snap-qt/snapcraft.yaml snap/snapcraft.yaml sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml - # The repo ROOT also carries snapcraft.yaml (the SOURCE-build recipe - # for Snap Store remote builders) and snapcraft resolves it over - # snap/snapcraft.yaml — the v2.0.0 run recompiled the whole - # workspace inside LXD and OOM'd the container. Remove the root - # recipes from the CI working copy so only the dump recipe exists. + # Belt and braces: no root recipe may shadow snap/snapcraft.yaml. rm -f snapcraft.yaml .snapcraft.yaml echo "Built snap version: ${VER}" - cat snap/snapcraft.yaml | head -20 + head -20 snap/snapcraft.yaml - - name: Build the snap (no compilation — dump of the prebuilt binary) + - name: Build the snap (dump of the prebuilt AppDir) id: build uses: snapcore/action-build@v1 @@ -229,51 +175,52 @@ jobs: name: qbz-snap-amd64 path: ${{ steps.build.outputs.snap }} if-no-files-found: error + retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }} - name: Publish to the Snap Store (skipped when no store credentials) - if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' }} + if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' && (startsWith(github.ref, 'refs/tags/') || inputs.publish) }} uses: snapcore/action-publish@v1 with: snap: ${{ steps.build.outputs.snap }} release: ${{ github.event.inputs.channel || 'edge' }} snap-arm64: - needs: build-binary-arm64 + needs: [version, build-arm64] runs-on: ubuntu-24.04-arm - # No compilation here (plugin: dump); the headroom is for snapcraft's LXD - # bootstrap, which is variable on hosted runners. timeout-minutes: 90 env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + VERSION: ${{ needs.version.outputs.version }} steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag || github.ref }} - - name: Download prebuilt binary + - name: Download AppDir uses: actions/download-artifact@v8 with: - name: qbz-bin-arm64 - path: dl + name: qbz-appdir-aarch64 - - name: Stage snap payload (binary + desktop + icon) and recipe + - name: Stage snap payload (AppDir tree + desktop + icon) and recipe run: | set -eux - TAG="${{ github.event.inputs.tag || github.ref_name }}" - VER="${TAG#v}" + VER="$VERSION" + tar -xf appdir.tar mkdir -p snap-payload - install -m755 dl/qbz snap-payload/qbz + cp -a AppDir/usr/bin AppDir/usr/lib AppDir/usr/plugins AppDir/usr/qml snap-payload/ cp packaging/snap/snap/gui/qbz.desktop snap-payload/qbz.desktop cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png - # Place the arm64 dump recipe where snapcraft looks for it, stamp version. + cp LICENSE snap-payload/LICENSE + cp -r licenses snap-payload/licenses + test -f snap-payload/licenses/Apache-2.0-dst-decoder.txt mkdir -p snap - cp packaging/snap-slint/snapcraft-arm64.yaml snap/snapcraft.yaml + cp packaging/snap-qt/snapcraft-arm64.yaml snap/snapcraft.yaml sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml - # Remove the root source-build recipe from the CI working copy so - # snapcraft resolves ONLY the dump recipe (see the amd64 job). rm -f snapcraft.yaml .snapcraft.yaml echo "Built snap version: ${VER} (arm64)" - cat snap/snapcraft.yaml | head -20 + head -20 snap/snapcraft.yaml - - name: Build the snap (no compilation — dump of the prebuilt binary) + - name: Build the snap (dump of the prebuilt AppDir) id: build uses: snapcore/action-build@v1 @@ -283,9 +230,10 @@ jobs: name: qbz-snap-arm64 path: ${{ steps.build.outputs.snap }} if-no-files-found: error + retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }} - name: Publish to the Snap Store (skipped when no store credentials) - if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' }} + if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' && (startsWith(github.ref, 'refs/tags/') || inputs.publish) }} uses: snapcore/action-publish@v1 with: snap: ${{ steps.build.outputs.snap }} diff --git a/.github/workflows/release-updater-manifest.yml b/.github/workflows/release-updater-manifest.yml index 2ee9bac5a..7ae425855 100644 --- a/.github/workflows/release-updater-manifest.yml +++ b/.github/workflows/release-updater-manifest.yml @@ -18,11 +18,19 @@ jobs: # Only run on successful tag-triggered builds (not manual dispatches) if: > github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && startsWith(github.event.workflow_run.head_branch, 'v') runs-on: ubuntu-latest permissions: contents: write steps: + - name: Checkout triggering release commit + uses: actions/checkout@v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + - name: Resolve version from tag id: version # `head_branch` on a `workflow_run` event is attacker-controllable @@ -31,14 +39,29 @@ jobs: # the shape before letting it flow into shell or $GITHUB_OUTPUT. env: HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail - if ! printf '%s' "$HEAD_BRANCH" | grep -Eq '^v[0-9][0-9A-Za-z._-]{0,63}$'; then + if ! printf '%s' "$HEAD_BRANCH" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$'; then echo "::error::Rejected head_branch for manifest job: $HEAD_BRANCH" exit 1 fi TAG="$HEAD_BRANCH" VERSION="${TAG#v}" + git fetch origin main --tags + if ! git show-ref --verify --quiet "refs/tags/${TAG}"; then + echo "::error::Release tag ${TAG} does not exist" + exit 1 + fi + TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")" + if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then + echo "::error::workflow_run SHA does not match ${TAG}" + exit 1 + fi + if ! git merge-base --is-ancestor "$TAG_SHA" origin/main; then + echo "::error::${TAG} is not on main" + exit 1 + fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -48,7 +71,12 @@ jobs: TAG: ${{ steps.version.outputs.tag }} run: | # Wait up to 20 minutes for .sig files from all platforms - REQUIRED_SIGS=("aarch64.app.tar.gz.sig" "x64.app.tar.gz.sig" "AppImage.sig") + REQUIRED_SIGS=( + "aarch64.app.tar.gz.sig" + "x64.app.tar.gz.sig" + "amd64.AppImage.sig" + "aarch64.AppImage.sig" + ) MAX_ATTEMPTS=40 SLEEP_SECONDS=30 @@ -69,8 +97,8 @@ jobs: fi if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then - echo "Warning: Not all .sig files found after $MAX_ATTEMPTS attempts. Generating manifest with available assets." - break + echo "::error::Not all updater signatures appeared after $MAX_ATTEMPTS attempts; refusing to replace latest.json with a partial manifest." + exit 1 fi echo "Attempt $attempt/$MAX_ATTEMPTS: waiting for all .sig files..." @@ -158,6 +186,13 @@ jobs: '. + {"linux-aarch64": {"signature": $sig, "url": $url}}') fi + PLATFORM_COUNT=$(echo "$PLATFORMS" | jq 'length') + if [ "$PLATFORM_COUNT" -ne 4 ]; then + echo "::error::Expected four updater platforms, generated $PLATFORM_COUNT." + echo "$PLATFORMS" | jq . + exit 1 + fi + # Assemble final latest.json jq -n \ --arg version "$VERSION" \ @@ -175,7 +210,4 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.version.outputs.tag }} run: | - # Delete existing latest.json if present (from a previous run) - gh release delete-asset "$TAG" latest.json --repo "${{ github.repository }}" --yes 2>/dev/null || true - # Upload the new one - gh release upload "$TAG" latest.json --repo "${{ github.repository }}" + gh release upload "$TAG" latest.json --repo "${{ github.repository }}" --clobber diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index c13dd491b..276495d76 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -106,13 +106,13 @@ jobs: foreach ($f in "qbz.exe", "platforms\qwindows.dll", "qml\QtQuick\Controls\Basic\qmldir", "imageformats\qsvg.dll", "LICENSE.txt") { if (-not (Test-Path "$root\$f")) { throw "installed layout is missing $f" } } - foreach ($protocol in "qbz", "qobuzapp") { + foreach ($protocol in "qbz") { if (-not (Test-Path "HKCU:\Software\Classes\$protocol")) { throw "$protocol protocol key missing" } } if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { throw "Start-menu shortcut missing" } $uninstall = Start-Process msiexec.exe -ArgumentList "/x `"$msi`" /qn /norestart" -Wait -PassThru if ($uninstall.ExitCode -ne 0) { throw "synthetic MSI uninstall failed: $($uninstall.ExitCode)" } - foreach ($protocol in "qbz", "qobuzapp") { + foreach ($protocol in "qbz") { if (Test-Path "HKCU:\Software\Classes\$protocol") { throw "$protocol protocol key survived uninstall" } } @@ -171,9 +171,11 @@ jobs: fi # MSI ProductVersion must be numeric x.y.z; the full string goes to ARPCOMMENTS. MSI_VERSION="$(echo "$VERSION" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "msi_version=$MSI_VERSION" >> "$GITHUB_OUTPUT" - echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" + { + echo "version=$VERSION" + echo "msi_version=$MSI_VERSION" + echo "prerelease=$PRERELEASE" + } >> "$GITHUB_OUTPUT" - name: Verify tag is on main if: startsWith(github.ref, 'refs/tags/') @@ -238,7 +240,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: crates -> target - shared-key: qt-windows-x86_64 + shared-key: qt-windows-x86_64-${{ env.QT_VERSION }} cache-on-failure: true save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }} @@ -258,7 +260,7 @@ jobs: # the link and let the PDB carry them. CARGO_PROFILE_RELEASE_STRIP: "none" run: | - cargo build --release --manifest-path crates/Cargo.toml \ + python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml \ -p qbz-qt --target "$TARGET" ls -la "crates/target/$TARGET/release/qbz.exe" "crates/target/$TARGET/release/qbz.pdb" @@ -286,7 +288,7 @@ jobs: # (the committed .qsb carry HLSL 50 *source*; d3d11 compiles it at runtime). # Exit code is checked (pipefail); the grep is a separate, informational pass. "$QT_ROOT_DIR/bin/windeployqt.exe" --release --qmldir crates/qbz-qt/qml \ - --no-translations --no-opengl-sw "$DIST/qbz.exe" 2>&1 | tee windeploy.log + --no-translations --no-opengl-sw --no-compiler-runtime "$DIST/qbz.exe" 2>&1 | tee windeploy.log grep -iE 'error|warning' windeploy.log || true # Nothing uses Qt SQL, Qt TLS (rustls) or network-information. rm -rf "$DIST/sqldrivers" "$DIST/tls" "$DIST/networkinformation" @@ -319,43 +321,14 @@ jobs: # Hard fail, not a warning — plan A task 14 adds licenses/. if [ ! -d licenses ]; then echo "::error::licenses/ (Qt LGPL notice) is missing; refusing to package Qt without it"; exit 1; fi cp -r licenses "$DIST/licenses" - # offscreen platform plugin: smoke only; removed before packaging. - cp "$QT_ROOT_DIR/plugins/platforms/qoffscreen.dll" "$DIST/platforms/" du -sh "$DIST" - # THE gate: the deployed directory boots without the Qt install. Pass = - # >= 10 log lines, zero QML/loader complaints, core initialized (login - # screen reached; `home published` needs a session CI does not have). - # This is the step that catches the qrc-backslash bug (W1) as `Cannot open`. - # pwsh, not Git-Bash: MSYS `kill $!` cannot reliably stop a native - # Windows process (Sol R15), and a survivor locks the deploy dir. - - name: Offscreen smoke (the directory that ships) + # Both products harvest this exact app-local runtime. Audit every EXE, + # DLL and plugin (normal + delay imports) before making either artifact. + - name: Deploy official MSVC CRT app-local and audit imports shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $dist = "dist\qbz" - $qtRoot = $env:QT_ROOT_DIR - $qtHidden = "$qtRoot.hidden" - # Move-Item accepts a full destination path. Rename-Item's NewName - # parameter does not, so using it here can leave the Qt install live - # and turn this deployed-tree gate into a false positive. - Move-Item -LiteralPath $qtRoot -Destination $qtHidden - foreach ($v in "QT_PLUGIN_PATH","QML_IMPORT_PATH","QML2_IMPORT_PATH","QT_ROOT_DIR") { Remove-Item "Env:$v" -ErrorAction SilentlyContinue } - $env:QT_QPA_PLATFORM = "offscreen"; $env:RUST_LOG = "info" - $p = Start-Process -FilePath "$dist\qbz.exe" -RedirectStandardOutput smoke.out -RedirectStandardError smoke.err -PassThru -NoNewWindow - Start-Sleep -Seconds 45 - if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force; Start-Sleep 1 } - Move-Item -LiteralPath $qtHidden -Destination $qtRoot - Get-Content smoke.out, smoke.err | Set-Content smoke.log - $lines = (Get-Content smoke.log | Measure-Object -Line).Lines - Write-Host "smoke log: $lines lines" - if ($lines -lt 10) { Get-Content smoke.log; Write-Host "::error::the app did not start"; exit 1 } - $pat = 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|non-existent property|failed to load component|is not installed|could not be found|0xc0000135|STATUS_DLL_NOT_FOUND|This application failed to start' - $errs = Get-Content smoke.log | Where-Object { $_ -notmatch 'propertyCache' } | Select-String -Pattern $pat - if ($errs) { $errs | Select-Object -First 20; Write-Host "::error::$($errs.Count) QML/loader complaint(s) in the smoke log"; exit 1 } - if (-not (Select-String -Path smoke.log -Pattern 'QbzCore initialized' -Quiet)) { Get-Content smoke.log -Tail 20; Write-Host "::error::never reached QbzCore init"; exit 1 } - Remove-Item "$dist\platforms\qoffscreen.dll" - Write-Host "smoke OK (0 QML complaints, core initialized)" + run: ./scripts/packaging/deploy_windows_crt.ps1 -Dist dist/qbz + # D4 = unsigned, so this is a no-op until a cert exists (HAVE_CERT is # resolved at workflow level — see the env block at the top). PLACEHOLDER # for an OV cert in a PFX: cloud-issued keys (Azure Trusted Signing, @@ -400,37 +373,43 @@ jobs: -o "dist/QBZ_${VERSION}_x64.msi" ls -la dist - # The MSI is exercised, not just built: install per-user (no UAC), assert - # the layout + registry, launch the INSTALLED exe offscreen, uninstall, - # assert nothing is left. `wix msi validate` is deliberately NOT run: - # per-user harvested components fail ICE38 by WiX design (issue #8633). - - name: Install / launch / uninstall the MSI (runner smoke) + # Product smoke uses qwindows from the installed payload with an isolated + # profile/PATH. A VS runner still does not certify a clean Windows image. + - name: Install / upgrade / launch / uninstall the MSI (runner smoke) shell: pwsh env: VERSION: ${{ steps.release.outputs.version }} run: | + $ErrorActionPreference = 'Stop' $msi = (Resolve-Path "dist\QBZ_$($env:VERSION)_x64.msi").Path - $p = Start-Process msiexec -ArgumentList "/i `"$msi`" /qn /l*v install.log" -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; Write-Host "::error::msiexec /i exit $($p.ExitCode)"; exit 1 } $root = "$env:LOCALAPPDATA\Programs\QBZ" - foreach ($f in "qbz.exe","platforms\qwindows.dll","qml\QtQuick\Controls\Basic","imageformats\qsvg.dll","LICENSE.txt") { - if (-not (Test-Path "$root\$f")) { Write-Host "::error::installed layout is missing $f"; exit 1 } + $installed = $false + try { + foreach ($stage in @('install','upgrade')) { + $p = Start-Process msiexec -ArgumentList "/i `"$msi`" /qn /l*v $stage.log" -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content "$stage.log" -Tail 60; throw "msiexec $stage exit $($p.ExitCode)" } + $installed = $true + } + foreach ($f in @('qbz.exe','platforms\qwindows.dll','qml\QtQuick\Controls\Basic','imageformats\qsvg.dll','LICENSE.txt')) { + if (-not (Test-Path "$root\$f")) { throw "Installed layout is missing $f" } + } + if (-not (Test-Path 'HKCU:\Software\Classes\qbz')) { throw 'qbz protocol key missing' } + if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { throw 'Start-menu shortcut missing' } + $sourceHash = (Get-FileHash 'dist/qbz/licenses/msvc-runtime.json').Hash + if ((Get-FileHash "$root/licenses/msvc-runtime.json").Hash -ne $sourceHash) { throw 'MSI CRT inventory differs from shared payload' } + python scripts/packaging/windows_crt.py $root --report windows-msi-imports.json + if ($LASTEXITCODE -ne 0) { throw 'Installed MSI CRT audit failed' } + ./scripts/packaging/smoke_windows_deploy.ps1 -Directory $root -LogPrefix inst } - foreach ($protocol in "qbz", "qobuzapp") { - if (-not (Test-Path "HKCU:\Software\Classes\$protocol")) { Write-Host "::error::$protocol protocol key missing"; exit 1 } + finally { + if ($installed) { + $u = Start-Process msiexec -ArgumentList "/x `"$msi`" /qn /l*v uninstall.log" -Wait -PassThru + if ($u.ExitCode -ne 0) { Get-Content uninstall.log -Tail 60; throw "msiexec uninstall exit $($u.ExitCode)" } + } } - if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { Write-Host "::error::Start-menu shortcut missing"; exit 1 } - $env:QT_QPA_PLATFORM = "offscreen"; $env:RUST_LOG = "info" - $app = Start-Process "$root\qbz.exe" -RedirectStandardOutput inst.out -RedirectStandardError inst.err -PassThru -NoNewWindow - Start-Sleep 25; if (-not $app.HasExited) { Stop-Process -Id $app.Id -Force; Start-Sleep 1 } - if (-not (Select-String -Path inst.out,inst.err -Pattern 'QbzCore initialized' -Quiet)) { Get-Content inst.out,inst.err -Tail 30; Write-Host "::error::installed app never reached QbzCore init"; exit 1 } - $u = Start-Process msiexec -ArgumentList "/x `"$msi`" /qn /l*v uninstall.log" -Wait -PassThru - if ($u.ExitCode -ne 0) { Get-Content uninstall.log -Tail 60; Write-Host "::error::msiexec /x exit $($u.ExitCode)"; exit 1 } - if (Test-Path "$root\qbz.exe") { Write-Host "::error::uninstall left $root\qbz.exe"; exit 1 } - foreach ($protocol in "qbz", "qobuzapp") { - if (Test-Path "HKCU:\Software\Classes\$protocol") { Write-Host "::error::uninstall left the $protocol protocol key"; exit 1 } - } - Write-Host "MSI install/launch/uninstall OK" + if (Test-Path "$root\qbz.exe") { throw 'Uninstall left qbz.exe' } + if (Test-Path 'HKCU:\Software\Classes\qbz') { throw 'Uninstall left the qbz protocol key' } + Write-Host 'MSI install/upgrade/GUI launch/uninstall OK' - name: Sign MSI if: ${{ env.HAVE_CERT == 'true' }} @@ -450,6 +429,31 @@ jobs: run: | Compress-Archive -Path dist\qbz\* -DestinationPath "dist\qbz_$($env:VERSION)_windows_x64.zip" Copy-Item "crates\target\$env:TARGET\release\qbz.pdb" "dist\qbz_$($env:VERSION)_windows_x64.pdb" + $unpacked = Join-Path $env:RUNNER_TEMP ('qbz-portable-' + [guid]::NewGuid()) + try { + Expand-Archive -LiteralPath "dist\qbz_$($env:VERSION)_windows_x64.zip" -DestinationPath $unpacked + if ((Get-FileHash "$unpacked/licenses/msvc-runtime.json").Hash -ne (Get-FileHash 'dist/qbz/licenses/msvc-runtime.json').Hash) { throw 'ZIP CRT inventory differs from shared payload' } + python scripts/packaging/windows_crt.py $unpacked --report windows-zip-imports.json + if ($LASTEXITCODE -ne 0) { throw 'Portable ZIP CRT audit failed' } + ./scripts/packaging/smoke_windows_deploy.ps1 -Directory $unpacked -LogPrefix portable + } + finally { if (Test-Path $unpacked) { Remove-Item -LiteralPath $unpacked -Recurse -Force } } + + - name: Upload deployment evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: qbz-windows-deployment-checks + retention-days: 14 + if-no-files-found: ignore + path: | + windows-*-imports.json + inst.* + portable.* + install.log + upgrade.log + uninstall.log + dist/qbz/licenses/msvc-runtime.json - name: Upload artifacts uses: actions/upload-artifact@v7 diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index ff17277f8..c914b6594 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -1,16 +1,34 @@ -# Workspace unit/doc tests for the shipping crates/ tree (Slint-era). +# CI for the crates/ workspace on the integration branch. # -# Companion to the release/build workflows, not a substitute: -# - release-linux / build-slint / release-snap compile -p qbz (full UI, ~30 GB peak) -# - this job runs `cargo test` on every non-UI crate only (~5 min cold, ~1–2 min warm) +# Three jobs, three economies: +# test — validate all eight gettext catalogs, then `cargo test` on +# every crate that needs no Qt (scripts/cargo-test.sh: +# excludes the frozen Slint crates and qbz-qt), then the named +# listen-log suite check (qbz-app::listen_log must exist and be +# green — the per-user listen store's schema, accumulator, end +# reasons and pause/clear rules), and the account-migration + +# portable-blacklist suite check (Settings > Import / Export: +# snapshot/plan/apply/ledger/local copy, additive blacklist +# merge), plus QConnect controller handoff/read-vs-write, +# proto3-zero cursor, manual skip/loop/shuffle/autoplay and +# consecutive-log repetition regression checks, plus SACD +# 2048/2064-sector TOC/frame/seek equivalence and scanner import/ +# failed-read preservation. ~5 min cold. +# qt-gate — the Qt frontend: the five static QML audits, `cargo test -p +# qbz-qt`, the Slint-free dep-graph gate, and an OFFSCREEN BOOT of +# debug AND release binaries (including process liveness). +# Release also boots on Xvfb with a deliberately silent D-Bus. +# Node executes the actual Local Library filter/tab expressions. +# This is the thing `cargo check` cannot give: +# a missing QML component or a call to a bridge member that does +# not exist compiles clean and throws when the user opens the +# view. Qt comes from aqt — ubuntu-24.04's own Qt is 6.4.2 and has +# no QtQuick.Effects module. # -# Excludes qbz, qbz-ui, qbz-dac-wizard, qbz-slint-common so the UI compile unit -# never enters the graph. Cache key is intentionally separate from -# `slint-linux` (stable + test profile vs nightly + release). -# -# Repo policy note: release builds stay tag/dispatch-only. This is the one -# workflow that runs on pre-release integration so regressions land before a -# release cut. Path-filtered so packaging/docs PRs skip it. +# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Release packaging +# stays tag/dispatch-only; this workflow also tests optimized startup on +# pre-release +# integration so regressions land before a release cut. name: test-crates @@ -22,6 +40,14 @@ on: paths: - "crates/**" - "scripts/cargo-test.sh" + - "scripts/qt-*.py" + - "scripts/qt-*.sh" + - "scripts/test_qt_build_gates.py" + - "scripts/packaging/windows_crt.py" + - "scripts/packaging/test_windows_crt.py" + - "scripts/packaging/smoke_release.py" + - "scripts/packaging/test_smoke_release.py" + - "scripts/qml-audits/**" - ".github/workflows/test-crates.yml" push: # Integration branch only — never main (a push to main is a release merge). @@ -29,6 +55,14 @@ on: paths: - "crates/**" - "scripts/cargo-test.sh" + - "scripts/qt-*.py" + - "scripts/qt-*.sh" + - "scripts/test_qt_build_gates.py" + - "scripts/packaging/windows_crt.py" + - "scripts/packaging/test_windows_crt.py" + - "scripts/packaging/smoke_release.py" + - "scripts/packaging/test_smoke_release.py" + - "scripts/qml-audits/**" - ".github/workflows/test-crates.yml" workflow_dispatch: @@ -39,12 +73,17 @@ concurrency: permissions: contents: read +env: + # Same pin as build-qt-linux.yml (the Linux floor). Bump both together. + QT_VERSION: ${{ vars.QT_VERSION_LINUX || '6.8.3' }} + jobs: test: name: cargo test (workspace, no UI) runs-on: ubuntu-24.04 - # Cold first run ~5 min (compile + test); warm ~1–2 min. Headroom for - # registry lag / cache restore, not for a Slint UI compile. + # The rule: --no-fail-fast, 20 minutes. All cores: measured cold on an + # equivalent 4-vCPU VM, 392 s (jobs=4) vs 589 s (jobs=2), 2.7 GB peak — + # the old jobs=2 was a Slint-era memory tier with nothing left to protect. timeout-minutes: 20 steps: - name: Checkout @@ -52,54 +91,135 @@ jobs: - name: Install system dependencies run: | - # Minimal subset of release-linux.yml deps: non-UI crates still link - # ALSA / JACK / D-Bus (qbz-audio) and OpenSSL (reqwest). mold matches - # the release linker for faster test-binary link. + # Non-UI crates still link ALSA / JACK / D-Bus (qbz-audio) and + # OpenSSL (reqwest). sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config mold \ + build-essential pkg-config gettext \ libasound2-dev libjack-jackd2-dev \ libdbus-1-dev libssl-dev - # Floating stable is fine here: rust-cache keys on rustc -V, so a stable - # bump just rebuilds the test cache once. Nightly is reserved for the - # release/UI builds that need -Z threads. - name: Setup Rust uses: dtolnay/rust-toolchain@stable - name: Rust cache uses: Swatinem/rust-cache@v2 with: - # Manifest is crates/Cargo.toml → artifacts in crates/target (same - # workspaces mapping as release-linux / build-slint). workspaces: crates -> target shared-key: test-crates-linux cache-on-failure: true - # PRs restore only. pre-release pushes write so the integration - # branch keeps the cache warm (mirrors release-linux save-if policy). save-if: ${{ github.ref == 'refs/heads/pre-release' }} - - name: cargo test + - name: cargo test (scripts/cargo-test.sh — the local mirror) env: CARGO_TERM_COLOR: always - CARGO_BUILD_JOBS: "2" - # Faster link; no -Z flags (stable toolchain). - RUSTFLAGS: "-C link-arg=-fuse-ld=mold" run: | - echo "::group::toolchain" - rustc -V - cargo -V - echo "::endgroup::" + rustc -V; cargo -V START=$(date +%s) ./scripts/cargo-test.sh - END=$(date +%s) - echo "cargo test wall time: $((END - START))s" + echo "cargo test wall time: $(( $(date +%s) - START ))s" - - name: qbzd slint-free dep-graph gate (01-architecture.md §1.2) + # Every shader must bake. Separate job so qsb is never on PATH while cargo + # runs (see qt-gate). The BAKE toolchain is pinned apart from the runtime + # floor: the committed .qsb need qsb >= 6.9 (plasma/ambient's uint hash + # constants vs the GLSL 1.20 variant, which desktop GL <= 3.1 selects at + # runtime, so it must stay in the pack) — see shader_bake_gate.sh. + shader-gate: + name: shader bake gate + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + QT_SHADERTOOLS_VERSION: ${{ vars.QT_VERSION_SHADERTOOLS || '6.9.3' }} + steps: + - uses: actions/checkout@v7 + - name: Install Qt ${{ env.QT_SHADERTOOLS_VERSION }} + shader tools + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_SHADERTOOLS_VERSION }} + host: linux + target: desktop + arch: linux_gcc_64 + modules: qtshadertools + cache: true + - name: Bake every shader to a temp dir + run: scripts/qml-audits/shader_bake_gate.sh "$QT_ROOT_DIR/bin/qsb" + + qt-gate: + name: qt gate (audits, tests, offscreen boot) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config cmake clang libclang-dev lld nasm dbus \ + libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev \ + libgl1-mesa-dev libegl1-mesa-dev libxkbcommon-dev libwayland-dev \ + xvfb nodejs libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ + libxcb-render-util0 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0 + # cxx-qt prefers LLD when present; without it, Ubuntu falls back to + # the deprecated gold linker. Do not add workspace-wide RUSTFLAGS. + ld.lld --version + + # No qtshadertools here ON PURPOSE: with qsb on PATH build.rs re-bakes + # shaders by mtime, which makes a fresh checkout nondeterministic. The + # build explicitly validates/uses committed .qsb; shader health is the + # shader-gate job, independent of tools accidentally present on PATH. + - name: Install Qt ${{ env.QT_VERSION }} + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: linux + target: desktop + arch: linux_gcc_64 + cache: true + + - name: qmllint (QML type check) + run: scripts/qml-audits/qmllint_gate.sh "$QT_ROOT_DIR" + + # The audits need Qt's own plugins.qmltypes to resolve QtQuick types — + # hence after the Qt install, pointed at it. ~1 s, and the class of + # mistake nothing else in CI can see (a missing component, a bridge + # member that does not exist). + - name: QML audits + env: + QBZ_QT_QML_DIR: ${{ env.QT_ROOT_DIR }}/qml run: | set -euo pipefail - hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbzd -e normal \ + for a in qml_resolution_audit qml_singleton_xref qml_eager_tab_audit qml_module_registration_audit qml_icon_bake_audit qml_on_prefixed_property_audit; do + echo "::group::$a" + python3 "scripts/qml-audits/$a.py" "$PWD/crates/qbz-qt" + echo "::endgroup::" + done + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: crates -> target + shared-key: qt-gate-${{ env.QT_VERSION }} + cache-on-failure: true + save-if: ${{ github.ref == 'refs/heads/pre-release' }} + + # The exact QT=1 half of scripts/cargo-test.sh, minus its `test` job + # (already run above) and its shader gate (the shader-gate job, so qsb + # is never on PATH while cargo runs here): dep-graph gate, cargo test + # -p qbz-qt, Local Library/Kiosk artwork/navigation regressions, debug/release + # offscreen boot. One shared runtime script. + - name: qt gates (scripts/cargo-test.sh QT=1, without the workspace test) + env: + CARGO_TERM_COLOR: always + QBZ_QT_QML_DIR: ${{ env.QT_ROOT_DIR }}/qml + QBZ_PREBUILT_SHADERS: '1' + run: | + set -euo pipefail + hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbz-qt -e normal \ | grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true) - if [ -n "$hits" ]; then - echo "::error::qbzd graph resolves Slint crates:"; echo "$hits"; exit 1 - fi + [ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; } + bash scripts/qt-runtime-gate.sh diff --git a/.gitignore b/.gitignore index 207f210c0..ba7c2bbba 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ static/*upscayl* /.tmp/ /tmp/ /tools/ +/scratchpad/ # Documentation (kept external) qbz-nix-docs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf31e9854..da48d5de4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,16 +5,20 @@ This project is actively evolving. Contributions are welcome, but we have a few ## Where the code lives The live app is the Rust workspace under `crates/` — a single native process -with a Slint UI. UI code is in `crates/qbz-ui` (`.slint` + generated bindings) -and `crates/qbz` (the binary). The old Svelte `src/` and Tauri `src-tauri/` -trees were **deleted in 2.0.2**; they survive only at the git tag -`legacy-tauri-svelte` for reference. PRs against those paths cannot be merged — -port the change to `crates/` instead. +with a Qt/QML interface. UI code and the desktop binary live in +`crates/qbz-qt`; shared behavior belongs in the relevant core crate. + +The Slint crates (`crates/qbz-ui`, `crates/qbz`, `crates/qbz-slint-common` and +`crates/qbz-dac-wizard`) are frozen historical references. The old Svelte +`src/` and Tauri `src-tauri/` trees were deleted in 2.0.2 and survive only at +the git tag `legacy-tauri-svelte`. PRs against these retired paths cannot be +merged — port the change to the live Qt/Rust tree instead. ## Quick rules - Write clear, concise English (no emojis in code, comments, or commit messages). - Keep PRs focused and small when possible. +- Disclose every LLM model used and its role in the PR body; see below. - Do not change app branding or legal disclaimers without discussing it first. - Do not modify protected audio-backend behavior unless explicitly requested by the maintainer. @@ -72,9 +76,11 @@ PRs targeting `main` will be closed and asked to retarget to `pre-release`. - `git checkout pre-release` - `git merge --no-ff /external/` 5. **Run checks** - - Build/validate a touched core crate: `cargo check -p ` (run from - `crates/`). The full UI (`qbz`/`qbz-ui`) is a ~20–30 GB compile — see the - README "Building from Source" section before attempting it. + - Test or check each touched core crate from `crates/`. + - For Qt/QML changes, run the relevant QML audits and use + `./scripts/qt-run.sh` from the repository root when your platform can + build the desktop application. + - Never build the frozen Slint crates as validation for a live change. 6. **Push pre-release** - `git push origin pre-release` 7. **Close the PR with a comment** explaining it was merged to `pre-release`. @@ -104,9 +110,46 @@ Prefer: ## What to include in PRs - A short description of the problem and solution. +- For a bug fix: the observed behavior, the root cause or best-supported + diagnosis, and why the proposed change addresses it. +- For a feature: the user need, the existing flow or components you inspected, + and the constraints the design preserves. - Screenshots for UI changes when possible. +- The checks or manual tests you ran. +- The LLM disclosure described below, or `None` if no LLM was used. - Notes about any breaking changes or migrations. +## AI / LLM-assisted contributions + +LLM-assisted contributions are welcome. If you used one or more models, the +PR body must identify each model as precisely as the tool exposes it and state +what role it played. A product, client or provider name alone is not enough +when the tool shows a more specific model name or version. There is +deliberately no predefined model list to keep current: report the identifier +shown by the tool at the time of the contribution. + +List different models separately when they handled different stages, such as +repository/context mapping, problem definition, root-cause analysis, +plan/contract/dependency graph, implementation, tests or code review. Include +only the stages that apply and use one line per model: + +```text +- : repository mapping, diagnosis +- : plan/contract, implementation +- : tests, code review +``` + +Saying only that a change was "prompted" or "AI-generated" does not describe +the engineering process. The PR should make it possible to see how the problem, +existing code, constraints and proposed solution were understood before the +change was submitted. + +If the exact model is not shown by the tool, say that instead of guessing. +Prompt transcripts and private conversations are not required. This disclosure +helps the maintainer choose an appropriate review strategy; it is not grounds +for rejecting a contribution. The contributor remains responsible for reading +the resulting diff and reporting how it was verified. + ## What not to include - Large refactors mixed with feature work. @@ -119,16 +162,16 @@ Prefer: QBZ ships 8 locales (`en es de fr pt ru ja nl`) as gettext `.po` files, bundled via the `qbz-i18n` crate. Rules: -- **No hardcoded UI strings in `.slint`** — every string goes through - `@tr("...")`. +- **No hardcoded UI strings in QML** — use + `QbzSession.tr("…", QbzSession.trRev)` so live language changes update the + binding. Rust uses `qbz_i18n::t()` or `qbz_i18n::tf()`. - Adding or changing a string means updating **all** locale `.po` files, not just English. -- `@tr` property defaults are not reactive — re-seed from Rust on language - change (see `crates/qbz-i18n` and `select_bundled_translation()`, called - after `AppWindow::new()`). +- The English text is the gettext `msgid`; do not introduce dotted keys. ### Checklist for PRs with UI Text -- [ ] No hardcoded strings in `.slint` — all text via `@tr` +- [ ] No hardcoded strings in QML — all text uses `QbzSession.tr()` with + `trRev` - [ ] Every new/changed string updated across all 8 `.po` locales - [ ] Reused an existing string where one already fit diff --git a/README.md b/README.md index 4f42f100a..771211222 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ License Platform macOS (stable) + Windows (experimental)

@@ -19,45 +20,149 @@ # QBZ -QBZ is a free and open source high-fidelity streaming client for Linux and macOS with fully native playback. As of 2.0, the entire app is a single native Rust process built with a Slint UI — no browser engine, no webview — with DAC passthrough, per-track sample rate switching, exclusive mode, and bit-perfect audio delivery. +QBZ is a free and open source HiFi music player. Originally built only for +Linux, it is now also available for macOS and Windows (experimental). The +application started as a Qobuz client for paying subscribers who wanted to +listen without the audio quality limits of web browsers. Its strengths were +DAC passthrough, sending audio directly to the DAC or sound card, and switching +sample rates for each track, all with a focus on bit-perfect audio delivery. -No API keys needed. No telemetry. No tracking. Just music. +QBZ has always played both your Qobuz subscription and your local files. +Local playback was initially an extra; it has since become just as capable. +QBZ also supports self-hosted music through Plex, Jellyfin and +Subsonic/Navidrome, all through the same interface and the same audio pipeline. -## Legal / Branding +The application requires no API keys, token extraction or anything like that. +It has zero telemetry, with opt-in integrations for scrobbling, metadata +enrichment and Discord Rich Presence. QBZ doesn't track you. It just plays +your music. + +## Contents + + + + + + +
+ +- [What QBZ is and what it is not](#what-qbz-is-and-what-it-is-not) +- [Legal and branding](#legal-and-branding) +- [Installation](#installation) +- [Features](#features) +- [Headless daemon (qbzd)](#headless-daemon-qbzd) +- [Kiosk mode](#kiosk-mode) +- [Tech stack](#tech-stack) +- [Building from source](#building-from-source) + + + +- [Environment variables](#environment-variables) +- [Known issues](#known-issues) +- [Reporting a problem](#reporting-a-problem) +- [On LLMs and AI tools](#on-llms-and-ai-tools) +- [Documentation](#documentation) +- [Contributing](#contributing) +- [License](#license) + +
+ +## What QBZ is and what it is not + +QBZ exists to fill the gap left by the lack of an official Linux client. +Although it is available on other platforms, QBZ will always be Linux first. +I believe Linux users are not second-class citizens, and we deserve software +that works, looks good and, every now and then, is easy to use. + +But it is just as important to understand what QBZ is not. QBZ is not a +stream-ripping tool. Its purchase downloader lets you download music you have +bought on Qobuz, and only your purchases can be downloaded DRM-free. I built +QBZ rather than TDL, SPTFY, DZR or PPLMSC out of love for music, because I +believe it belongs to the artists who create it, and because Qobuz is perhaps +the fairest platform for artists so far. + +You can keep tracks from the service for offline listening and access them +through your library. These are protected with QBZ's own encryption and cannot +be used outside the application. Offline listening remains subject to your +subscription: if Qobuz reports that it is no longer valid, QBZ allows a 30-day +grace period from that first invalid response, then removes the cached tracks +unless the subscription has been validated again. + +QBZ will never be an all-in-one audio system with a built-in equalizer, +spatial filters, DSP or similar processing. The point is to send audio to your +DAC, or whichever output you prefer, untouched, bit for bit. Any processing +would break that contract. Of course, you can configure your output so that +once the music leaves QBZ, it goes wherever you want, including an EQ such as +EasyEffects. PipeWire and JACK make that easy. + +Above all, I'm a software engineer, not an audio engineer. I can't ship +something when I don't understand how it works. I can't take responsibility +for an EQ copied from somewhere else or written by an AI model without being +sure I can test it and verify that it works 100%. I already did that with +another module, and it didn't go well. + +## Legal and branding - This application uses the Qobuz API but is not certified by Qobuz. -- Qobuz is a trademark of Qobuz. QBZ is not affiliated with, endorsed by, or certified by Qobuz. -- **Offline cache** is a temporary playback store for listening without an internet connection while you have a valid subscription. If your subscription becomes invalid, QBZ will remove all cached content after 3 days. -- **Local library** is a "bring your own music" feature — play your own files with bit-perfect audio and the full QBZ interface, no streaming subscription required. +- Qobuz is a trademark of Qobuz. QBZ is not affiliated with, endorsed by, or + certified by Qobuz. +- **Offline cache** is a temporary playback store for listening without an + internet connection, protected with QBZ's own encryption. It is not a + DRM-free download of music from your subscription. +- **Local library** is a "bring your own music" feature — play your own files + with bit-perfect audio and the full QBZ interface, no streaming subscription + required. +- **SACD images** are user-supplied local files. QBZ does not provide disc + images or tools to bypass SACD copy protection. +- **SACD / DST decoding** uses `dst-decoder` 0.1.2 solely to decode MPEG-4 + Direct Stream Transfer audio. The crate declares Apache-2.0 and is based + on the MPEG-4 DST reference implementation. QBZ includes the + [Apache license](licenses/Apache-2.0-dst-decoder.txt), + [provenance notice](licenses/dst-decoder-NOTICE.md) and + [full upstream MPEG notice](licenses/dst-decoder-upstream-NOTICE.txt), + including its patent notice, with its third-party licenses. QBZ does not + claim worldwide patent clearance. - Qobuz Terms of Service: https://www.qobuz.com/us-en/legal/terms -## Why QBZ - -QBZ exists as the player we wish a streaming company would give its users. It exists to fill the gap of an official client on Linux, because we don't like being second class citizens. - -But it is just as important to understand what QBZ is not, and never will be. It is not a web wrapper; I did not just package the web player and "Linuxify" it. It is not a download tool, and it never will be. I built QBZ and not TDL, SPTFY or DZR because I believe music belongs to the artists, and Qobuz is, so far, the fairest option and the one with the best quality. That is why QBZ exists. - -It will never have a built-in equalizer or DSP, either. The point is to send audio to your DAC untouched, bit for bit, and any processing would break that. If you want an EQ or effects, add them at the system level, for example with EasyEffects on PipeWire or a JACK graph, which make it easy. - -You can download songs to listen to them offline, but they carry the same locks as the official client and the same usage window once your membership expires. - -On the technical side: browsers cap audio output at 48 kHz and resample everything through WebAudio. QBZ uses a native playback pipeline with direct device control, so your DAC receives the original resolution, up to 24-bit / 192 kHz, with no forced resampling. - ## Installation ### Arch Linux (AUR) +Install the prebuilt packages (recommended): + ```bash -yay -S qbz-bin # or paru -S qbz-bin +yay -S qbz-bin # desktop player; or: paru -S qbz-bin +yay -S qbzd-bin # optional headless daemon; or: paru -S qbzd-bin ``` +To build from source instead: + +```bash +yay -S qbz # desktop player; or: paru -S qbz +yay -S qbzd # optional headless daemon; or: paru -S qbzd +``` + +Pick either the source or `-bin` variant in each pair; both variants provide +the same package and therefore conflict with each other. + ### Flatpak (Flathub) ```bash flatpak install flathub com.blitzfc.qbz ``` -> **Audiophiles:** Flatpak sandboxing limits PipeWire bit-perfect. Use ALSA Direct backend for guaranteed bit-perfect in Flatpak, or install via native packages for full PipeWire support. +> **Audiophiles:** bit-perfect works in Flatpak. The sandbox needs one +> permission grant so QBZ can ask PipeWire to hand the DAC over cleanly (D-Bus +> device reservation) — without it PipeWire keeps holding the device and other +> apps keep mixing through it, even with exclusive mode selected: +> +> ```bash +> flatpak override --user --own-name=org.freedesktop.ReserveDevice1.* com.blitzfc.qbz +> ``` +> +> QBZ shows this and the other sandbox grants under **Settings → Flatpak** with +> copyable commands, and the HiFi Wizard covers the rest. Your host audio stack +> still has to be configured correctly. ### Snap @@ -67,9 +172,10 @@ sudo snap connect qbz-player:alsa sudo snap connect qbz-player:pipewire ``` -> **Note:** After installing, connect ALSA and PipeWire interfaces for full audio support. MPRIS media keys work out of the box. +> **Note:** After installing, connect ALSA and PipeWire interfaces for full +> audio support. MPRIS media keys work out of the box. -### APT Repository (Debian/Ubuntu/Mint) +### APT (Debian / Ubuntu / Mint) ```bash curl -fsSL https://vicrodh.github.io/qbz-apt/qbz-archive-keyring.gpg | gpg --dearmor | sudo tee /usr/share/keyrings/qbz-archive-keyring.gpg > /dev/null @@ -84,13 +190,42 @@ EOF sudo apt update && sudo apt install qbz ``` -> Requires glibc 2.39+ (Ubuntu 24.04+, Debian 13+). For older releases use Flatpak, Snap, or AppImage. +> **x86_64:** glibc 2.35+ (Ubuntu 22.04+, Debian 12+, Mint 21+). +> **arm64 (desktop app):** glibc 2.39+ (Ubuntu 24.04+, Debian 13+) — the Qt +> arm64 build needs it; Raspberry Pi OS *bookworm* is 2.36, so use `qbzd` +> there (2.35+, see below). For older systems, check Flatpak or Snap runtime +> support. The arm64 AppImage has the same glibc 2.39 minimum as the native +> packages. -### RPM (Fedora/openSUSE) +### RPM (Fedora / openSUSE) -Download from [Releases](https://github.com/vicrodh/qbz/releases): `sudo dnf install ./qbz-*.rpm` +Add the signed QBZ repository and install with DNF: -> Requires glibc 2.39+ (Fedora 40+, openSUSE Tumbleweed). +```bash +sudo curl --fail --location \ + --output /etc/yum.repos.d/qbz.repo \ + https://vicrodh.github.io/qbz-rpm/qbz.repo +sudo dnf install qbz # desktop player, including qbzd +# or: sudo dnf install qbzd # headless daemon only +``` + +On openSUSE, use the same repository definition with Zypper: + +```bash +sudo curl --fail --location \ + --output /etc/zypp/repos.d/qbz.repo \ + https://vicrodh.github.io/qbz-rpm/qbz.repo +sudo zypper refresh qbz +sudo zypper install qbz # or: sudo zypper install qbzd +``` + +The signing-key fingerprint is published on the +[repository landing page](https://vicrodh.github.io/qbz-rpm/). Individual RPM +files also remain available from [Releases](https://github.com/vicrodh/qbz/releases). + +> **x86_64:** glibc 2.35+ (Fedora 36+, openSUSE Leap 15.6+ / Tumbleweed). +> **arm64 (desktop app):** glibc 2.39+ (Fedora 40+). `qbzd` packages stay at +> 2.35+ on both architectures. ### Gentoo @@ -102,13 +237,17 @@ emerge media-sound/qbz-bin # prebuilt binary (recommended) emerge media-sound/qbz # build from source ``` -> **Source-build warning (yes, even by Gentoo standards):** QBZ's UI compiles -> into one giant generated Rust module — the release build peaks at 20–30 GB -> of RAM and takes a long while (see -> [The memory wall](#-the-memory-wall-read-this-before-your-first-build)). -> Every version bump recompiles it. `qbz-bin` spares you all of that; the -> source ebuild is there for those who enjoy the smell of rustc in the -> morning. +For a headless installation, choose the matching daemon package: + +```bash +emerge media-sound/qbzd-bin # prebuilt binary (recommended) +# or +emerge media-sound/qbzd # build from source +``` + +`qbzd` does not require systemd. The ebuild detects systemd, OpenRC or runit +and prints the appropriate post-install command; the daemon can generate +service definitions for all three init systems. ### NixOS / Nix @@ -144,18 +283,42 @@ inputs.qbz.url = "github:vicrodh/qbz"; ### AppImage -Download from [Releases](https://github.com/vicrodh/qbz/releases): `chmod +x QBZ.AppImage && ./QBZ.AppImage` +Download from [Releases](https://github.com/vicrodh/qbz/releases): +`chmod +x QBZ.AppImage && ./QBZ.AppImage` ### macOS -**QBZ is Linux-first, but as of 2.0 macOS is out of its experimental phase and is a stable, fully supported platform** — a proper player for Linux and Mac. PipeWire, ALSA, and JACK are Linux-specific backends; macOS plays through its own CoreAudio backend, including a Core Audio Direct passthrough path for bit-perfect output. Casting (Chromecast/DLNA) and Qobuz Connect work on macOS as well. +**QBZ is Linux-first, but macOS is a stable, fully supported platform** — a +proper player for Linux and Mac. PipeWire, ALSA and JACK are Linux-specific +backends; macOS plays through its own CoreAudio backend, including a Core Audio +Direct passthrough path for bit-perfect output. Casting (Chromecast/DLNA) and +Qobuz Connect work on macOS as well. -Download the DMG (Apple Silicon or Intel) from [Releases](https://github.com/vicrodh/qbz/releases) and drag QBZ into Applications. +**Recommended — signed and notarized:** Afonso Ramos independently maintains a +[Homebrew Cask](https://github.com/afonsojramos/homebrew-qbz), which installs +without requiring a manual Gatekeeper bypass: -**First launch:** QBZ is ad-hoc signed but NOT notarized (no Apple Developer -subscription), so Gatekeeper will block the first run — and on recent macOS -(Sequoia / 15 and later) the old right-click → Open trick no longer works for -un-notarized apps. Two ways to unlock it, pick one: +```bash +brew install --cask afonsojramos/qbz/qbz +``` + +You can also download the Apple Silicon or Intel DMG from the +[signed macOS releases](https://github.com/afonsojramos/qbz-macos/releases/latest). +These builds use the upstream QBZ application without recompiling it: +[@afonsojramos](https://github.com/afonsojramos) replaces its ad-hoc signature +and DMG container, then notarizes the result. This community-maintained signing +and distribution is the recommended way to install QBZ on macOS, and the macOS +version would likely not exist in its current form without Afonso's work. The +mirror publishes the source commit, original checksums, and signed checksums +for each release; see its +[trust and provenance documentation](https://github.com/afonsojramos/qbz-macos#trust-and-provenance). + +**Official upstream alternative — ad-hoc signed, not notarized:** if you prefer +the artifact produced directly by the QBZ project, download the Apple Silicon +or Intel DMG from [QBZ Releases](https://github.com/vicrodh/qbz/releases) and +drag QBZ into Applications. Because the project has no Apple Developer +subscription, Gatekeeper blocks its first run. On recent macOS versions +(Sequoia / 15 and later), unlock it using either route: - **Settings route:** try to open QBZ once (it gets blocked), then go to **System Settings → Privacy & Security**, scroll down to the message that @@ -172,34 +335,61 @@ un-notarized apps. Two ways to unlock it, pick one: ## Features -### Audio and Playback +### Audio and playback -- **Bit-perfect playback** with DAC passthrough and per-track sample rate switching (44.1–192 kHz) -- **Linux backends:** PipeWire, ALSA (with a Direct hw: bypass mode), PulseAudio, and JACK — all hardened in 2.0, PipeWire/PulseAudio work out of the box -- **macOS backend:** CoreAudio, including a Core Audio Direct passthrough path for bit-perfect output -- **HiFi Wizard** — revamped in 2.0 with real hardware auto-detection and a simpler, guided bit-perfect setup +- **Bit-perfect playback** with DAC passthrough and per-track sample rate + switching (44.1–192 kHz) +- **Linux backends:** PipeWire, ALSA (with a Direct `hw:` bypass mode), + PulseAudio and JACK — PipeWire and PulseAudio work out of the box +- **macOS backend:** CoreAudio, including a Core Audio Direct passthrough path + for bit-perfect output +- **HiFi Wizard** — hardware auto-detection and a guided bit-perfect setup - Native decoding: FLAC, MP3, AAC, ALAC, WavPack, Ogg Vorbis, Opus (Symphonia) -- **DSD support** — DSF/DFF playback with DSD-to-PCM conversion, DoP, and native DSD passthrough (ALSA) +- **DSD support** — DSF/DFF playback with DSD-to-PCM conversion, DoP, and + native DSD passthrough (ALSA) +- **CD and SACD playback** — audio CDs and SACD discs play directly from the + drive; SACD goes out over DoP, with the titles read from the disc +- **CD ripping to FLAC** — a guided rip wizard with per-track progress - Gapless playback on all backends - **Loudness normalization** (EBU R128) with ReplayGain support - Two-level audio cache with next-track prefetching -- Streaming playback — start listening before download completes +- Streaming playback — start listening before the download completes -### Queue and Library +### Your music, wherever it lives -- Queue with shuffle, repeat (track/queue/off), and history +QBZ treats every source the same way: one queue, one player, one interface. + +- **Qobuz** — your subscription, favourites, playlists and purchases +- **Local files** — directory scanning, metadata extraction, CUE sheets and a + SQLite catalog; usable without ever logging into Qobuz +- **Plex** — browse and play your Plex music library +- **Jellyfin** — browse and play your Jellyfin music library +- **Subsonic / Navidrome** — any Subsonic-compatible server +- **Optical discs** — CD and SACD, played or ripped + +### Queue and library + +- Queue with shuffle, repeat (track/queue/off) and history - Favorites and playlists from your Qobuz account -- **Qobuz playlist follow/unfollow** — subscribe natively, syncs across all Qobuz clients -- **Local library** — directory scanning, metadata extraction, CUE sheets, SQLite indexing; usable without ever logging into Qobuz -- **Artist/album blacklist** — block artists or individual albums, not just genres; fully reversible -- Tag editor with sidecar storage (preserves original files) +- **Qobuz playlist follow/unfollow** — subscribe natively, syncs across all + Qobuz clients +- **Playlist manager** with folders and tags, plus playlist import from + Spotify, Apple Music, Tidal and Deezer +- **Artist/album blacklist** — block artists or individual albums, not just + genres; fully reversible +- **Metadata editor** — a full tabular editor for your local files, with + MusicBrainz and Discogs lookup, album art from local files or from + Cover Art Archive / Discogs / Last.fm, and a sidecar mode that leaves your + original files untouched - Virtualized lists for large libraries ### Qobuz Connect -Multi-device playback control using Qobuz's real-time streaming protocol. Considerably more stable as of 2.0, though full 1:1 parity with the official clients is still in progress. +Multi-device playback control using Qobuz's real-time streaming protocol. Full +1:1 parity with the official clients is still in progress. -- **Renderer mode** — receive playback commands from your phone, tablet, or web player +- **Renderer mode** — receive playback commands from your phone, tablet or web + player - **Controller mode** — control remote devices from QBZ - Server-authoritative queue sync across all devices - Bidirectional transport: play, pause, skip, seek, shuffle, repeat, volume @@ -214,57 +404,79 @@ Multi-device playback control using Qobuz's real-time streaming protocol. Consid - **MPRIS** media controls and media keys - **Last.fm** scrobbling and now-playing - **ListenBrainz** scrobbling with offline queue -- **MusicBrainz** artist enrichment, musician credits, relationships (no telemetry — one-way pull) -- **Discogs** artwork for local library -- Playlist import from Spotify, Apple Music, Tidal, Deezer +- **MusicBrainz** artist enrichment, musician credits, relationships (no + telemetry — one-way pull) +- **Discogs** artwork and metadata for the local library - Desktop notifications with artwork +- **Listening history** is stored only on your disk, per Qobuz account; it + never leaves the machine unless you enable a scrobbler (Settings › + Integrations › Privacy to pause or clear it) -### Immersive Player +### Immersive player -- Full-screen player with tabbed panel system, trimmed and re-tuned in 2.0 to stop being a resource drain -- Multiple full-bleed view modes — Album Reactive, Coverflow, Static, Spectrum, Wave Bed, Lyrics — plus GPU shader scenes (Plasma, Tunnel, Aurora, Spectral Ribbon, Line Bed) -- **Search overlay works inside Immersive mode** — switch albums without leaving the view +- Full-screen player with a tabbed panel system +- Multiple full-bleed view modes — Album Reactive, Coverflow, Static, Spectrum, + Wave Bed, Lyrics — plus GPU shader scenes (Plasma, Tunnel, Aurora, Spectral + Ribbon, Line Bed) +- **Search overlay works inside Immersive mode** — switch albums without + leaving the view - Synchronized lyrics with line-by-line display - Split-panel layouts: Lyrics, Track Info, Suggestions, Queue ### Discovery -- **Scene Discovery** — explore artists by location and musical scene (MusicBrainz-powered) +- **Scene Discovery** — explore artists by location and musical scene + (MusicBrainz-powered) - **3-tab Home:** customizable Home, Editor's Picks, personalized For You -- **Recommendations** — Last.fm and ListenBrainz/MusicBrainz-powered discovery based on your listening history, similarities, and local-listen vectorization -- **Live search overlay** with a small cache layer that learns your preferences and stops surfacing results you never touch +- **Recommendations** — Last.fm and ListenBrainz/MusicBrainz-powered discovery + based on your listening history, similarities and local-listen vectorization +- **Live search overlay** with a small cache layer that learns your preferences + and stops surfacing results you never touch - Genre filtering, artist similarity engine, radio stations - Musician pages, label pages, album credits ### Interface -- 30+ themes (Dark, OLED, Nord, Dracula, Tokyo Night, Catppuccin, Breeze, Adwaita...) +- 30+ themes (Dark, OLED, Nord, Dracula, Tokyo Night, Catppuccin, Breeze, + Adwaita...) plus a custom theme editor - Auto-theme from DE, wallpaper, or custom image -- Mini player -- Album booklets download to your device — the old in-app PDF reader was dropped in 2.0 to keep the binary lean +- Mini player and system tray +- Album booklets download to your device - Configurable keyboard shortcuts, UI scale presets (XS–XL) -- **8 languages:** English, Spanish, German, French, Portuguese, Russian, Japanese, Dutch -- **Offline mode** usable without ever logging into Qobuz, with fully offline playlists and automatic reconnection +- **8 languages:** English, Spanish, German, French, Portuguese, Russian, + Japanese, Dutch +- **Offline mode** usable without ever logging into Qobuz, with fully offline + playlists and automatic reconnection -## Headless Daemon (qbzd) +## Headless daemon (qbzd)

qbzd — headless daemon CLI and TUI

-Run QBZ without a screen: `qbzd` is a standalone ~25 MB binary (shipped inside the deb/rpm -packages and as its own tarball) that turns any Linux box — a Raspberry Pi, a NAS, the -living-room mini-PC — into a bit-perfect **Qobuz Connect endpoint** that appears in the -official Qobuz apps like a hardware streamer. +Run QBZ without a screen: `qbzd` is a standalone ~25 MB binary (shipped inside +the deb/rpm packages and as its own tarball) that turns any Linux box — a +Raspberry Pi, a NAS, the living-room mini-PC — into a bit-perfect **Qobuz +Connect endpoint** that appears in the official Qobuz apps like a hardware +streamer. It needs only **glibc 2.35+ on x86_64 and arm64** (Raspberry Pi OS +*bookworm* and *trixie*, 1 GB boards included) — deliberately lower than the +arm64 desktop app's 2.39, so a trimmed Pi image stays a valid target. - Daemon + full CLI + terminal setup wizard (TUI) in one binary -- Browser-based login that works over SSH; one-file settings hand-off from desktop QBZ +- Browser-based login that works over SSH; one-file settings hand-off from + desktop QBZ - HiFi wizard with copyable audio-stack config blocks (clipboard works over SSH) -- MPRIS out of the box, live JSON events (`qbzd watch`), service files for systemd/OpenRC/runit +- MPRIS out of the box, live JSON events (`qbzd watch`), service files for + systemd/OpenRC/runit +- Event hooks: `qbzd settings set hooks.script /path/to/script` runs your script + on playback/session events with `QBZ_*` environment variables — push + integration for audio-box distros (moOde, Volumio, DIY setups), no polling + required -Full manual: **[Headless Daemon (qbzd) — Wiki](https://github.com/vicrodh/qbz/wiki/Headless-Daemon)** +Full manual: +**[Headless Daemon (qbzd) — Wiki](https://github.com/vicrodh/qbz/wiki/Headless-Daemon)** -## Kiosk Mode +## Kiosk mode For Raspberry screens and handheld consoles. @@ -272,224 +484,381 @@ For Raspberry screens and handheld consoles. QBZ Kiosk Mode on a handheld console

-A touch-first face for touchscreens and small panels: set `QBZ_PROFILE=kiosk` and QBZ boots -a big-target shell with its own NavRail, touch scrolling, an on-screen keyboard, and a -centerpiece Now Playing with cover↔lyrics toggle and queue/history tabs. Switch between -Kiosk and Desktop live from the Now Playing layout menu; fullscreen is opt-in via -`QBZ_KIOSK_FULLSCREEN=1`. +A touch-first face for touchscreens and small panels: set `QBZ_PROFILE=kiosk` +and QBZ boots a big-target shell with its own NavRail, touch scrolling, an +on-screen keyboard, and a centerpiece Now Playing with cover↔lyrics toggle and +queue/history tabs. Switch between Kiosk and Desktop live from the Now Playing +layout menu; fullscreen is opt-in via `QBZ_KIOSK_FULLSCREEN=1`. -## Tech Stack +## Tech stack -As of 2.0, QBZ is a single native Rust process: the **Slint UI runs in-process** and talks to the same Rust core crates directly through callbacks and shared state — there is no browser engine, no webview, and no IPC bridge to serialize across (the old Tauri command-invoke boundary is gone entirely). +QBZ is a single native Rust process. The UI is **Qt/QML**, bound to the Rust +core through [cxx-qt](https://github.com/KDAB/cxx-qt): the QML scene graph and +the Rust backend live in the same process and talk through generated QObject +bridges — there is no browser engine, no webview and no IPC boundary to +serialize across. | Layer | Technology | |-------|-----------| -| **Desktop shell + UI** | Rust + Slint (native, single process — no webview, no IPC) | -| **Audio decoding** | Symphonia (all codecs) via rodio 0.22 | -| **Audio backends** | Linux: PipeWire, ALSA (alsa-rs, incl. Direct hw:), PulseAudio, JACK. macOS: CoreAudio (incl. Core Audio Direct) | +| **Desktop shell + UI** | Qt 6 / QML via cxx-qt (native, single process — no webview, no IPC) | +| **Custom rendering** | Qt RHI scene-graph items + baked `.qsb` shaders (visualizers, immersive scenes, waveform) | +| **Audio decoding** | Symphonia (all codecs) via rodio | +| **Audio backends** | Linux: PipeWire, ALSA (alsa-rs, incl. Direct `hw:`), PulseAudio, JACK. macOS: CoreAudio (incl. Core Audio Direct) | | **Networking** | reqwest (rustls-tls) | | **Database** | rusqlite (bundled SQLite, WAL mode) | -| **Desktop** | mpris-server (Linux MPRIS), souvlaki (macOS/Windows media controls), ksni (Linux tray), keyring | +| **Desktop** | mpris-server (Linux MPRIS), souvlaki (macOS media controls), ksni (Linux tray), keyring | | **Casting** | rust_cast (Chromecast), rupnp (DLNA/UPnP), mdns-sd | -| **i18n** | qbz-i18n, gettext-style `.po` bundles compiled into the binary (7 locales) | +| **i18n** | qbz-i18n, gettext-style `.po` bundles compiled into the binary (8 locales) | -### Multi-Crate Architecture +### Multi-crate architecture -The Rust workspace lives entirely under `crates/` (manifest `crates/Cargo.toml`); the `qbz` crate is the binary, `qbz-ui` holds the Slint views and view-models. A representative slice of the 36 workspace members: +The Rust workspace lives entirely under `crates/` (manifest +`crates/Cargo.toml`). `qbz-qt` is the application crate — it owns the QML tree, +the cxx-qt bridges and the RHI items, and produces the `qbz` binary. Everything +below it is frontend-agnostic. A representative slice of the workspace: ``` crates/ - qbz/ Binary crate: app entrypoint, window/renderer setup, wiring - qbz-ui/ Slint views (.slint) and view-model glue + qbz-qt/ Application crate: QML tree, cxx-qt bridges, RHI items qbz-app/ Application-level orchestration (non-UI) - qbz-theme/ Theme engine (30+ themes) - qbz-i18n/ Bundled translations (7 locales) + qbz-core/ Orchestrator (player + audio + API) qbz-models/ Shared domain types + qbz-theme/ Theme engine (30+ themes) + qbz-i18n/ Bundled translations (8 locales) + qbz-audio/ Audio backends, loudness, device management qbz-player/ Playback engine, streaming, queue - qbz-qobuz/ Qobuz API client and auth - qbz-core/ Orchestrator (player + audio + API) - qbz-library/ Local library scanning and metadata qbz-dsd/ DSD (DSF/DFF) decoding, DoP, native DSD packing - qbz-plex/ Plex library integration + qbz-disc/ Optical media: CD-DA and SACD + qbz-rip/ CD ripping to FLAC + qbz-cmaf/ CMAF/DASH streaming + qbz-cache/ L1 memory + L2 disk audio caching + qbz-offline-cache/ Offline downloads and their lifecycle + + qbz-qobuz/ Qobuz API client and auth + qbz-source/ Source-agnostic seam over every backend + qbz-library/ Local file scanning and metadata + qbz-local-catalog/ Derived read projection for the local library + qbz-plex/ Plex integration + qbz-jellyfin/ Jellyfin integration + qbz-subsonic/ Subsonic / Navidrome integration + qbz-media-cache/ Shared cache for remote media servers + qbz-integrations/ Last.fm, ListenBrainz, MusicBrainz, Discogs qbz-reco/ qbz-external-reco/ Recommendations engine qbz-lyrics/ Lyrics (Qobuz-native, external fallback) + qbz-radio/ Radio stations qbz-mixtape/ Mixtape/DJ-mix generation qbz-playlist-import/ Spotify, Apple Music, Tidal, Deezer import qbz-media-controls/ MPRIS / SMTC / MPNowPlayingInfoCenter - qbz-dac-wizard/ HiFi Wizard (hardware auto-detection) - qbz-cache/ L1 memory + L2 disk audio caching qbz-cast/ Chromecast, DLNA/UPnP + qbz-dac-wizard-core/ HiFi Wizard (hardware auto-detection) qbz-credentials/ qbz-secrets/ Auth/token storage + qconnect-protocol/ Qobuz Connect protobuf wire format qconnect-core/ Queue and renderer domain models qconnect-app/ Application logic and concurrency qconnect-transport-ws/ WebSocket transport with qcloud framing + + qbzd/ Headless daemon + CLI + setup TUI ``` -## Building from Source +## Building from source -QBZ 2.0 is a pure Rust workspace — there is no Node.js, no `npm install`, no -webview. The workspace manifest is `crates/Cargo.toml`, and the app binary is -the `qbz` crate inside it. +QBZ is a pure Rust workspace — there is no Node.js, no `npm install`, no +webview. The workspace manifest is `crates/Cargo.toml` and the application +crate is `qbz-qt`, which builds a binary called `qbz`. ### Prerequisites -- **Rust stable** for a plain `cargo build`, or the **nightly toolchain** if - you use the repo's build scripts (they pass `-Z threads` to parallelize the - compiler frontend — see below). -- Linux or macOS with audio support. On Linux the scripts use - [`mold`](https://github.com/rui314/mold) as linker (install it, or edit the - `RUSTFLAGS` they set); plain `cargo build` needs neither nightly nor mold. +- **Rust stable.** No nightly, no `mold`, no custom `RUSTFLAGS` — the build + needs none of them, and setting any of them invalidates the whole build cache + for no gain. +- **Qt 6.8 or newer** (6.9+ on Windows), including its development headers + *and* the private headers (`` lives in Qt's private tree and the + custom scene-graph items need it). +- **Python 3**, for the Qt SDK cache guard and QML audits. +- Linux, macOS or Windows x64 (experimental), with audio support. - No Node.js/npm required. -### System Dependencies +### System dependencies -Verified against the project's own Linux CI build (`.github/workflows/build-slint.yml`): - -**Debian/Ubuntu:** -```bash -sudo apt install build-essential pkg-config cmake clang libclang-dev nasm \ - libasound2-dev libjack-jackd2-dev libfontconfig1-dev libfreetype-dev \ - libxkbcommon-dev libwayland-dev libxcb1-dev libgl1-mesa-dev libegl1-mesa-dev \ - libdbus-1-dev libssl-dev -``` - -**Fedora, Arch, Gentoo, and other distros:** package names differ; look for the -equivalents of the Debian list above (a C compiler + clang/libclang, cmake, -nasm, ALSA + JACK dev headers, fontconfig/freetype, Wayland/X11/xkbcommon, -Mesa GL/EGL, D-Bus, and OpenSSL dev headers). Please open a PR if you confirm -exact package names for your distro. - -**macOS:** Xcode Command Line Tools (`xcode-select --install`) and a Rust -toolchain — that's it. - -### ⚠ The memory wall (read this before your first build) - -Slint compiles the entire UI into ONE generated Rust module (~1.6 M lines for -QBZ). A single **release** `rustc` invocation for that crate peaks at -**20–30 GB of RAM**. This is a one-time cost per profile — once the UI crate is -cached, incremental builds are cheap — but the first build WILL hit it, and on -a machine without enough headroom the compiler gets OOM-killed (or worse, -swap-freezes the box). - -The knobs that tame it (each lowers peak memory at some compile-time or -runtime-optimization cost): - -| Knob | Effect | -|------|--------| -| `CARGO_BUILD_JOBS=1` | one rustc at a time — the single biggest saver | -| `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=256` | smaller codegen chunks (slightly less optimized binary) | -| `CARGO_PROFILE_RELEASE_OPT_LEVEL=2` | opt 2 instead of 3 shaves several GB | -| `-Z threads=1..2` (nightly RUSTFLAGS) | fewer frontend threads = less parallel memory | -| swap | the peak tolerates swap; 16 GB RAM + ~18 GB swap is CI-proven | - -**If you have < 32 GB of RAM**, this works on any machine down to ~16 GB -(+ swap) — it is literally the recipe our 16 GB CI runners use; expect it to be -slow (2–3 h from scratch) rather than to fail: +**Debian / Ubuntu:** ```bash -CARGO_BUILD_JOBS=1 \ -CARGO_PROFILE_RELEASE_CODEGEN_UNITS=256 \ -CARGO_PROFILE_RELEASE_OPT_LEVEL=2 \ -cargo build --release --manifest-path crates/Cargo.toml -p qbz +sudo apt install build-essential pkg-config cmake clang libclang-dev nasm python3 \ + qt6-base-dev qt6-base-private-dev \ + qt6-declarative-dev qt6-declarative-private-dev \ + qt6-shadertools-dev \ + libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev ``` -**If you have ≥ 32 GB free**, the plain build is fine and much faster: +`qt6-shadertools-dev` provides `qsb`, the shader baker. It is optional: the +`.qsb` files are committed, so a build without `qsb` simply keeps them (the +build prints a warning). You need it if you intend to modify a shader. + +**Arch Linux:** + +```bash +sudo pacman -Syu --needed base-devel git rust pkgconf cmake clang nasm python \ + qt6-base qt6-declarative qt6-svg qt6-wayland alsa-lib dbus openssl +``` + +If you manage Rust with rustup, omit `rust` from that command. +Also install a JACK development provider: `pipewire-jack` if you use PipeWire's +JACK support, or `jack2` for JACK itself. Keep the provider your system already +uses. Both supply the headers and `jack.pc`. Arch's Qt packages include the +private headers; use `QMAKE=/usr/bin/qmake6` if another Qt version is selected. +For shader editing, add `qt6-shadertools`. + +**Gentoo:** + +With the standard Gentoo compiler toolchain and a stable Rust toolchain +installed (`dev-lang/rust`, `dev-lang/rust-bin` or rustup): + +```bash +sudo emerge --ask dev-vcs/git dev-build/cmake dev-lang/nasm dev-lang/python \ + virtual/pkgconfig '>=dev-qt/qtbase-6.8:6[gui,network,opengl]' \ + '>=dev-qt/qtdeclarative-6.8:6[network,opengl]' \ + '>=dev-qt/qtsvg-6.8:6' '>=dev-qt/qtwayland-6.8:6' \ + media-libs/alsa-lib virtual/jack sys-apps/dbus dev-libs/openssl +``` + +Keep the Qt modules on the same version and enable the USE flags for your +display session (`X` or `wayland`). The Qt packages include their private +headers. If `qmake6` is not on PATH, set `QMAKE` to the Qt 6 executable, usually +`/usr/lib64/qt6/bin/qmake`. For shader editing, add `dev-qt/qtshadertools:6`. + +**NixOS / Nix:** + +Use a development shell with a nixpkgs revision that provides Qt 6.8 or newer. +For a manual checkout, the build dependencies can be loaded without installing +QBZ or adding development libraries to the system configuration: + +```bash +nix-shell -p rustc cargo python3 pkg-config cmake nasm \ + qt6.qmake qt6.wrapQtAppsHook qt6.qtbase qt6.qtdeclarative \ + qt6.qtsvg qt6.qtwayland alsa-lib libjack2 dbus openssl +``` + +The Qt setup hooks select `qmake` and expose the development headers. For +shader editing, add `qt6.qtshadertools`. When packaging the application, keep +`qt6.wrapQtAppsHook` so the installed binary can find its Qt plugins and QML +modules; see the [Nixpkgs Qt documentation](https://nixos.org/manual/nixpkgs/unstable/#sec-language-qt). + +**Fedora and other Linux distributions:** package names differ; look for the +equivalents of the list above — a C/C++ compiler plus clang/libclang, cmake, +nasm, the Qt 6 Base and Declarative modules with their development *and* +private headers, Qt Shader Tools, and ALSA, JACK, D-Bus and OpenSSL development +headers. Please open a PR if you confirm exact package names for your distro. + +**macOS:** Xcode Command Line Tools (`xcode-select --install`), a Rust +toolchain, and Qt 6 — Homebrew's `qt` is what the build is tested against +(`brew install qt`); the build script finds it at `/opt/homebrew/opt/qt` +without any `PATH` fiddling. + +**Windows x64 (experimental):** + +To compile and run against an installed Qt SDK instead of using the QBZ bundle, +install: + +- Visual Studio 2022 Build Tools with **Desktop development with C++**, + including the MSVC v143 x64 toolchain and a Windows 10 or 11 SDK. +- Rust stable for `x86_64-pc-windows-msvc`, Git, Python 3, CMake and NASM. +- Qt **6.9 or newer**, using the **MSVC 2022 64-bit** kit, with Qt Base, + Declarative/Quick, SVG and the private headers. CI currently uses **6.9.3**. + Qt Shader Tools is optional unless you are editing shaders. + +Use Qt's installer or an existing SDK with that kit. QBZ's Windows build uses +MSVC throughout; select the matching [Qt for Windows kit](https://doc.qt.io/archives/qt-6.9/windows.html). +Open an **x64 Native Tools Command Prompt for VS 2022**, then start PowerShell +from it. In the repository root, adjust the SDK path and run: + +```powershell +$env:QT_ROOT_DIR = 'C:\Qt\6.9.3\msvc2022_64' +$env:QMAKE = "$env:QT_ROOT_DIR\bin\qmake.exe" +$env:CMAKE_PREFIX_PATH = $env:QT_ROOT_DIR +$env:PATH = "$env:QT_ROOT_DIR\bin;$env:PATH" +$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER = "$env:VCToolsInstallDir\bin\Hostx64\x64\link.exe" +python scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -p qbz-qt --target x86_64-pc-windows-msvc +.\crates\target\x86_64-pc-windows-msvc\release\qbz.exe +``` + +Keep the SDK's `bin` directory on PATH when running this build so Qt's DLLs +can be found. WiX and .NET are only needed to build the MSI installer. + +### Build and run + +On Linux and macOS: ```bash git clone https://github.com/vicrodh/qbz.git && cd qbz -cargo build --release --manifest-path crates/Cargo.toml -p qbz +NORUN=1 ./scripts/qt-run.sh ./crates/target/release/qbz ``` -### The convenient way (Linux): `scripts/slint-run.sh` +A release build from scratch is on the order of ten minutes on a modern +desktop; incremental builds are a couple of minutes. The UI is QML and is +loaded at runtime, so a change that touches only `.qml` does not go through +`rustc` at all. -The repo ships the build script we use ourselves — it reads `MemAvailable` -and picks a tier automatically, so you don't have to think about any of the -above: +### The build script: `scripts/qt-run.sh` -| Free RAM | Tier | Settings | -|----------|------|----------| -| ≥ 26 GB | FAST | threads=16, cgu=16, opt=3 — the distribution-grade build | -| 14–26 GB | SAFE | threads=2, cgu=256, opt=3 | -| < 14 GB | MIN | threads=1, cgu=256, opt=2 — slow but never freezes | +The repo ships the build script we use ourselves. It runs the static QML audits +first (they catch the class of mistake `cargo check` cannot see — a missing +component, or a call to a bridge member that does not exist — in about a +second), then builds and executes the binary directly: ```bash -./scripts/slint-run.sh # build (auto-tier) and run the binary -NORUN=1 ./scripts/slint-run.sh # build only -FAST=1 ./scripts/slint-run.sh # force the fast tier (close your apps first) -THREADS=4 CODEGEN_UNITS=128 OPT=3 ./scripts/slint-run.sh # manual override +./scripts/qt-run.sh # build (release) and run +DEBUG=1 ./scripts/qt-run.sh # debug profile +NORUN=1 ./scripts/qt-run.sh # build only +TEST=1 ./scripts/qt-run.sh # also run the crate's tests +SMOKE=1 ./scripts/qt-run.sh # offscreen smoke run instead of the GUI +NO_AUDIT=1 ./scripts/qt-run.sh # skip the QML audits +JOBS=4 ./scripts/qt-run.sh # cargo build jobs ``` -It requires the nightly toolchain and `mold`. Note that SAFE/MIN produce a -functionally identical but not byte-identical binary vs FAST (different -codegen-units/opt-level), and switching tiers forces a one-time rebuild of the -UI crate. +It works on Linux and macOS. -### Development iteration: release vs fast-debug - -Two dev loops, on purpose: - -- **`./scripts/slint-dev.sh`** — builds and runs in **release** mode with the - parallel compiler frontend. This is the default dev loop: runtime - performance is the whole reason QBZ is a native app, so we always test at - release perf and a regression can never hide behind "it's just dev mode". - The binary is the same optimized artifact as `cargo build --release`; only - compile time improves. -- **`./scripts/slint-dev-fast.sh`** — **debug** build, dramatically faster to - compile and far lighter on RAM. Use it for purely visual/layout iteration - where runtime performance does not matter. Never benchmark on it. - -On macOS use **`./scripts/slint-dev-mac.sh`** (same intent, Apple-toolchain -flags; works on Apple Silicon and Intel — an 8 GB M-series Mac builds fine, -just slowly, thanks to macOS's aggressive swap). +Qt builds use `scripts/qt-cargo.py` to include the installed SDK's header +contents in the native build cache. This prevents mixing stale C++ objects +with new Qt headers after a system update, without deleting `target/` or +changing Rust optimization flags. For custom Cargo commands, use +`python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -p qbz-qt` +(or `test` instead of `build`). On Linux, the smoke uses an isolated profile +and private D-Bus session, and fails on early exits, including segmentation +faults. The CI runtime gate checks both debug and release. ### Nix / NixOS -`flake.nix` builds the same `qbz` binary via `rustPlatform.buildRustPackage` -(`cargoBuildFlags = [ "-p" "qbz" ]`, root at `crates/`) — see the -[NixOS / Nix](#nixos--nix) install section above, or run `nix build` / -`nix develop` directly from a checkout. +`flake.nix` builds the same binary via `rustPlatform.buildRustPackage` with the +crate root at `crates/` — see the [NixOS / Nix](#nixos--nix) install section +above, or run `nix build` / `nix develop` directly from a checkout. -### API Proxy +### API proxy -Last.fm, Discogs, Tidal, Spotify-import, and MusicBrainz traffic goes through a hosted Cloudflare Workers proxy (`qbz-api-proxy.blitzkriegfc.workers.dev`) that holds all credentials server-side. Both pre-built releases and source builds use it out of the box — **no API keys or `.env` file required**. +Last.fm, Discogs, Tidal, Spotify-import and MusicBrainz traffic goes through a +hosted Cloudflare Workers proxy (`qbz-api-proxy.blitzkriegfc.workers.dev`) that +holds all credentials server-side. Both pre-built releases and source builds use +it out of the box — **no API keys or `.env` file required**. -If you want to run against your own proxy (for development, or if you fork QBZ), the proxy source lives at [`vicrodh/qbz-api-proxy`](https://github.com/vicrodh/qbz-api-proxy). Deploy it with `wrangler deploy` and then edit the `*_PROXY_URL` constants in `crates/qbz-integrations/src/lastfm/client.rs`, `crates/qbz-integrations/src/discogs/mod.rs`, `crates/qbz-playlist-import/src/providers/tidal.rs`, and `crates/qbz-integrations/src/musicbrainz/client.rs` to point at your worker before rebuilding. +If you want to run against your own proxy (for development, or if you fork +QBZ), the proxy source lives at +[`vicrodh/qbz-api-proxy`](https://github.com/vicrodh/qbz-api-proxy). Deploy it +with `wrangler deploy` and then edit the `*_PROXY_URL` constants in +`crates/qbz-integrations/src/lastfm/client.rs`, +`crates/qbz-integrations/src/discogs/mod.rs`, +`crates/qbz-playlist-import/src/providers/tidal.rs` and +`crates/qbz-integrations/src/musicbrainz/client.rs` to point at your worker +before rebuilding. -### Environment Variables +## Environment variables -As of 2.0, QBZ auto-detects a working renderer (GPU wgpu → femtovg/OpenGL → software) at startup across Wayland/X11/Metal, so there is normally nothing to configure. One override remains for diagnostics or a broken GPU stack: +QBZ picks a working renderer at startup and reverts automatically if a forced +choice fails to produce frames, so there is normally nothing to configure. The +renderer can also be set from **Settings → Appearance**; the environment +variable overrides it for one launch. | Variable | Effect | |----------|--------| -| `QBZ_RENDERER=software` (or `cpu`, `soft`) | Force the software renderer (crash recovery, VMs) | -| `QBZ_RENDERER=gl` (or `gles`, `femtovg`) | Force the femtovg/OpenGL renderer (mid-tier GPUs) | -| `QBZ_RENDERER=wgpu` (or `gpu`, `hardware`, `hw`) | Force the wgpu (GPU) renderer | +| `QBZ_RENDERER=auto` (or `gpu`, `hardware`, `hw`) | Qt's default backend — the GPU path. This is the default | +| `QBZ_RENDERER=gl` | Force the OpenGL backend (`QSG_RHI_BACKEND=opengl`) on Linux. On macOS this resolves to Metal | +| `QBZ_RENDERER=software` (or `cpu`, `soft`) | Force the software renderer (`QT_QUICK_BACKEND=software`) — for VMs and broken GPU stacks | +| `QBZ_PROFILE=kiosk` | Boot the touch-first Kiosk shell (`desktop` for the normal one) | +| `QBZ_KIOSK_FULLSCREEN=1` | Start Kiosk mode fullscreen | + +Qt's own variables (`QSG_RHI_BACKEND`, `QT_QUICK_BACKEND`, `QT_QPA_PLATFORM`, +`QT_SCALE_FACTOR`…) work as usual and take precedence over `QBZ_RENDERER`. If QBZ fails to start, try `QBZ_RENDERER=software qbz` first. -## Known Issues +## Known issues -- **Hi-Res seeking** — seeking in tracks >96kHz can take 10-20s (decoder must scan from start). Use prev/next for instant navigation. -- **ALSA Direct** — exclusive access blocks other apps. Use DAC/amplifier physical volume control. -- **PipeWire bit-perfect in Flatpak** — limited by sandbox. Use ALSA Direct or native packages. -- **DSD DoP / native mode** — seeking is disabled and volume is fixed while a DoP or native-DSD stream is active (any sample manipulation would corrupt the DSD stream). Convert-to-PCM mode has no such limits. +- **Hi-Res seeking** — seeking in tracks above 96 kHz can take 10–20 s (the + decoder must scan from the start). Use prev/next for instant navigation. +- **ALSA Direct** — exclusive access blocks other apps. Use your DAC's or + amplifier's physical volume control. +- **DSD DoP / native mode** — seeking is disabled and volume is fixed while a + DoP or native-DSD stream is active (any sample manipulation would corrupt the + DSD stream). Convert-to-PCM mode has no such limits. +- **Multichannel DSD comes out as stereo** — DoP and native passthrough are + two-channel by design (a DoP receiver is a stereo device). Multichannel + sources therefore play through the convert-to-PCM path, which folds up to 5.1 + down to stereo (ITU-R BS.775, LFE dropped); 7.1 and above are not supported + and the file will not load. SACD discs play their stereo area. + +## Supporting QBZ + +QBZ is deeply grateful for every sponsor and supporter. There is no premium +edition and no sponsor-only functionality; your support helps sustain the +project while the same complete app remains available to everyone. Starting +with QBZ 2.1.0, public sponsors and supporters are thanked by name in the +app's **About** dialog. Private or anonymous support remains unnamed — and is +just as appreciated. + +## Reporting a problem + +Having trouble with the app? Please report it in the +[issue tracker](https://github.com/vicrodh/qbz/issues). + +What makes a problem solvable: a title that names the actual symptom, the logs, +and a screenshot when it is something you can see. QBZ ships a tool for the logs +so you don't have to go hunting for files — **Settings → Share logs**, pinned at +the bottom of the Settings sidebar, opens the in-app log viewer. From there you +can filter by level, search, copy the log with secrets redacted, or upload it and +get a link to paste into the issue. + +## On LLMs and AI tools + +This project started about two years ago. I wrote the first version in Python, +which is my main stack. Then it became my Rust learning project — most of the +logic was written by hand, as a hobby, a proof to myself of what I could build +and how much I could learn. It then sat frozen for a long time. + +Then LLMs went mainstream. They helped me put a real UX on top of what I already +had, they unblocked problems I had never found the time for, and they compressed +work that, at the pace and the hours I could actually give it, would have taken +months — or would have left the project sitting in a folder on my NAS forever. + +Somewhere along the way QBZ filled a gap, and that made it relevant. Paying +attention and caring about the details also means more responsibility, and more +responsibility means more transparency: the project started by hand, but the +amount of code I type myself today is minimal. That does not mean the code is +careless. It goes through the same standards I use in my day job — the real one +— it is under continuous improvement, and it is always open to comments, +suggestions, requests and issue reports. + +Using AI tools does not mean I ask an LLM for every feature or change that +crosses my mind, and it does not mean every request for a change or a new +feature gets accepted without analysis. I go out of my way to avoid that rabbit +hole — have a look at the [issues](https://github.com/vicrodh/qbz/issues). So if +you come up with something new, help me by explaining it and justifying it. If I +don't understand it, it is very unlikely to go in, because I cannot measure it +or test it properly. + +I know we all want a lot of things. But keeping this app from turning into +[The Homer](https://tenor.com/IeFy.gif) is real work. + +Almost all of the documentation, except this README, is AI slop. Seriously, +help writing or improving it is welcome. I hate writing documentation, and +who doesn't? + +If you have a problem using software built with AI tools, this software is +probably not for you. ## Documentation -User guides, audio configuration, integrations, and troubleshooting: **[QBZ Wiki](https://github.com/vicrodh/qbz/wiki)** (work in progress). - - -## Open Source - -QBZ is MIT-licensed. No telemetry, no tracking, no hidden services. Built for Linux and macOS audio enthusiasts. +User guides, audio configuration, integrations and troubleshooting: +**[QBZ Wiki](https://github.com/vicrodh/qbz/wiki)** (work in progress). ## Contributing -Contributions welcome. Please read `CONTRIBUTING.md` before submitting issues or pull requests. +QBZ is MIT-licensed. No telemetry, no tracking, no hidden services. Built for +Linux and macOS audio enthusiasts. + +Contributions welcome. Please read `CONTRIBUTING.md` before submitting issues or +pull requests. ### Contributors @@ -497,10 +866,27 @@ Contributions welcome. Please read `CONTRIBUTING.md` before submitting issues or - [@boxdot](https://github.com/boxdot) - [@arminfelder](https://github.com/arminfelder) - [@afonsojramos](https://github.com/afonsojramos) — macOS port -- [@Vudgekek](https://github.com/Vudgekek) — macOS audio - [@GwendalBeaumont](https://github.com/GwendalBeaumont) — i18n - [@AdamArstall](https://github.com/AdamArstall) +- [@Vudgekek](https://github.com/Vudgekek) — macOS audio - [@DoubleGate](https://github.com/DoubleGate) +- [@hoyon](https://github.com/hoyon) — classical work grouping +- [@mxnix](https://github.com/mxnix) — Russian translation +- [@TerminalTilt](https://github.com/TerminalTilt) — Catppuccin themes +- [@Alexandre-Menigault](https://github.com/Alexandre-Menigault) — active lyrics wrapping +- [@MarkusAbtion](https://github.com/MarkusAbtion) — opt-in section navigation +- [@fengalin](https://github.com/fengalin) — crypto-provider test coverage +- [@luukvanderduim](https://github.com/luukvanderduim) — applied-filter visibility +- [@pbaart](https://github.com/pbaart) — Dutch translation +- [@b0bbywan](https://github.com/b0bbywan) — ALSA buffer sizing +- [@stshow](https://github.com/stshow) — DLNA strict-renderer casting +- [@Ronjar](https://github.com/Ronjar) — deb822 APT instructions +- [@eldios](https://github.com/eldios) — Nix packaging +- [@herder](https://github.com/herder) — Spotify-parity hotkeys and the Vim keymap +- [@PhilipVinc](https://github.com/PhilipVinc) — daemon event hooks +- [@Mazipani](https://github.com/Mazipani) — Chromecast X.509 v1 certificates +- [@RayneGit](https://github.com/RayneGit) — Wayland clipboard +- [@LuckyTheCoder](https://github.com/LuckyTheCoder) — macOS Liquid Glass icon ## License diff --git a/assets/steam/hero.png b/assets/steam/hero.png new file mode 100644 index 000000000..23bcedeb2 Binary files /dev/null and b/assets/steam/hero.png differ diff --git a/assets/steam/landscape.png b/assets/steam/landscape.png new file mode 100644 index 000000000..dc2996522 Binary files /dev/null and b/assets/steam/landscape.png differ diff --git a/assets/steam/logo.png b/assets/steam/logo.png new file mode 100644 index 000000000..5eebdb0c1 Binary files /dev/null and b/assets/steam/logo.png differ diff --git a/assets/steam/portrait.png b/assets/steam/portrait.png new file mode 100644 index 000000000..cda210a19 Binary files /dev/null and b/assets/steam/portrait.png differ diff --git a/assets/steam/qbz_controller_neptune.vdf b/assets/steam/qbz_controller_neptune.vdf new file mode 100644 index 000000000..832a5d6eb --- /dev/null +++ b/assets/steam/qbz_controller_neptune.vdf @@ -0,0 +1,481 @@ +// QBZ — Steam Deck (neptune) controller layout, seeded by qbz-add-to-steam. +// +// Based on the stock Steam "Gamepad" template (controller_neptune_gamepad_joystick.vdf, +// Steam client 2026-06) — PURE GAMEPAD: only virtual X360 pad output, NO keyboard +// or mouse emulation, so QBZ's own gilrs gamepad thread is the single input source +// (see qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md, PR 3/PR 5). +// +// The one deviation from the template: Y is bound to Steam's "Show Keyboard" +// system action (controller_action SHOW_KEYBOARD — token verified in +// steamclient.so) as a manual OSK fallback next to the steam://open/keyboard +// deeplink. Y therefore does NOT emit an XInput button. +// +// TODO(verify-on-hardware): this file is authored from the known exported +// config format, not yet exported/validated by the Deck UI. Do the one-time +// validation from the plan: load it on a real Deck in Game Mode, confirm the +// layout applies on first launch (configset_.vdf path) and that Y +// opens the OSK, then re-export and replace this file if the format drifted. +"controller_mappings" +{ + "version" "3" + "title" "QBZ Kiosk (gamepad + Y=keyboard)" + "description" "Pure gamepad layout for the QBZ kiosk: D-pad/stick navigate, A activates, B goes back, Y shows the Steam keyboard. No keyboard/mouse emulation." + "controller_type" "controller_neptune" + "major_revision" "0" + "minor_revision" "0" + "group" + { + "id" "0" + "mode" "four_buttons" + "name" "" + "description" "" + "inputs" + { + "button_a" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button A, Activate, , " + } + } + } + } + "button_b" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button B, Back, , " + } + } + } + } + "button_x" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button X, , , " + } + } + } + } + "button_y" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "controller_action SHOW_KEYBOARD, Show Keyboard, , " + } + } + } + } + } + } + "group" + { + "id" "1" + "mode" "dpad" + "name" "" + "description" "" + "inputs" + { + "dpad_north" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button dpad_up, , , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + } + "dpad_south" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button dpad_down, , , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + } + "dpad_east" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button dpad_right, , , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + } + "dpad_west" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button dpad_left, , , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + } + } + } + "group" + { + "id" "2" + "mode" "joystick_move" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Soft_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_RIGHT, , , " + } + } + } + } + } + } + "group" + { + "id" "3" + "mode" "joystick_move" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_LEFT, , , " + } + } + } + } + } + "settings" + { + "deadzone_inner_radius" "7199" + } + } + "group" + { + "id" "4" + "mode" "trigger" + "name" "" + "description" "" + "inputs" + { + } + "settings" + { + "output_trigger" "1" + } + } + "group" + { + "id" "5" + "mode" "trigger" + "name" "" + "description" "" + "inputs" + { + } + "settings" + { + "output_trigger" "2" + } + } + "group" + { + "id" "7" + "mode" "switches" + "name" "" + "description" "" + "inputs" + { + "button_escape" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button start, , , " + } + } + } + } + "button_menu" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button select, , , " + } + } + } + } + "left_bumper" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button shoulder_left, , , " + } + } + } + } + "right_bumper" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button shoulder_right, , , " + } + } + } + } + "button_back_left" + { + "activators" + { + "Full_Press" + { + "bindings" + { + } + } + } + } + "button_back_right" + { + "activators" + { + "Full_Press" + { + "bindings" + { + } + } + } + } + "button_back_left_upper" + { + "activators" + { + "Full_Press" + { + "bindings" + { + } + } + } + } + "button_back_right_upper" + { + "activators" + { + "Full_Press" + { + "bindings" + { + } + } + } + } + } + } + "group" + { + "id" "8" + "mode" "joystick_move" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_RIGHT, , , " + } + } + } + } + } + } + "group" + { + "id" "9" + "mode" "dpad" + "name" "" + "description" "" + "inputs" + { + "dpad_north" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button DPAD_UP, , , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + } + "dpad_south" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button DPAD_DOWN, , , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + } + "dpad_east" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button DPAD_RIGHT, , , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + } + "dpad_west" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button DPAD_LEFT, , , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + } + } + "settings" + { + "requires_click" "0" + } + } + "preset" + { + "id" "0" + "name" "Default" + "group_source_bindings" + { + "7" "switch active" + "0" "button_diamond active" + "1" "left_trackpad active" + "2" "right_trackpad active" + "3" "joystick active" + "4" "left_trigger active" + "5" "right_trigger active" + "8" "right_joystick active" + "9" "dpad active" + } + } + "settings" + { + "left_trackpad_mode" "0" + "right_trackpad_mode" "0" + } +} diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 11f471f64..14a203420 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -2,111 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - -[[package]] -name = "accesskit" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eca13c82f9a5cd813120b2e9b6a5d10532c6e4cd140c295cebd1f770095c8a5" - -[[package]] -name = "accesskit_atspi_common" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb9cc46b7fb6987c4f891f0301b230b29d9e69b4854f060a0cf41fbc407ab77" -dependencies = [ - "accesskit", - "accesskit_consumer", - "atspi-common", - "serde", - "zvariant 5.11.0", -] - -[[package]] -name = "accesskit_consumer" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d880a613f29621c90e801feec40f5dd61d837d7e20bf9b67676d45e7364a36" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_macos" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0ddfc3fe3d457d11cc1c4989105986a03583a1d54d0c25053118944b62e100" -dependencies = [ - "accesskit", - "accesskit_consumer", - "hashbrown 0.16.1", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "accesskit_unix" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d552169ef018149966ed139bb0311c6947b3343e9140d1b9f88d69da9528fd" -dependencies = [ - "accesskit", - "accesskit_atspi_common", - "async-channel", - "async-executor", - "async-task", - "atspi", - "futures-lite", - "futures-util", - "serde", - "zbus 5.15.0", -] - -[[package]] -name = "accesskit_windows" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d277279d0a3b0c0021dd110b55aa1fe326b09ee2cbc338df28f847c7daf94e25" -dependencies = [ - "accesskit", - "accesskit_consumer", - "hashbrown 0.16.1", - "static_assertions", - "windows 0.61.3", - "windows-core 0.61.2", -] - -[[package]] -name = "accesskit_winit" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db08dff285306264a1de127ea07bb9e7a1ed71bd8593c168d0731caa782516c9" -dependencies = [ - "accesskit", - "accesskit_macos", - "accesskit_unix", - "accesskit_windows", - "raw-window-handle", - "winit", -] - [[package]] name = "adler2" version = "2.0.1" @@ -148,46 +43,15 @@ dependencies = [ "subtle", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[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" @@ -201,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -216,60 +80,15 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "android-activity" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" -dependencies = [ - "android-properties", - "bitflags 2.13.0", - "cc", - "jni 0.22.4", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "num_enum", - "thiserror 2.0.18", -] - -[[package]] -name = "android-build" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "android-properties" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" - [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] -[[package]] -name = "annotate-snippets" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" -dependencies = [ - "anstyle", - "memchr", - "unicode-width", -] - [[package]] name = "anstream" version = "1.0.0" @@ -322,9 +141,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -335,12 +154,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - [[package]] name = "arboard" version = "3.6.1" @@ -358,46 +171,15 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] -[[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 = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "ascii" @@ -405,48 +187,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" -[[package]] -name = "ash" -version = "0.38.0+1.3.281" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" -dependencies = [ - "libloading 0.8.9", -] - [[package]] name = "ashpd" -version = "0.11.1" +version = "0.13.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.3", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus 5.15.0", -] - -[[package]] -name = "ashpd" -version = "0.13.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" dependencies = [ "enumflags2", "futures-util", - "getrandom 0.4.2", + "getrandom 0.4.3", "serde", - "zbus 5.15.0", + "zbus 5.19.0", ] [[package]] @@ -510,7 +261,7 @@ dependencies = [ "futures-io", "futures-lite", "parking", - "polling 3.11.0", + "polling", "rustix 1.1.4", "slab", "windows-sys 0.61.2", @@ -527,17 +278,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - [[package]] name = "async-process" version = "2.5.0" @@ -564,7 +304,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -593,13 +333,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -617,109 +357,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atspi" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" -dependencies = [ - "atspi-common", - "atspi-proxies", -] - -[[package]] -name = "atspi-common" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" -dependencies = [ - "enumflags2", - "serde", - "static_assertions", - "zbus 5.15.0", - "zbus-lockstep", - "zbus-lockstep-macros", - "zbus_names 4.3.2", - "zvariant 5.11.0", -] - -[[package]] -name = "atspi-proxies" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" -dependencies = [ - "atspi-common", - "serde", - "zbus 5.15.0", -] - -[[package]] -name = "auto_enums" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65398a2893f41bce5c9259f6e1a4f03fbae40637c1bdc755b4f387f48c613b03" -dependencies = [ - "derive_utils", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey 0.1.1", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" -dependencies = [ - "arrayvec", -] +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -727,14 +375,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -744,34 +393,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bincode" -version = "2.0.1" +name = "base64" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "serde", - "unty", -] - -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.13.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn 2.0.117", -] +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bit-set" @@ -779,16 +404,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec 0.6.3", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", + "bit-vec", ] [[package]] @@ -797,18 +413,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -817,21 +421,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block" @@ -850,9 +442,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -886,9 +478,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -897,27 +489,17 @@ dependencies = [ "piper", ] -[[package]] -name = "borsh" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" -dependencies = [ - "bytes", - "cfg_aliases", -] - [[package]] name = "built" -version = "0.8.1" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "by_address" @@ -927,23 +509,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -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", -] +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -959,60 +527,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "calloop" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" -dependencies = [ - "bitflags 2.13.0", - "log", - "polling 3.11.0", - "rustix 0.38.44", - "slab", - "thiserror 1.0.69", -] - -[[package]] -name = "calloop" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" -dependencies = [ - "bitflags 2.13.0", - "polling 3.11.0", - "rustix 1.1.4", - "slab", - "tracing", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" -dependencies = [ - "calloop 0.13.0", - "rustix 0.38.44", - "wayland-backend", - "wayland-client", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.4", - "rustix 1.1.4", - "wayland-backend", - "wayland-client", -] +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -1034,9 +551,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -1050,15 +567,6 @@ 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 7.1.3", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -1067,35 +575,26 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "cgl" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" -dependencies = [ - "libc", -] +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.0", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1122,21 +621,19 @@ dependencies = [ ] [[package]] -name = "clang-sys" -version = "1.8.1" +name = "clang-format" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "696283b40e1a39d208ee614b92e5f6521d16962edeb47c48372585ec92419943" dependencies = [ - "glob", - "libc", - "libloading 0.8.9", + "thiserror 1.0.69", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -1144,9 +641,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1156,23 +653,23 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.7" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -1190,15 +687,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "clru" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "cmake" version = "0.1.58" @@ -1218,8 +706,8 @@ dependencies = [ "block", "cocoa-foundation", "core-foundation 0.9.4", - "core-graphics 0.22.3", - "foreign-types 0.3.2", + "core-graphics", + "foreign-types", "libc", "objc", ] @@ -1233,27 +721,31 @@ dependencies = [ "bitflags 1.3.2", "block", "core-foundation 0.9.4", - "core-graphics-types 0.1.3", + "core-graphics-types", "libc", "objc", ] [[package]] name = "codespan-reporting" -version = "0.12.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ - "serde", "termcolor", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] -name = "color_quant" -version = "1.1.0" +name = "codespan-reporting" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.2", +] [[package]] name = "colorchoice" @@ -1263,9 +755,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -1294,33 +786,21 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-field-offset" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9713489fbc45a2d113f58d17448c73115d533ed27fd2f2d0e5e5c69663eaf19b" -dependencies = [ - "const-field-offset-macro", - "field-offset", -] - -[[package]] -name = "const-field-offset-macro" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b5dc851b7ea80cc4f69aa28632385ffd3e0f88014d53db3f2e5a781896ca783" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[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 = "convert_case" version = "0.10.0" @@ -1332,9 +812,9 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "percent-encoding", "time", @@ -1359,20 +839,6 @@ dependencies = [ "url", ] -[[package]] -name = "copypasta" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" -dependencies = [ - "clipboard-win", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "smithay-clipboard", - "x11-clipboard", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -1407,21 +873,8 @@ checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types 0.3.2", - "libc", -] - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types 0.5.0", + "core-graphics-types", + "foreign-types", "libc", ] @@ -1436,33 +889,13 @@ dependencies = [ "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.13.0", - "core-foundation 0.10.1", - "libc", -] - -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - [[package]] name = "coreaudio-rs" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16dd574a72a021b90c7656c474ea31d11a2f0366a8eff574186e761e0b9e3586" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -1470,12 +903,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "countme" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" - [[package]] name = "cpal" version = "0.17.3" @@ -1517,18 +944,33 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "crc" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1541,37 +983,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" 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" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -1579,7 +1002,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -1619,9 +1042,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -1636,15 +1059,6 @@ dependencies = [ "phf", ] -[[package]] -name = "ctor" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" -dependencies = [ - "dtor", -] - [[package]] name = "ctr" version = "0.9.2" @@ -1655,16 +1069,154 @@ dependencies = [ ] [[package]] -name = "cursor-icon" -version = "1.2.0" +name = "cxx" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +checksum = "824894a4a85dca76d4c95c2b9098c036f5a29f627b30c12780774f6654e60974" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash 0.2.0", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1ae0b651ea5b0000b19513aef5a03f194d7e3486f2d9258b658da8677fe9036" +dependencies = [ + "cc", + "codespan-reporting 0.13.1", + "indexmap", + "proc-macro2", + "quote", + "scratch", + "syn 3.0.4", +] + +[[package]] +name = "cxx-gen" +version = "0.7.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e54565cd4c9385e30c81b73b0f3702a7c92b3c86aad59c6d5d50d6dd9fb870" +dependencies = [ + "codespan-reporting 0.13.1", + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "cxx-qt" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb4ce9106b3ee7ef85f77d5f69ec30ec3037ea1edacd3e29a61b26ff47ecc637" +dependencies = [ + "cxx", + "cxx-qt-build", + "cxx-qt-macro", + "qt-build-utils", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "cxx-qt-build" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23a7884708e645dc34a2c475bd8c505a2ffeb7c9cb0843f5c580c002c939ea30" +dependencies = [ + "cc", + "codespan-reporting 0.11.1", + "cxx-gen", + "cxx-qt-gen", + "proc-macro2", + "qt-build-utils", + "quote", + "serde", + "serde_json", + "version_check", +] + +[[package]] +name = "cxx-qt-gen" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6b431dc58e4a9b7a55b675be0941331cc2cab0a494e4742ebf7bb393c3fc39" +dependencies = [ + "clang-format", + "convert_case 0.6.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cxx-qt-lib" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527b46c28be6bf3dd02f1567706641fe247a8798defab8268c84602955741217" +dependencies = [ + "cxx", + "cxx-qt", + "cxx-qt-build", + "qt-build-utils", +] + +[[package]] +name = "cxx-qt-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "971d8811fd1c8dd06c17284edcfc8e9663dac30d7e3d52159405f836a81923f1" +dependencies = [ + "cxx-qt-gen", + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb05f91d3fb8435d9bab6ac5ce6ac1868be774325fb7fb2a91be39393b21388e" +dependencies = [ + "clap", + "codespan-reporting 0.13.1", + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf293202e0e3e98495785745389e8d0755b217e66f19194a5c695c25e03282ef" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca001d746947c7249ed9d332a10f7a59daedbafeb0ec68c5c18a7db7a93f6ccc" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.4", +] [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -1672,26 +1224,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -1711,25 +1263,19 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbus" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1759,6 +1305,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deltae" version = "0.3.2" @@ -1770,9 +1347,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_more" @@ -1789,23 +1363,11 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", - "unicode-xid", -] - -[[package]] -name = "derive_utils" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "362f47930db19fe7735f527e6595e4900316b893ebf6d48ad3d31be928d57dd6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1825,9 +1387,9 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -1883,7 +1445,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_repr", - "thiserror 2.0.18", + "thiserror 2.0.20", "uuid 0.8.2", ] @@ -1899,7 +1461,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1907,22 +1469,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading 0.8.9", + "syn 3.0.4", ] [[package]] @@ -1941,57 +1494,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "dpi" +name = "dst-decoder" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - -[[package]] -name = "drm" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +checksum = "0d22ef63dfde0eac89eca20926f8bb64eec4bf12016647274a51d3bb11f6147c" dependencies = [ - "bitflags 2.13.0", - "bytemuck", - "drm-ffi", - "drm-fourcc", - "libc", - "rustix 0.38.44", + "anyhow", ] -[[package]] -name = "drm-ffi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" -dependencies = [ - "drm-sys", - "rustix 1.1.4", -] - -[[package]] -name = "drm-fourcc" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" - -[[package]] -name = "drm-sys" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" -dependencies = [ - "libc", - "linux-raw-sys 0.9.4", -] - -[[package]] -name = "dtor" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" - [[package]] name = "dunce" version = "1.0.5" @@ -2012,9 +1522,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encoding_rs" @@ -2049,14 +1559,14 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "env_filter" -version = "1.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -2064,9 +1574,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -2075,26 +1585,6 @@ dependencies = [ "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" @@ -2113,9 +1603,9 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "euclid" @@ -2128,11 +1618,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2147,21 +1636,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "extended" version = "0.1.0" @@ -2186,21 +1660,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set 0.5.3", + "bit-set", "regex", ] -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" @@ -2217,35 +1685,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "femtovg" -version = "0.23.2" -dependencies = [ - "bitflags 2.13.0", - "bytemuck", - "fnv", - "glow 0.17.0", - "imgref", - "itertools 0.14.0", - "log", - "rgb", - "slotmap", - "swash", - "wasm-bindgen", - "web-sys", - "wgpu", -] - -[[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 = "filedescriptor" version = "0.8.3" @@ -2259,20 +1698,19 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "finl_unicode" @@ -2287,20 +1725,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] -name = "flate2" -version = "1.1.9" +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flacenc" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c892c2b5fa08f967e8b5ad29121570b4762202f2402033ce08479ec65eccd0" dependencies = [ - "crc32fast", - "miniz_oxide", + "built", + "crc", + "crossbeam-channel", + "heapless", + "log", + "md-5", + "num-traits", + "rustversion", + "seq-macro", + "serde", ] [[package]] -name = "float-cmp" -version = "0.9.0" +name = "flate2" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] [[package]] name = "float-cmp" @@ -2313,9 +1770,9 @@ dependencies = [ [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -2340,77 +1797,13 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "font-types" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "fontdb" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" -dependencies = [ - "log", - "slotmap", - "tinyvec", - "ttf-parser", -] - -[[package]] -name = "fontique" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23358480a54a886d51b306718d5ca743fdfe238e5df38ef650f4e72f29c5d9e9" -dependencies = [ - "hashbrown 0.16.1", - "linebender_resource_handle", - "memmap2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-text", - "objc2-foundation 0.3.2", - "parlance", - "read-fonts", - "roxmltree 0.21.1", - "smallvec", - "windows 0.62.2", - "windows-core 0.62.2", - "yeslogic-fontconfig-sys", -] - [[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", + "foreign-types-shared", ] [[package]] @@ -2419,12 +1812,6 @@ 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.2" @@ -2441,25 +1828,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "futures" -version = "0.3.32" +name = "fsevent-sys" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "libc", ] [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2467,26 +1848,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" 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", -] +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2503,34 +1873,33 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2541,28 +1910,6 @@ dependencies = [ "slab", ] -[[package]] -name = "gbm" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" -dependencies = [ - "bitflags 2.13.0", - "drm", - "drm-fourcc", - "gbm-sys", - "libc", -] - -[[package]] -name = "gbm-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" -dependencies = [ - "libc", -] - [[package]] name = "genawaiter" version = "0.99.1" @@ -2599,15 +1946,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -2615,10 +1953,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -2628,25 +1964,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", ] [[package]] @@ -2659,162 +1991,11 @@ dependencies = [ "polyval", ] -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "glow" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glow" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" -dependencies = [ - "bitflags 2.13.0", - "cfg_aliases", - "cgl", - "dispatch2", - "glutin_egl_sys", - "glutin_glx_sys", - "glutin_wgl_sys", - "libloading 0.8.9", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "raw-window-handle", - "wayland-sys", - "windows-sys 0.52.0", - "x11-dl", -] - -[[package]] -name = "glutin-winit" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" -dependencies = [ - "cfg_aliases", - "glutin", - "raw-window-handle", - "winit", -] - -[[package]] -name = "glutin_egl_sys" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" -dependencies = [ - "gl_generator", - "windows-sys 0.52.0", -] - -[[package]] -name = "glutin_glx_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" -dependencies = [ - "gl_generator", - "x11-dl", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gpu-allocator" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" -dependencies = [ - "ash", - "hashbrown 0.16.1", - "log", - "presser", - "thiserror 2.0.18", - "windows 0.62.2", -] - -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.13.0", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.13.0", -] - -[[package]] -name = "grid" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "half" @@ -2824,30 +2005,16 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", - "num-traits", "zerocopy", ] [[package]] -name = "harfrust" -version = "0.5.2" +name = "hash32" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" dependencies = [ - "bitflags 2.13.0", - "bytemuck", - "core_maths", - "read-fonts", - "smallvec", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", + "byteorder", ] [[package]] @@ -2883,11 +2050,22 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.9.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.17.1", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "serde", + "stable_deref_trait", ] [[package]] @@ -2896,12 +2074,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - [[package]] name = "hermit-abi" version = "0.5.2" @@ -2914,12 +2086,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hexf-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" - [[package]] name = "hkdf" version = "0.12.4" @@ -2947,17 +2113,11 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "htmlparser" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144" - [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2965,9 +2125,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2975,9 +2135,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -3000,18 +2160,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.8.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -3022,7 +2182,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3030,19 +2189,17 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.6", ] [[package]] @@ -3051,7 +2208,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3062,296 +2219,12 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "tokio", "tower-service", "tracing", ] -[[package]] -name = "i-slint-backend-android-activity" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a318b5aebc2ad8e6fdbac8588984d9ea2e99a210449954717073d8a246e12fe7" -dependencies = [ - "android-build", - "i-slint-core", - "i-slint-renderer-skia", - "jni 0.22.4", - "raw-window-handle", -] - -[[package]] -name = "i-slint-backend-linuxkms" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3224c3aa9f3ea568daf38389d0f7ace164eda4d6f1851042eb83750ac43e1a65" -dependencies = [ - "bytemuck", - "calloop 0.14.4", - "cfg_aliases", - "drm", - "gbm", - "glutin", - "i-slint-common", - "i-slint-core", - "i-slint-renderer-femtovg", - "i-slint-renderer-skia", - "i-slint-renderer-software", - "input", - "memmap2", - "nix 0.30.1", - "raw-window-handle", - "xkbcommon", -] - -[[package]] -name = "i-slint-backend-selector" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93c9c4203e0a2c5c396b8f1686160816f73e9b9cadaf3915aa3b888a83448aa" -dependencies = [ - "cfg-if", - "i-slint-backend-linuxkms", - "i-slint-backend-winit", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "i-slint-renderer-femtovg", - "i-slint-renderer-skia", -] - -[[package]] -name = "i-slint-backend-winit" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4df6ec88d452e3c22e418d20c96bc1ccbc9509e81e6596426d3388bf226a5f9" -dependencies = [ - "accesskit", - "accesskit_winit", - "block2 0.6.2", - "bytemuck", - "cfg-if", - "cfg_aliases", - "copypasta", - "derive_more", - "futures", - "glutin", - "glutin-winit", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "i-slint-renderer-femtovg", - "i-slint-renderer-skia", - "i-slint-renderer-software", - "imgref", - "lyon_path", - "muda", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "objc2-ui-kit 0.3.2", - "pin-weak", - "raw-window-handle", - "rgb", - "scoped-tls-hkt", - "scopeguard", - "softbuffer", - "strum", - "vtable", - "wasm-bindgen", - "web-sys", - "webbrowser", - "windows 0.62.2", - "winit", - "zbus 5.15.0", -] - -[[package]] -name = "i-slint-common" -version = "1.16.1" -dependencies = [ - "derive_more", - "fontique", - "htmlparser", - "pulldown-cmark", - "skrifa", -] - -[[package]] -name = "i-slint-compiler" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b7f9834faf2a62cc36f01decc7c683e66bb0861b53511723c3da9ad914d9bb" -dependencies = [ - "annotate-snippets", - "by_address", - "data-url", - "derive_more", - "i-slint-common", - "icu_normalizer", - "image", - "itertools 0.14.0", - "linked_hash_set", - "lyon_extra", - "lyon_path", - "num_enum", - "proc-macro2", - "quote", - "rayon", - "resvg", - "rowan", - "rspolib", - "skrifa", - "smol_str 0.3.6", - "strum", - "swash", - "typed-index-collections", - "unicode-segmentation", - "url", -] - -[[package]] -name = "i-slint-core" -version = "1.16.1" -dependencies = [ - "auto_enums", - "bitflags 2.13.0", - "cfg-if", - "chrono", - "clru", - "const-field-offset", - "derive_more", - "euclid", - "i-slint-common", - "i-slint-core-macros", - "icu_normalizer", - "image", - "lyon_algorithms", - "lyon_extra", - "lyon_geom", - "lyon_path", - "num-traits", - "once_cell", - "parley", - "pin-project", - "pin-weak", - "portable-atomic", - "raw-window-handle", - "resvg", - "rgb", - "scoped-tls-hkt", - "scopeguard", - "skrifa", - "slab", - "strum", - "swash", - "sys-locale", - "taffy", - "unicode-linebreak", - "unicode-script", - "unicode-segmentation", - "vtable", - "wasm-bindgen", - "web-sys", - "web-time", - "wgpu", -] - -[[package]] -name = "i-slint-core-macros" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7931356d5238281e6be994ec15241fb2d9e66359776d53b4bd6b2fdd9c257c6e" -dependencies = [ - "quote", - "serde_json", - "syn 2.0.117", -] - -[[package]] -name = "i-slint-renderer-femtovg" -version = "1.16.1" -dependencies = [ - "cfg-if", - "const-field-offset", - "derive_more", - "femtovg", - "glow 0.17.0", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "imgref", - "lyon_path", - "pin-weak", - "rgb", - "wasm-bindgen", - "web-sys", - "wgpu", -] - -[[package]] -name = "i-slint-renderer-skia" -version = "1.16.1" -dependencies = [ - "ash", - "bytemuck", - "cfg-if", - "cfg_aliases", - "clru", - "const-field-offset", - "derive_more", - "foreign-types 0.5.0", - "glow 0.17.0", - "glutin", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "lyon_path", - "metal 0.31.0", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", - "objc2-quartz-core 0.3.2", - "pin-weak", - "raw-window-handle", - "raw-window-metal", - "read-fonts", - "scoped-tls-hkt", - "skia-safe", - "softbuffer", - "spin_on", - "unicode-segmentation", - "vtable", - "wgpu", - "windows 0.62.2", - "windows-core 0.62.2", - "write-fonts", -] - -[[package]] -name = "i-slint-renderer-software" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8a21285f3a81a8e1b1e7c385eefe89bf9487f53fabfd5b238c68bc895d6013" -dependencies = [ - "bytemuck", - "clru", - "derive_more", - "euclid", - "i-slint-common", - "i-slint-core", - "integer-sqrt", - "lyon_path", - "num-traits", - "skrifa", - "swash", - "zeno", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3378,57 +2251,36 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", ] -[[package]] -name = "icu_locale" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_locale_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", - "serde", "tinystr", "writeable", "zerovec", ] -[[package]] -name = "icu_locale_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c5f1d16b4c3a2642d3a719f18f6b06070ab0aef246a6418130c955ae08aa831" - [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3440,16 +2292,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3460,20 +2313,18 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", - "serde", - "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -3481,40 +2332,13 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_segmenter" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a807a7488f3f758629ae86d99d9d30dce24da2fb2945d74c80a4f4a62c71db73" -dependencies = [ - "icu_collections", - "icu_locale", - "icu_provider", - "icu_segmenter_data", - "potential_utf", - "utf8_iter", - "zerovec", -] - -[[package]] -name = "icu_segmenter_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebbb7321d9e21d25f5660366cb6c08201d0175898a3a6f7a41ee9685af21c80" - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "id3" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d6d9f23a28c6feab60d7b4a339e004809019ec9d5fd964b3f4c655ff9bc141" +checksum = "24993fcabcbc07c8ac076a8e62db8593d1a5c4dbe81d57e531d2b7cb7f737380" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "byteorder", "flate2", ] @@ -3538,9 +2362,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -3556,6 +2380,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "image" version = "0.25.10" @@ -3564,17 +2398,10 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", - "color_quant", - "exr", - "gif", "image-webp", "moxcms", "num-traits", - "png 0.18.1", - "qoi", - "ravif", - "rayon", - "rgb", + "png", "tiff", "zune-core", "zune-jpeg", @@ -3590,28 +2417,14 @@ dependencies = [ "quick-error", ] -[[package]] -name = "imagesize" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" - -[[package]] -name = "imgref" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" - [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] @@ -3623,6 +2436,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3633,84 +2466,24 @@ dependencies = [ "generic-array", ] -[[package]] -name = "input" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbdc09524a91f9cacd26f16734ff63d7dc650daffadd2b6f84d17a285bd875a9" -dependencies = [ - "bitflags 2.13.0", - "input-sys", - "libc", - "log", - "udev", -] - -[[package]] -name = "input-sys" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" - [[package]] name = "instability" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "integer-sqrt" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770" -dependencies = [ - "num-traits", -] - -[[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 = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi 0.3.9", - "libc", - "windows-sys 0.48.0", + "syn 3.0.4", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is-docker" @@ -3757,9 +2530,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jack" @@ -3767,7 +2540,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7811b07bcac5dafabf814ab52c4b0ca9b7948aa1e279f572f03aa6544d47d27" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "jack-sys", "lazy_static", "libc", @@ -3783,17 +2556,19 @@ dependencies = [ "bitflags 1.3.2", "lazy_static", "libc", - "libloading 0.7.4", + "libloading", "log", "pkg-config", ] [[package]] name = "jiff" -version = "0.2.25" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6835eea34fb6321b9b3aa7b685c2b433948c09447e389dc017fdf687d5d11e65" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ + "defmt", + "jiff-core", "jiff-static", "log", "portable-atomic", @@ -3802,14 +2577,24 @@ dependencies = [ ] [[package]] -name = "jiff-static" -version = "0.2.25" +name = "jiff-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c22e04db9c58f5136eb1757f3d5c49a7b187f49e52185228cbd2f5acdfcc08c" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3821,7 +2606,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys 0.3.0", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -3840,7 +2625,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -3855,14 +2640,17 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] [[package]] name = "jni-sys" @@ -3880,26 +2668,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -3911,18 +2700,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.13.0", - "serde", - "unicode-segmentation", + "thiserror 2.0.20", ] [[package]] @@ -3931,35 +2709,42 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "byteorder", "dbus-secret-service", "log", "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", "zbus 4.4.0", "zeroize", ] [[package]] -name = "khronos-egl" -version = "6.0.0" +name = "kqueue" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ + "kqueue-sys", "libc", - "libloading 0.8.9", - "pkg-config", ] [[package]] -name = "khronos_api" -version = "3.1.0" +name = "kqueue-sys" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] [[package]] name = "ksni" -version = "0.3.4" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7ca513d0be42df5edb485af9f44a12b2cb85af773d91c27dc796d1c58b78edc" +checksum = "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b" dependencies = [ "async-executor", "async-io", @@ -3967,22 +2752,10 @@ dependencies = [ "futures-channel", "futures-lite", "futures-util", - "once_cell", - "pastey 0.2.3", + "pastey", "serde", - "zbus 5.15.0", -] - -[[package]] -name = "kurbo" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" -dependencies = [ - "arrayvec", - "euclid", - "polycool", - "smallvec", + "task-local", + "zbus 5.19.0", ] [[package]] @@ -3997,23 +2770,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libc" -version = "0.2.183" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -4024,16 +2785,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "libfuzzer-sys" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" -dependencies = [ - "arbitrary", - "cc", -] - [[package]] name = "libloading" version = "0.7.4" @@ -4044,16 +2795,6 @@ dependencies = [ "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" @@ -4062,65 +2803,40 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" dependencies = [ - "bitflags 2.13.0", "libc", - "plain", - "redox_syscall 0.7.3", ] [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", "vcpkg", ] -[[package]] -name = "libudev-sys" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" -dependencies = [ - "libc", - "pkg-config", -] - [[package]] name = "line-clipping" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] -name = "linebender_resource_handle" -version = "0.1.1" +name = "link-cplusplus" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - -[[package]] -name = "linked_hash_set" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" dependencies = [ - "linked-hash-map", + "cc", ] [[package]] @@ -4129,12 +2845,6 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4143,9 +2853,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -4164,9 +2874,9 @@ dependencies = [ [[package]] name = "lofty" -version = "0.23.3" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d0c107dba5af049cf1c36b646fc1ba0cd2705f40d766d2c4c64f1b797c5fbed" +checksum = "e0f052e8f93a76f4b2cd551c38a77d3025c7be1dc473f7acbac0ef1663c9f33b" dependencies = [ "byteorder", "data-encoding", @@ -4179,96 +2889,42 @@ dependencies = [ [[package]] name = "lofty_attr" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" +checksum = "fdbca1b60ba7cea959ffc84ac27f3dbdbacd6dfda168c7eabbc00e48654bb9fd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" -version = "0.18.1" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lyon_algorithms" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" -dependencies = [ - "lyon_path", - "num-traits", -] - -[[package]] -name = "lyon_extra" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72" -dependencies = [ - "lyon_path", - "thiserror 2.0.18", -] - -[[package]] -name = "lyon_geom" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" -dependencies = [ - "arrayvec", - "euclid", - "num-traits", -] - -[[package]] -name = "lyon_path" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" -dependencies = [ - "lyon_geom", - "num-traits", -] - [[package]] name = "mac-notification-sys" -version = "0.6.12" +version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" dependencies = [ "cc", + "log", "objc2 0.6.4", "objc2-foundation 0.3.2", "time", + "uuid 1.26.0", ] [[package]] @@ -4277,7 +2933,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix 0.29.0", + "nix", "winapi", ] @@ -4299,16 +2955,6 @@ dependencies = [ "libc", ] -[[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 = "md-5" version = "0.10.6" @@ -4321,31 +2967,24 @@ dependencies = [ [[package]] name = "mdns-sd" -version = "0.11.5" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e" +checksum = "ba97b4c886eea3c2823388120d92063c011ac866919ffc4200a0e4b1642a54a5" dependencies = [ + "fastrand", "flume", - "if-addrs", + "if-addrs 0.15.0", "log", - "polling 2.8.0", - "socket2 0.5.10", + "mio", + "socket-pktinfo", + "socket2", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" 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", -] +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmem" @@ -4362,36 +3001,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "metal" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" -dependencies = [ - "bitflags 2.13.0", - "block", - "core-graphics-types 0.1.3", - "foreign-types 0.5.0", - "log", - "objc", - "paste", -] - -[[package]] -name = "metal" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" -dependencies = [ - "bitflags 2.13.0", - "block", - "core-graphics-types 0.2.0", - "foreign-types 0.5.0", - "log", - "objc", - "paste", -] - [[package]] name = "microfft" version = "0.6.0" @@ -4420,10 +3029,20 @@ dependencies = [ ] [[package]] -name = "mio" -version = "1.1.1" +name = "miniz_oxide" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -4443,80 +3062,28 @@ dependencies = [ [[package]] name = "mpris-server" -version = "0.8.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "058bc2227727af394f34aa51da3e36aeecf2c808f39315d35f754872660750ae" +checksum = "70cf358e9a9516cc35ed40eebd56cbe80f5190a7a2b3cd0c2d358c86b879132b" dependencies = [ "async-channel", "futures-channel", "serde", "trait-variant", - "zbus 4.4.0", + "zbus 5.19.0", ] -[[package]] -name = "muda" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b20e2339ef964849496bd6dab1bcd8ceb7a3a5a268dc64c8b84c833e75d66dab" -dependencies = [ - "crossbeam-channel", - "dpi", - "keyboard-types", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "png 0.17.16", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "naga" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135" -dependencies = [ - "arrayvec", - "bit-set 0.8.0", - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.16.1", - "hexf-parse", - "indexmap", - "libm", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "spirv", - "thiserror 2.0.18", - "unicode-ident", -] - -[[package]] -name = "natord" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" - [[package]] name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.0", - "jni-sys 0.3.0", + "bitflags 2.13.1", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", - "raw-window-handle", "thiserror 1.0.69", ] @@ -4532,49 +3099,22 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys 0.3.0", + "jni-sys 0.3.1", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", "memoffset", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "no_std_io2" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" -dependencies = [ - "memchr", -] - [[package]] name = "nom" version = "7.1.3" @@ -4595,23 +3135,44 @@ dependencies = [ ] [[package]] -name = "noop_proc_macro" -version = "0.3.0" +name = "notify" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] [[package]] name = "notify-rust" -version = "4.16.0" +version = "4.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" dependencies = [ "futures-lite", "log", "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus 5.15.0", + "zbus 5.19.0", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", ] [[package]] @@ -4630,9 +3191,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -4649,9 +3210,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -4661,25 +3222,24 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -4707,9 +3267,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -4717,14 +3277,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4776,14 +3336,14 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", - "objc2-core-data 0.2.2", - "objc2-core-image 0.2.2", + "objc2-core-data", + "objc2-core-image", "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", + "objc2-quartz-core", ] [[package]] @@ -4792,19 +3352,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", - "libc", "objc2 0.6.4", - "objc2-cloud-kit 0.3.2", - "objc2-core-data 0.3.2", - "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image 0.3.2", - "objc2-core-text", - "objc2-core-video", "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", ] [[package]] @@ -4813,7 +3365,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", "objc2 0.6.4", "objc2-core-audio", @@ -4832,41 +3384,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-cloud-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-contacts" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - [[package]] name = "objc2-core-audio" version = "0.3.2" @@ -4888,7 +3405,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2 0.6.4", ] @@ -4898,30 +3415,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-core-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "dispatch2", "libc", @@ -4934,7 +3440,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -4950,54 +3456,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-location" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-contacts", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", + "objc2-metal", ] [[package]] @@ -5012,9 +3471,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", - "dispatch", "libc", "objc2 0.5.2", ] @@ -5025,7 +3483,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -5038,159 +3496,50 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] -[[package]] -name = "objc2-link-presentation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", -] - [[package]] name = "objc2-metal" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-quartz-core" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", -] - -[[package]] -name = "objc2-symbols" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" -dependencies = [ - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-cloud-kit 0.2.2", - "objc2-core-data 0.2.2", - "objc2-core-image 0.2.2", - "objc2-core-location", - "objc2-foundation 0.2.2", - "objc2-link-presentation", - "objc2-quartz-core 0.2.2", - "objc2-symbols", - "objc2-uniform-type-identifiers", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.13.0", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", -] - -[[package]] -name = "objc2-uniform-type-identifiers" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", + "objc2-metal", ] [[package]] name = "ogg_pager" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" dependencies = [ "byteorder", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -dependencies = [ - "critical-section", - "portable-atomic", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -5206,13 +3555,12 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.5" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" dependencies = [ "is-wsl", "libc", - "pathdiff", ] [[package]] @@ -5227,16 +3575,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "orbclient" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" -dependencies = [ - "libc", - "libredox", -] - [[package]] name = "ordered-float" version = "4.6.0" @@ -5246,15 +3584,6 @@ dependencies = [ "num-traits", ] -[[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-stream" version = "0.2.0" @@ -5266,36 +3595,46 @@ dependencies = [ ] [[package]] -name = "owned_ttf_parser" -version = "0.25.1" +name = "os_pipe" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ - "ttf-parser", + "libc", + "windows-sys 0.61.2", ] [[package]] name = "palette" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ "approx", - "fast-srgb8", "libm", "palette_derive", + "palette_math", ] [[package]] name = "palette_derive" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", ] [[package]] @@ -5322,68 +3661,23 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] -[[package]] -name = "parlance" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" - -[[package]] -name = "parley" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c00ec192e1a402861526eff91a4b4690a2e5a17a4ae2deb772d72b49daf868" -dependencies = [ - "fontique", - "harfrust", - "hashbrown 0.16.1", - "icu_normalizer", - "icu_properties", - "icu_segmenter", - "linebender_resource_handle", - "parlance", - "parley_data", - "skrifa", -] - -[[package]] -name = "parley_data" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "232313eddc02ac27f015e8f8eeb7facce8a896116df7e3eda1bfd9f600a9a39d" -dependencies = [ - "icu_properties", -] - [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pastey" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -5392,9 +3686,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -5402,9 +3696,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -5412,26 +3706,37 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -5459,7 +3764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -5472,7 +3777,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5484,50 +3789,12 @@ dependencies = [ "siphasher", ] -[[package]] -name = "pico-args" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pin-weak" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b330c9d1b92dfe68442ca20b009c717d5f0b1e3cf4965e62f704c3c6e95a1305" - [[package]] name = "piper" version = "0.2.5" @@ -5541,27 +3808,21 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] -name = "plain" -version = "0.2.3" +name = "plist" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", ] [[package]] @@ -5570,27 +3831,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "concurrent-queue", - "libc", - "log", - "pin-project-lite", - "windows-sys 0.48.0", + "miniz_oxide 0.8.9", ] [[package]] @@ -5601,7 +3846,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi 0.5.2", + "hermit-abi", "pin-project-lite", "rustix 1.1.4", "windows-sys 0.61.2", @@ -5613,15 +3858,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" -[[package]] -name = "polycool" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" -dependencies = [ - "arrayvec", -] - [[package]] name = "polyval" version = "0.6.2" @@ -5636,12 +3872,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -dependencies = [ - "critical-section", -] +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -5654,12 +3887,10 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ - "serde_core", - "writeable", "zerovec", ] @@ -5678,22 +3909,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "presser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "primal-check" version = "0.3.4" @@ -5714,37 +3929,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "prost" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -5752,15 +3948,15 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5830,25 +4026,6 @@ dependencies = [ "psl-types", ] -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags 2.13.0", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - [[package]] name = "pure-rust-locales" version = "0.8.2" @@ -5857,79 +4034,30 @@ checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d" [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] -name = "qbz" -version = "2.0.2" +name = "qbz-account-migration" +version = "0.1.0" dependencies = [ - "arboard", - "async-trait", "chrono", - "dirs 5.0.1", - "env_logger", - "futures-util", - "i-slint-backend-winit", - "i-slint-renderer-femtovg", - "image", - "ksni", "log", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "open", - "qbz-app", - "qbz-audio", - "qbz-cache", - "qbz-cast", - "qbz-core", - "qbz-credentials", - "qbz-dac-wizard", - "qbz-dsd", - "qbz-external-reco", - "qbz-i18n", - "qbz-integrations", - "qbz-library", - "qbz-log", - "qbz-lyrics", - "qbz-media-controls", - "qbz-mixtape", "qbz-models", - "qbz-music-link", - "qbz-offline-cache", - "qbz-player", - "qbz-playlist-import", - "qbz-plex", "qbz-qobuz", - "qbz-reco", - "qbz-slint-common", - "qbz-text-utils", - "qbz-theme", - "qbz-ui", - "qconnect-app", - "qconnect-transport-ws", - "rand 0.10.1", - "raw-window-handle", - "reqwest", - "rfd", "rusqlite", "serde", "serde_json", - "slint", - "swash", + "tempfile", "tokio", - "urlencoding", - "uuid 1.22.0", - "zbus 5.15.0", ] [[package]] name = "qbz-app" -version = "2.0.2" +version = "2.1.0" dependencies = [ - "base64", + "base64 0.23.1", "chrono", "dirs 6.0.0", "log", @@ -5938,20 +4066,23 @@ dependencies = [ "qbz-core", "qbz-credentials", "qbz-models", + "qbz-offline-cache", "qbz-player", "qbz-qobuz", - "rand 0.10.1", + "rand 0.10.2", "reqwest", "rusqlite", + "rustls", "serde", "serde_json", + "tempfile", "tokio", - "uuid 1.22.0", + "uuid 1.26.0", ] [[package]] name = "qbz-audio" -version = "2.0.2" +version = "2.1.0" dependencies = [ "alsa", "alsa-sys", @@ -5960,6 +4091,7 @@ dependencies = [ "dirs 6.0.0", "ebur128", "jack", + "libc", "log", "objc2-core-audio", "qbz-models", @@ -5971,8 +4103,10 @@ dependencies = [ "serde_json", "spectrum-analyzer", "symphonia", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", + "wasapi", + "windows 0.62.2", "zbus 4.4.0", ] @@ -5993,37 +4127,36 @@ name = "qbz-cast" version = "0.1.0" dependencies = [ "futures-util", - "getrandom 0.4.2", + "getrandom 0.4.3", "log", "mdns-sd", "rupnp", "rust_cast", - "rustls", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tiny_http", "tokio", ] [[package]] name = "qbz-cmaf" -version = "2.0.2" +version = "2.1.0" dependencies = [ "aes", - "base64", + "base64 0.23.1", "cbc", "ctr", "hkdf", "log", "md-5", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "qbz-core" -version = "2.0.2" +version = "2.1.0" dependencies = [ "anyhow", "async-trait", @@ -6038,21 +4171,22 @@ dependencies = [ "qbz-reco", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", + "windows-sys 0.61.2", ] [[package]] name = "qbz-credentials" -version = "2.0.2" +version = "2.1.0" dependencies = [ "aes-gcm", - "ashpd 0.13.11", - "base64", + "ashpd", + "base64 0.23.1", "dirs 6.0.0", "keyring", "log", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -6061,28 +4195,48 @@ dependencies = [ ] [[package]] -name = "qbz-dac-wizard" -version = "2.0.2" +name = "qbz-dac-wizard-core" +version = "2.1.0" dependencies = [ "qbz-audio", "qbz-i18n", "qbz-models", - "qbz-ui", - "slint", +] + +[[package]] +name = "qbz-disc" +version = "2.1.0" +dependencies = [ + "base64 0.23.1", + "dirs 6.0.0", + "encoding_rs", + "libc", + "log", + "plist", + "reqwest", + "rusqlite", + "rustls", + "serde", + "serde_json", + "sha1 0.10.7", + "thiserror 2.0.20", + "tokio", ] [[package]] name = "qbz-dsd" -version = "2.0.2" +version = "2.1.0" dependencies = [ + "dst-decoder", "id3", "log", - "thiserror 2.0.18", + "qbz-disc", + "thiserror 2.0.20", ] [[package]] name = "qbz-external-reco" -version = "2.0.2" +version = "2.1.0" dependencies = [ "async-trait", "futures-util", @@ -6115,6 +4269,17 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "qbz-jellyfin" +version = "2.1.0" +dependencies = [ + "reqwest", + "rustls", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "qbz-library" version = "0.1.0" @@ -6124,19 +4289,35 @@ dependencies = [ "image", "lofty", "log", + "notify", + "qbz-disc", "qbz-dsd", "rusqlite", "serde", "serde_json", "tempfile", - "thiserror 2.0.18", - "uuid 1.22.0", + "thiserror 2.0.20", + "uuid 1.26.0", "walkdir", ] +[[package]] +name = "qbz-local-catalog" +version = "0.1.0" +dependencies = [ + "log", + "rusqlite", + "rustix 1.1.4", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + [[package]] name = "qbz-log" -version = "2.0.2" +version = "2.1.0" dependencies = [ "chrono", "dirs 5.0.1", @@ -6164,10 +4345,19 @@ dependencies = [ ] [[package]] -name = "qbz-media-controls" -version = "2.0.2" +name = "qbz-media-cache" +version = "2.1.0" dependencies = [ - "ashpd 0.13.11", + "rusqlite", + "serde_json", + "tempfile", +] + +[[package]] +name = "qbz-media-controls" +version = "2.1.0" +dependencies = [ + "ashpd", "async-channel", "dirs 6.0.0", "image", @@ -6175,10 +4365,12 @@ dependencies = [ "md-5", "mpris-server", "notify-rust", + "qbz-models", "reqwest", "souvlaki", "tokio", "urlencoding", + "windows-sys 0.61.2", ] [[package]] @@ -6187,35 +4379,33 @@ version = "0.1.0" dependencies = [ "async-trait", "log", - "qbz-library", "qbz-models", - "qbz-plex", "qbz-qobuz", - "rand 0.10.1", + "rand 0.10.2", "rusqlite", "serde", "strsim", "tokio", - "uuid 1.22.0", + "uuid 1.26.0", ] [[package]] name = "qbz-models" -version = "2.0.2" +version = "2.1.0" dependencies = [ "async-trait", "chrono", "log", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "urlencoding", ] [[package]] name = "qbz-music-link" -version = "2.0.2" +version = "2.1.0" dependencies = [ "async-trait", "log", @@ -6224,13 +4414,13 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", ] [[package]] name = "qbz-offline-cache" -version = "2.0.2" +version = "2.1.0" dependencies = [ "dirs 6.0.0", "futures-util", @@ -6243,6 +4433,7 @@ dependencies = [ "qbz-secrets", "reqwest", "rusqlite", + "rustls", "serde", "serde_json", "tempfile", @@ -6251,7 +4442,7 @@ dependencies = [ [[package]] name = "qbz-player" -version = "2.0.2" +version = "2.1.0" dependencies = [ "async-trait", "futures-util", @@ -6262,12 +4453,12 @@ dependencies = [ "qbz-dsd", "qbz-models", "qbz-qobuz", - "rand 0.9.3", + "rand 0.10.2", "reqwest", "rodio", "serde", "symphonia", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", ] @@ -6276,34 +4467,37 @@ dependencies = [ name = "qbz-playlist-import" version = "0.1.0" dependencies = [ + "encoding_rs", "futures-util", "log", "qbz-models", "qbz-qobuz", "reqwest", + "roxmltree 0.21.1", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", ] [[package]] name = "qbz-plex" -version = "2.0.2" +version = "2.1.0" dependencies = [ "dirs 6.0.0", "open", "reqwest", "rusqlite", "serde", + "serde_json", ] [[package]] name = "qbz-qobuz" -version = "2.0.2" +version = "2.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.23.1", "chrono", "dirs 6.0.0", "futures-util", @@ -6314,15 +4508,94 @@ dependencies = [ "qbz-models", "regex", "reqwest", + "rustls", "serde", "serde_json", - "thiserror 2.0.18", + "tempfile", + "thiserror 2.0.20", "tokio", + "zeroize", +] + +[[package]] +name = "qbz-qt" +version = "2.1.0" +dependencies = [ + "arboard", + "async-io", + "async-trait", + "cc", + "cxx", + "cxx-qt", + "cxx-qt-build", + "cxx-qt-lib", + "dirs 5.0.1", + "futures-util", + "image", + "ksni", + "libc", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "open", + "qbz-account-migration", + "qbz-app", + "qbz-audio", + "qbz-cache", + "qbz-cast", + "qbz-core", + "qbz-credentials", + "qbz-dac-wizard-core", + "qbz-disc", + "qbz-external-reco", + "qbz-i18n", + "qbz-integrations", + "qbz-jellyfin", + "qbz-library", + "qbz-local-catalog", + "qbz-log", + "qbz-lyrics", + "qbz-media-cache", + "qbz-media-controls", + "qbz-mixtape", + "qbz-models", + "qbz-music-link", + "qbz-offline-cache", + "qbz-player", + "qbz-playlist-import", + "qbz-plex", + "qbz-qobuz", + "qbz-rip", + "qbz-source", + "qbz-subsonic", + "qbz-text-utils", + "qbz-theme", + "qconnect-app", + "qconnect-lan", + "qconnect-protocol", + "qconnect-transport-ws", + "qt-build-utils", + "rand 0.10.2", + "reqwest", + "rfd", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "tokio", + "url", + "urlencoding", + "uuid 1.26.0", + "windows-sys 0.61.2", + "winresource", + "zbus 5.19.0", + "zeroize", ] [[package]] name = "qbz-radio" -version = "2.0.2" +version = "2.1.0" dependencies = [ "dirs 5.0.1", "log", @@ -6333,46 +4606,83 @@ dependencies = [ [[package]] name = "qbz-reco" -version = "2.0.2" +version = "2.1.0" dependencies = [ "log", "qbz-integrations", "qbz-models", "qbz-qobuz", - "rand 0.10.1", "rusqlite", "serde", "tokio", ] [[package]] -name = "qbz-secrets" -version = "2.0.2" +name = "qbz-rip" +version = "2.1.0" dependencies = [ - "aes-gcm", - "base64", - "hkdf", - "keyring", + "flacenc", + "lofty", "log", - "rand 0.9.3", - "serde", - "sha2 0.10.9", - "tempfile", - "thiserror 2.0.18", - "uuid 1.22.0", + "qbz-disc", + "thiserror 2.0.20", ] [[package]] -name = "qbz-slint-common" -version = "2.0.2" +name = "qbz-secrets" +version = "2.1.0" dependencies = [ - "qbz-ui", - "slint", + "aes-gcm", + "base64 0.23.1", + "hkdf", + "keyring", + "log", + "rand 0.10.2", + "serde", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.20", + "uuid 1.26.0", +] + +[[package]] +name = "qbz-source" +version = "2.1.0" +dependencies = [ + "async-trait", + "log", + "qbz-disc", + "qbz-jellyfin", + "qbz-library", + "qbz-media-cache", + "qbz-mixtape", + "qbz-models", + "qbz-plex", + "qbz-qobuz", + "qbz-subsonic", + "reqwest", + "rusqlite", + "rustls", + "tempfile", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "qbz-subsonic" +version = "2.1.0" +dependencies = [ + "md-5", + "reqwest", + "rustls", + "serde", + "serde_json", + "tokio", ] [[package]] name = "qbz-text-utils" -version = "2.0.2" +version = "2.1.0" dependencies = [ "chrono", "qbz-i18n", @@ -6380,24 +4690,16 @@ dependencies = [ [[package]] name = "qbz-theme" -version = "2.0.2" +version = "2.1.0" dependencies = [ "image", "serde", "serde_json", ] -[[package]] -name = "qbz-ui" -version = "2.0.2" -dependencies = [ - "slint", - "slint-build", -] - [[package]] name = "qbzd" -version = "2.0.2" +version = "2.1.0" dependencies = [ "async-trait", "clap", @@ -6420,9 +4722,10 @@ dependencies = [ "qbz-reco", "qconnect-app", "qconnect-core", + "qconnect-lan", "qconnect-protocol", "qconnect-transport-ws", - "rand 0.10.1", + "rand 0.10.2", "ratatui", "reqwest", "rusqlite", @@ -6431,55 +4734,84 @@ dependencies = [ "tempfile", "tiny_http", "tokio", - "toml", + "toml 0.9.12+spec-1.1.0", "urlencoding", - "uuid 1.22.0", + "uuid 1.26.0", + "windows-sys 0.61.2", + "zeroize", ] [[package]] name = "qconnect-app" -version = "2.0.2" +version = "2.1.0" dependencies = [ "async-trait", + "futures-util", "log", "prost", "qbz-models", "qbz-player", "qconnect-core", + "qconnect-lan", "qconnect-protocol", "qconnect-transport-ws", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", - "uuid 1.22.0", + "uuid 1.26.0", + "zeroize", ] [[package]] name = "qconnect-core" -version = "2.0.2" +version = "2.1.0" dependencies = [ "qbz-models", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", +] + +[[package]] +name = "qconnect-lan" +version = "2.1.0" +dependencies = [ + "base64 0.23.1", + "if-addrs 0.15.0", + "mdns-sd", + "qbz-credentials", + "qbz-qobuz", + "qconnect-core", + "qconnect-protocol", + "qconnect-transport-ws", + "reqwest", + "rustls", + "serde", + "serde_json", + "socket2", + "thiserror 2.0.20", + "tokio", + "url", + "uuid 1.26.0", + "zeroize", ] [[package]] name = "qconnect-protocol" -version = "2.0.2" +version = "2.1.0" dependencies = [ "log", "prost", "qconnect-core", "serde", "serde_json", - "thiserror 2.0.18", - "uuid 1.22.0", + "thiserror 2.0.20", + "uuid 1.26.0", ] [[package]] name = "qconnect-transport-ws" -version = "2.0.2" +version = "2.1.0" dependencies = [ "async-trait", "futures-util", @@ -6487,18 +4819,22 @@ dependencies = [ "prost", "qconnect-protocol", "serde", - "thiserror 2.0.18", + "serde_json", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", + "zeroize", ] [[package]] -name = "qoi" -version = "0.4.1" +name = "qt-build-utils" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +checksum = "63c7e24653d0b3084180066306b26e532b152470b13a050c2d2960284d4b9f53" dependencies = [ - "bytemuck", + "cc", + "thiserror 1.0.69", + "versions", ] [[package]] @@ -6509,83 +4845,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", - "serde", -] - -[[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.1", - "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", - "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 = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.3", - "ring", - "rustc-hash 2.1.1", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "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 0.6.3", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -6604,34 +4875,24 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.3" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.0", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -6644,16 +4905,6 @@ dependencies = [ "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.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -6665,18 +4916,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" @@ -6685,15 +4927,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.10.1", + "rand 0.10.2", ] -[[package]] -name = "range-alloc" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" - [[package]] name = "ratatui" version = "0.30.2" @@ -6716,7 +4952,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "compact_str", "critical-section", "hashbrown 0.17.1", @@ -6726,10 +4962,10 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -6781,7 +5017,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "hashbrown 0.17.1", "indoc", "instability", @@ -6792,57 +5028,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.3", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", + "unicode-width 0.2.2", ] [[package]] @@ -6851,74 +5037,13 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "raw-window-metal" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" -dependencies = [ - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "read-fonts" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" -dependencies = [ - "bytemuck", - "core_maths", - "font-types", -] - -[[package]] -name = "redox_syscall" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -6940,14 +5065,14 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -6957,9 +5082,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6968,23 +5093,17 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cookie", "cookie_store", @@ -7001,9 +5120,9 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -7019,57 +5138,30 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.6", -] - -[[package]] -name = "resvg" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" -dependencies = [ - "gif", - "image-webp", - "log", - "pico-args", - "rgb", - "svgtypes", - "tiny-skia 0.12.0", - "usvg", - "zune-jpeg", ] [[package]] name = "rfd" -version = "0.15.4" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "ashpd 0.11.1", "block2 0.6.2", "dispatch2", "js-sys", + "libc", "log", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", + "percent-encoding", "pollster", "raw-window-handle", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", + "windows-sys 0.61.2", ] [[package]] @@ -7106,23 +5198,11 @@ dependencies = [ "cpal", "dasp_sample", "num-rational", - "rand 0.10.1", + "rand 0.10.2", "rand_distr", "rtrb", "symphonia", - "thiserror 2.0.18", -] - -[[package]] -name = "rowan" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" -dependencies = [ - "countme", - "hashbrown 0.14.5", - "rustc-hash 1.1.0", - "text-size", + "thiserror 2.0.20", ] [[package]] @@ -7141,23 +5221,20 @@ dependencies = [ ] [[package]] -name = "rspolib" -version = "0.1.2" +name = "rsqlite-vfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ - "lazy_static", - "natord", - "snafu", - "unicode-linebreak", - "unicode-width", + "hashbrown 0.16.1", + "thiserror 2.0.20", ] [[package]] name = "rtrb" -version = "0.3.3" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7204ed6420f698836b76d4d5c2ec5dec7585fd5c3a788fd1cde855d1de598239" +checksum = "fae8ee26b0371a29a77d2b2d6b3ae13aa81def6f9bf1b1b92a32d279a5e709b7" [[package]] name = "rupnp" @@ -7173,7 +5250,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "if-addrs", + "if-addrs 0.13.4", "roxmltree 0.20.0", "ssdp-client", "tokio", @@ -7181,23 +5258,22 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.31.0" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] name = "rust_cast" version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235d09067a35143d271fdeaf28298b4f729b5da50abdbf3b9e6395262d8bc7a9" dependencies = [ "byteorder", "log", @@ -7207,21 +5283,9 @@ dependencies = [ "rustls-native-certs", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -7251,7 +5315,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7264,7 +5328,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -7273,14 +5337,13 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -7289,31 +5352,57 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ - "web-time", "zeroize", ] [[package]] -name = "rustls-webpki" -version = "0.103.13" +name = "rustls-platform-verifier" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "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.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -7323,27 +5412,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.13.0", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -7369,18 +5440,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scoped-tls-hkt" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9603871ffe5df3ac39cb624790c296dbd47a400d202f56bf3e414045099524d" - [[package]] name = "scopeguard" version = "1.2.0" @@ -7388,17 +5447,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "sctk-adwaita" -version = "0.10.1" +name = "scratch" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" -dependencies = [ - "ab_glyph", - "log", - "memmap2", - "smithay-client-toolkit 0.19.2", - "tiny-skia 0.11.4", -] +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" [[package]] name = "secret-service" @@ -7413,19 +5465,32 @@ dependencies = [ "hkdf", "num", "once_cell", - "rand 0.8.6", + "rand 0.8.8", "serde", "sha2 0.10.9", "zbus 4.4.0", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7444,15 +5509,21 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -7460,29 +5531,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -7493,13 +5564,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -7525,15 +5596,26 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -7552,15 +5634,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -7595,312 +5677,63 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", ] -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - [[package]] name = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "simplecss" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" -dependencies = [ - "log", -] - [[package]] name = "siphasher" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" -[[package]] -name = "skia-bindings" -version = "0.90.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f6f96e00735f14a781aac8a6870c862b8cc831df6d8e4ad77ab78e11411b9af" -dependencies = [ - "bindgen", - "cc", - "flate2", - "heck", - "pkg-config", - "regex", - "serde_json", - "tar", - "toml", -] - -[[package]] -name = "skia-safe" -version = "0.90.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a71c01d325d40b1031dee67d251a5e0132e79e2a9ec272149a4f4a0d4b8b3be" -dependencies = [ - "bitflags 2.13.0", - "skia-bindings", - "windows 0.62.2", -] - -[[package]] -name = "skrifa" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" -dependencies = [ - "bytemuck", - "read-fonts", -] - [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "slint" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d46bc502cc6b113bfd1a7e9a0876532a9454ff4ee905afe09784eaa2ec3f3f33" -dependencies = [ - "const-field-offset", - "i-slint-backend-android-activity", - "i-slint-backend-selector", - "i-slint-backend-winit", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "i-slint-renderer-femtovg", - "i-slint-renderer-software", - "num-traits", - "once_cell", - "pin-weak", - "raw-window-handle", - "slint-macros", - "unicode-segmentation", - "vtable", - "wgpu", -] - -[[package]] -name = "slint-build" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a73854b9adf81fa1600b46df488ffc3796400e8e9b40d504a94a36b55c8f3d0a" -dependencies = [ - "derive_more", - "fontique", - "i-slint-compiler", - "spin_on", - "toml_edit", -] - -[[package]] -name = "slint-macros" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d5884e06072c329da7096ad1367eb83b98c78e5313f7ac90d66f90d4acc62ee" -dependencies = [ - "i-slint-compiler", - "proc-macro2", - "quote", - "spin_on", -] - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] -name = "smithay-client-toolkit" -version = "0.19.2" +name = "socket-pktinfo" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" -dependencies = [ - "bitflags 2.13.0", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 0.38.44", - "thiserror 1.0.69", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.0", - "calloop 0.14.4", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.4", - "thiserror 2.0.18", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - -[[package]] -name = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -dependencies = [ - "serde", -] - -[[package]] -name = "smol_str" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" -dependencies = [ - "borsh", - "serde_core", -] - -[[package]] -name = "snafu" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" -dependencies = [ - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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 = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" dependencies = [ "libc", + "socket2", "windows-sys 0.61.2", ] [[package]] -name = "softbuffer" -version = "0.4.8" +name = "socket2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "as-raw-xcb-connection", - "bytemuck", - "drm", - "fastrand", - "js-sys", - "memmap2", - "ndk", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "raw-window-handle", - "redox_syscall 0.5.18", - "rustix 1.1.4", - "tiny-xlib", - "tracing", - "wasm-bindgen", - "wayland-backend", - "wayland-client", - "wayland-sys", - "web-sys", + "libc", "windows-sys 0.61.2", - "x11rb", ] [[package]] @@ -7909,10 +5742,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" dependencies = [ - "base64", + "base64 0.22.1", "block", "cocoa", - "core-graphics 0.22.3", + "core-graphics", "dbus", "dbus-crossroads", "dispatch", @@ -7923,11 +5756,11 @@ dependencies = [ [[package]] name = "spectrum-analyzer" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5353405212be77e7f9e95aa77934f0aafb0f80c586571680b9c20ce5bae758" +checksum = "ccb8018aad67b7c6d698a3bbc196a47bd28ab653d9a99017bb865c1cd408082b" dependencies = [ - "float-cmp 0.10.0", + "float-cmp", "libm", "microfft", "paste", @@ -7935,29 +5768,23 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] [[package]] -name = "spin_on" -version = "0.1.1" +name = "sqlite-wasm-rs" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076e103ed41b9864aa838287efe5f4e3a7a0362dd00671ae62a212e5e4612da2" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ - "pin-utils", -] - -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.13.0", + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", ] [[package]] @@ -7990,15 +5817,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -dependencies = [ - "float-cmp 0.9.0", -] - [[package]] name = "strsim" version = "0.11.1" @@ -8023,7 +5841,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8032,27 +5850,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "svgtypes" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" -dependencies = [ - "kurbo", - "siphasher", -] - -[[package]] -name = "swash" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" -dependencies = [ - "skrifa", - "yazi", - "zeno", -] - [[package]] name = "symphonia" version = "0.5.5" @@ -8261,9 +6058,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -8287,52 +6095,25 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] -name = "sys-locale" -version = "0.3.2" +name = "task-local" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626" dependencies = [ - "js-sys", - "libc", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "taffy" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" -dependencies = [ - "arrayvec", - "grid", - "serde", - "slotmap", -] - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", + "pin-project-lite", ] [[package]] name = "tauri-winrt-notification" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "quick-xml 0.37.5", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-version", ] @@ -8344,7 +6125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -8365,7 +6146,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "parking_lot", "rustix 1.1.4", "signal-hook", @@ -8400,21 +6181,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", - "bitflags 2.13.0", + "base64 0.22.1", + "bitflags 2.13.1", "fancy-regex", "filedescriptor", "finl_unicode", - "fixedbitset", + "fixedbitset 0.4.2", "hex", "lazy_static", "libc", "log", "memmem", - "nix 0.29.0", + "nix", "num-derive", "num-traits", - "ordered-float 4.6.0", + "ordered-float", "pest", "pest_derive", "phf", @@ -8435,12 +6216,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "text-size" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" - [[package]] name = "thiserror" version = "1.0.69" @@ -8452,11 +6227,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -8467,18 +6242,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -8497,12 +6272,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -8514,84 +6288,20 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", ] -[[package]] -name = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "tiny-skia-path 0.11.4", -] - -[[package]] -name = "tiny-skia" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png 0.18.1", - "tiny-skia-path 0.12.0", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] -name = "tiny-skia-path" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] -name = "tiny-xlib" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" -dependencies = [ - "as-raw-xcb-connection", - "ctor", - "libloading 0.8.9", - "pkg-config", - "tracing", -] - [[package]] name = "tiny_http" version = "0.12.0" @@ -8606,35 +6316,19 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", - "serde_core", "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -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 = "tokio" -version = "1.50.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -8642,7 +6336,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -8650,13 +6344,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -8671,9 +6365,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -8683,9 +6377,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -8699,9 +6393,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -8725,6 +6419,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -8736,40 +6445,39 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "toml_writer", - "winnow 0.7.15", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 0.7.15", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -8788,20 +6496,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -8822,7 +6530,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -8836,7 +6543,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8850,13 +6557,13 @@ dependencies = [ [[package]] name = "trait-variant" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8869,56 +6576,46 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] - [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ - "byteorder", "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.8.6", + "rand 0.10.2", "rustls", "rustls-pki-types", - "sha1", - "thiserror 1.0.69", - "utf-8", -] - -[[package]] -name = "typed-index-collections" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" -dependencies = [ - "bincode", - "serde", + "sha1 0.11.0", + "thiserror 2.0.20", ] [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -8926,18 +6623,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "udev" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" -dependencies = [ - "io-lifetimes", - "libc", - "libudev-sys", - "pkg-config", -] - [[package]] name = "uds_windows" version = "1.2.1" @@ -8949,59 +6634,17 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "unicode-script" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" - [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -9011,14 +6654,14 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools 0.14.0", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] -name = "unicode-vo" -version = "0.1.0" +name = "unicode-width" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" @@ -9026,12 +6669,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[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" @@ -9048,12 +6685,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "url" version = "2.5.8" @@ -9064,7 +6695,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -9073,40 +6703,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "usvg" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" -dependencies = [ - "base64", - "data-url", - "flate2", - "fontdb", - "imagesize", - "kurbo", - "log", - "pico-args", - "roxmltree 0.21.1", - "rustybuzz", - "simplecss", - "siphasher", - "strict-num", - "svgtypes", - "tiny-skia-path 0.12.0", - "ttf-parser", - "unicode-bidi", - "unicode-script", - "unicode-vo", - "xmlwriter", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -9130,28 +6726,17 @@ dependencies = [ [[package]] name = "uuid" -version = "1.22.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "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 = "vcpkg" version = "0.2.15" @@ -9165,26 +6750,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "vtable" -version = "0.4.0" +name = "versions" +version = "6.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea953f65593feb5287b022b83c35046d1ff49c7417b6f5851a079d8a231fe31" +checksum = "f25d498b63d1fdb376b4250f39ab3a5ee8d103957346abacd911e2d8b612c139" dependencies = [ - "const-field-offset", - "portable-atomic", - "stable_deref_trait", - "vtable-macro", -] - -[[package]] -name = "vtable-macro" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "482a37f9777480673d3a99c80ac2edc69f92382230440946855fa27ebc47cba6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "itertools 0.13.0", + "nom 7.1.3", ] [[package]] @@ -9215,6 +6787,19 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasapi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee144c215d8d31e94c1a599fbaf540ca82a7017e4f1786816b179f334fac751d" +dependencies = [ + "log", + "num-integer", + "thiserror 2.0.20", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -9223,27 +6808,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[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" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -9254,23 +6830,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9278,53 +6850,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" 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", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -9333,124 +6883,50 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", "rustix 1.1.4", - "scoped-tls", "smallvec", "wayland-sys", ] [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] -[[package]] -name = "wayland-csd-frame" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" -dependencies = [ - "bitflags 2.13.0", - "cursor-icon", - "wayland-backend", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" -dependencies = [ - "rustix 1.1.4", - "wayland-client", - "xcursor", -] - [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", ] -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-plasma" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - [[package]] name = "wayland-protocols-wlr" version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -9459,12 +6935,12 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.39.4", + "quick-xml", "quote", ] @@ -9474,46 +6950,26 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ - "dlib", - "log", - "once_cell", "pkg-config", ] [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "webpki-root-certs" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webbrowser" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" -dependencies = [ - "core-foundation 0.10.1", - "jni 0.22.4", - "log", - "ndk-context", - "objc2 0.6.4", - "objc2-foundation 0.3.2", - "url", - "web-sys", + "rustls-pki-types", ] [[package]] @@ -9522,14 +6978,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.6", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -9560,7 +7016,7 @@ dependencies = [ "mac_address", "sha2 0.10.9", "thiserror 1.0.69", - "uuid 1.22.0", + "uuid 1.26.0", ] [[package]] @@ -9582,7 +7038,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" dependencies = [ "log", - "ordered-float 4.6.0", + "ordered-float", "strsim", "thiserror 1.0.69", "wezterm-dynamic-derive", @@ -9612,156 +7068,6 @@ dependencies = [ "wezterm-dynamic", ] -[[package]] -name = "wgpu" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f" -dependencies = [ - "arrayvec", - "bitflags 2.13.0", - "bytemuck", - "cfg-if", - "cfg_aliases", - "document-features", - "hashbrown 0.16.1", - "js-sys", - "log", - "naga", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "28.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d23f4642f53f666adcfd2d3218ab174d1e6681101aef18696b90cbe64d1c10f9" -dependencies = [ - "arrayvec", - "bit-set 0.8.0", - "bit-vec 0.8.0", - "bitflags 2.13.0", - "bytemuck", - "cfg_aliases", - "document-features", - "hashbrown 0.16.1", - "indexmap", - "log", - "naga", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 2.0.18", - "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core-deps-apple" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-hal" -version = "28.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d6cb474beb218824dcc9e1ce679d973f719262789bfb27407da560cac20eeb" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set 0.8.0", - "bitflags 2.13.0", - "block", - "bytemuck", - "cfg-if", - "cfg_aliases", - "core-graphics-types 0.2.0", - "glow 0.16.0", - "glutin_wgl_sys", - "gpu-allocator", - "gpu-descriptor", - "hashbrown 0.16.1", - "js-sys", - "khronos-egl", - "libc", - "libloading 0.8.9", - "log", - "metal 0.33.0", - "naga", - "ndk-sys", - "objc", - "once_cell", - "ordered-float 5.3.0", - "parking_lot", - "portable-atomic", - "portable-atomic-util", - "profiling", - "range-alloc", - "raw-window-handle", - "renderdoc-sys", - "smallvec", - "thiserror 2.0.18", - "wasm-bindgen", - "web-sys", - "wgpu-types", - "windows 0.62.2", - "windows-core 0.62.2", -] - -[[package]] -name = "wgpu-types" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c" -dependencies = [ - "bitflags 2.13.0", - "bytemuck", - "js-sys", - "log", - "web-sys", -] - [[package]] name = "which" version = "4.4.2" @@ -9913,7 +7219,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -9924,7 +7230,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -10319,203 +7625,60 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winit" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" -dependencies = [ - "ahash", - "android-activity", - "atomic-waker", - "bitflags 2.13.0", - "block2 0.5.1", - "bytemuck", - "calloop 0.13.0", - "cfg_aliases", - "concurrent-queue", - "core-foundation 0.9.4", - "core-graphics 0.23.2", - "cursor-icon", - "dpi", - "js-sys", - "libc", - "memmap2", - "ndk", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "objc2-ui-kit 0.2.2", - "orbclient", - "percent-encoding", - "pin-project", - "raw-window-handle", - "redox_syscall 0.4.1", - "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit 0.19.2", - "smol_str 0.2.2", - "tracing", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-plasma", - "web-sys", - "web-time", - "windows-sys 0.52.0", - "x11-dl", - "x11rb", - "xkbcommon-dl", -] - [[package]] name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] [[package]] -name = "winnow" -version = "1.0.3" +name = "winresource" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" dependencies = [ - "memchr", + "toml 1.1.4+spec-1.1.0", + "version_check", ] [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "wl-clipboard-rs" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "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.13.0", - "indexmap", + "libc", "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", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "write-fonts" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12725b0845073e20b04e79b500dbfb465904d7cbd84883a1f1bbd084debc515" -dependencies = [ - "font-types", - "indexmap", - "kurbo", - "log", - "read-fonts", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.20", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", ] [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "x11-clipboard" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" -dependencies = [ - "libc", - "x11rb", -] - -[[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", -] +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x11rb" @@ -10523,11 +7686,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ - "as-raw-xcb-connection", "gethostname", - "libc", - "libloading 0.8.9", - "once_cell", "rustix 1.1.4", "x11rb-protocol", ] @@ -10538,22 +7697,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.4", -] - -[[package]] -name = "xcursor" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" - [[package]] name = "xdg-home" version = "1.3.0" @@ -10564,76 +7707,11 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "xkbcommon" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d" -dependencies = [ - "libc", - "memmap2", - "xkeysym", -] - -[[package]] -name = "xkbcommon-dl" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" -dependencies = [ - "bitflags 2.13.0", - "dlib", - "log", - "once_cell", - "xkeysym", -] - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - -[[package]] -name = "xmlwriter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "yazi" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" - -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] - [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10642,13 +7720,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -10674,12 +7752,12 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix 0.29.0", + "nix", "ordered-stream", - "rand 0.8.6", + "rand 0.8.8", "serde", "serde_repr", - "sha1", + "sha1 0.10.7", "static_assertions", "tokio", "tracing", @@ -10693,9 +7771,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.15.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -10718,36 +7796,12 @@ dependencies = [ "serde_repr", "tracing", "uds_windows", - "uuid 1.22.0", + "uuid 1.26.0", "windows-sys 0.61.2", - "winnow 1.0.3", - "zbus_macros 5.15.0", - "zbus_names 4.3.2", - "zvariant 5.11.0", -] - -[[package]] -name = "zbus-lockstep" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" -dependencies = [ - "zbus_xml", - "zvariant 5.11.0", -] - -[[package]] -name = "zbus-lockstep-macros" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "zbus-lockstep", - "zbus_xml", - "zvariant 5.11.0", + "winnow 1.0.4", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", ] [[package]] @@ -10759,23 +7813,23 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "zvariant_utils 2.1.0", ] [[package]] name = "zbus_macros" -version = "5.15.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", - "zbus_names 4.3.2", - "zvariant 5.11.0", - "zvariant_utils 3.3.1", + "syn 3.0.4", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -10791,99 +7845,90 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow 1.0.3", - "zvariant 5.11.0", + "winnow 1.0.4", + "zvariant 5.15.0", ] [[package]] -name = "zbus_xml" -version = "5.1.1" +name = "zcheapstr" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" dependencies = [ - "quick-xml 0.39.4", "serde", - "zbus_names 4.3.2", - "zvariant 5.11.0", ] -[[package]] -name = "zeno" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" - [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -10892,11 +7937,10 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ - "serde", "yoke", "zerofrom", "zerovec-derive", @@ -10904,41 +7948,38 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" -version = "0.5.12" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] @@ -10958,17 +7999,17 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.11.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "url", - "winnow 1.0.3", - "zvariant_derive 5.11.0", - "zvariant_utils 3.3.1", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -10980,21 +8021,21 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "zvariant_utils 2.1.0", ] [[package]] name = "zvariant_derive" -version = "5.11.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", - "zvariant_utils 3.3.1", + "syn 3.0.4", + "zvariant_utils 4.2.0", ] [[package]] @@ -11005,18 +8046,18 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zvariant_utils" -version = "3.3.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", - "winnow 1.0.3", + "syn 3.0.4", + "winnow 1.0.4", ] diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 90de0dfc6..759641545 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -13,9 +13,17 @@ members = [ "qbz-integrations", "qbz-cache", "qbz-library", + # Frontend-agnostic, derived Local Library read projection. It owns no + # source APIs and no Qt types; source caches project into this boundary. + "qbz-local-catalog", "qbz-cast", "qbz-cmaf", "qbz-dsd", + # Optical media (CD-DA now; SACD/ISO next). Frontend-agnostic, and NOT a + # dependency of qbz-dsd's reverse: qbz-dsd will depend on THIS, never the + # other way, or the two close a cycle. + "qbz-disc", + "qbz-rip", "qbz-secrets", "qbz-offline-cache", "qbz-plex", @@ -23,15 +31,15 @@ members = [ "qconnect-protocol", "qconnect-transport-ws", "qconnect-app", - "qbz-ui", - "qbz-dac-wizard", + "qconnect-lan", + # The HiFi Wizard's frontend-agnostic core (ADR-006), consumed by Qt. + "qbz-dac-wizard-core", "qbz-text-utils", - "qbz-slint-common", - "qbz", "qbz-credentials", "qbz-media-controls", "qbz-mixtape", "qbz-playlist-import", + "qbz-account-migration", "qbz-lyrics", "qbz-i18n", "qbz-music-link", @@ -39,10 +47,25 @@ members = [ "qbz-external-reco", "qbz-log", "qbzd", + "qbz-qt", + # The source-agnostic seam (one trait + one registry over Qobuz / local / + # Plex). It deliberately does NOT depend on qbz-audio / qbz-player. + "qbz-source", + # Media-server protocol crates: siblings of `qbz-plex`, same shape. HTTP + + # DTOs only — no cache schema, no settings store, no Qt (ADR-006). + "qbz-jellyfin", + "qbz-subsonic", + # ONE cache for every remote server (see its header for why the Plex one + # is not migrated in the same change). + "qbz-media-cache", ] +# The desktop application is Qt/QML. Plain `cargo build`/`cargo check` produces +# that binary plus its frontend-agnostic dependency closure. +default-members = ["qbz-qt"] + [workspace.package] -version = "2.0.2" +version = "2.1.0" edition = "2021" license = "MIT" repository = "https://github.com/vicrodh/qbz" @@ -59,7 +82,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" # HTTP -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "stream"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "query", "form", "rustls-no-provider", "cookies", "stream"] } # Logging log = "0.4" @@ -71,7 +94,7 @@ anyhow = "1" # Crypto (Qobuz auth) md-5 = "0.10" -base64 = "0.22" +base64 = "0.23" # Time chrono = "0.4" @@ -81,6 +104,7 @@ regex = "1" # Audio symphonia = { version = "0.5", features = ["all"] } +lofty = "0.25.1" # Random number generation (shuffle / DJ-mix) — mirrors src-tauri pin rand = "0.10" @@ -91,59 +115,22 @@ strsim = "0.11" # Note: Linux-specific dependencies (alsa) go in individual crate Cargo.toml files # because workspace manifests cannot have [target] sections +# Local patch of rust_cast 0.21.0 (see crates/vendor/rust_cast/QBZ_PATCH.md): +# its NoCertificateVerification verifier delegates TLS signature checks to +# rustls helpers that parse the server certificate with webpki, which rejects +# the X.509 v1 self-signed certificates served by many Cast devices (AV +# receivers with Chromecast built-in) during the handshake. The vendored copy +# asserts signature validity instead, matching the verifier's no-validation +# contract. Drop this patch once a rust_cast release carries the fix upstream. +[patch.crates-io] +rust_cast = { path = "vendor/rust_cast" } + [profile.release] # Strip symbol tables from release binaries. Measured on 2026-07-02: -# 400 MB -> 218 MB (-45%). There are no .debug_* sections in a default -# release build — the weight is .strtab (151 MB) + .symtab (22 MB), inflated -# by Slint's generated generic symbol names. Trade-off: panic backtraces show -# addresses instead of names; the release workflow archives the UNSTRIPPED -# binary as an Actions artifact per tag for offline symbolication. +# 400 MB -> 218 MB (-45%). Trade-off: panic backtraces show addresses instead +# of names; release automation retains an unstripped artifact for offline +# symbolication. # Codegen knobs (opt-level / codegen-units / jobs) intentionally stay in the # workflows' env (CARGO_PROFILE_RELEASE_*) — they are CI-memory tuning, not # a property of the package. strip = "symbols" - -[patch.crates-io] -# Slint 1.16's femtovg-wgpu renderer leaves the wgpu surface alpha mode at -# `Auto`, which resolves as an opaque compositor surface on KWin/Wayland and -# makes borderless transparent windows show a black rectangular backing. The -# patch keeps Slint pinned but chooses a supported non-opaque composite alpha -# mode when the surface advertises one, so the miniplayer card can have real -# transparent rounded corners. The non-opaque mode is PER-WINDOW opt-in -# (`wgpu::set_surface_prefers_transparent`, flipped from qbz's winit -# window-attributes hook): only the miniplayer pays the compositor-blend cost, -# the main window keeps an Opaque swapchain. -i-slint-renderer-femtovg = { path = "vendor/i-slint-renderer-femtovg-1.16.1" } -# On modern macOS, fontique's CoreText per-script fallback can fail to resolve -# a Han-script family (the suggested PingFang is not always in the scanned -# family map), so Japanese kanji rendered blank while kana worked. The patch -# pre-registers the known system CJK families (Hiragino/PingFang/Apple SD -# Gothic Neo/Arial Unicode MS, Japanese-biased default) in the fallback map at -# collection creation, which takes priority over the CoreText query. macOS-only -# (#[cfg]); Linux keeps its working fontconfig fallback. -i-slint-common = { path = "vendor/i-slint-common-1.16.1" } -# With `overflow: elide`, sharedparley culled EVERY line whose bottom edge -# exceeded max_height — including the FIRST one, eliding the text to nothing. -# CJK fallback fonts (Hiragino on macOS) report line metrics taller than -# snug fixed-height boxes (TrackRow's 20px title line at 14px), so every -# CJK track title rendered blank on macOS while Latin fit (#543; verified -# with a headless repro matrix on the Mac Mini). The patch always draws the -# first line — it still gets the ellipsis treatment as the last visible one. -i-slint-core = { path = "vendor/i-slint-core-1.16.1" } -# Slint compiles femtovg with the swash raster path. Swash fails to scale -# some variable fonts at a non-default variation instance (the CFF2-based -# CJK system fonts on modern macOS), and femtovg cached those glyphs as -# permanently missing — so CJK text at any non-regular weight rendered -# blank (#543; regular weight = empty coords = renders fine). The patch -# retries the glyph at the font's DEFAULT instance before giving up: worst -# case the glyph draws at regular weight instead of not at all. No effect -# on fonts/instances that already render. -femtovg = { path = "vendor/femtovg-0.23.2" } -# macOS Skia renderer: skia bug 310510989 makes new_from_data fail for TTC -# faces at index > 0, and for some system collections (AppleSDGothicNeo.ttc, -# where EVERY weight sits at index > 0) the upstream read/write-fonts rebuild -# workaround ALSO fails — every Korean glyph run at non-regular weights drew -# nothing. The patch adds a second-chance workaround: resolve the same face -# through CoreText by family name + OS/2 weight, guarded by cmap glyph-id -# spot checks so a wrong match is rejected instead of drawing wrong glyphs. -i-slint-renderer-skia = { path = "vendor/i-slint-renderer-skia-1.16.1" } diff --git a/crates/Cross.toml b/crates/Cross.toml index fe5ae7d4a..9016752b7 100644 --- a/crates/Cross.toml +++ b/crates/Cross.toml @@ -1,6 +1,8 @@ # cross (https://github.com/cross-rs/cross) config for cross-compiling the -# aarch64 `qbz` binary from an x86-64 Linux host. Used by -# scripts/build-aarch64-linux.sh (CROSS mode). +# aarch64 `qbzd` daemon from an x86-64 Linux host. Used by +# scripts/build-aarch64-qbzd.sh (CROSS mode). The Qt desktop binary is NOT +# cross-built (cxx-qt needs the target Qt's qmake at build time; see +# scripts/build-aarch64-linux.sh) — build it natively on an ARM box. # # `image` pins a newer Ubuntu (cross 0.2.5's default aarch64 image is Ubuntu # 16.04 "xenial" — EOL, archived repos, OpenSSL 1.0.2 — which breaks modern @@ -20,7 +22,7 @@ image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main" pre-build = [ "dpkg --add-architecture arm64", "apt-get update", - "apt-get install -y --no-install-recommends pkg-config cmake clang libclang-dev nasm libasound2-dev:arm64 libjack-jackd2-dev:arm64 libfontconfig1-dev:arm64 libfreetype6-dev:arm64 libxkbcommon-dev:arm64 libwayland-dev:arm64 libxcb1-dev:arm64 libgl1-mesa-dev:arm64 libegl1-mesa-dev:arm64 libdbus-1-dev:arm64 libssl-dev:arm64", + "apt-get install -y --no-install-recommends pkg-config cmake clang libclang-dev nasm libasound2-dev:arm64 libjack-jackd2-dev:arm64 libdbus-1-dev:arm64 libssl-dev:arm64", ] [target.aarch64-unknown-linux-gnu.env] diff --git a/crates/qbz-account-migration/Cargo.toml b/crates/qbz-account-migration/Cargo.toml new file mode 100644 index 000000000..68f38d146 --- /dev/null +++ b/crates/qbz-account-migration/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "qbz-account-migration" +version = "0.1.0" +edition = "2021" +description = "Qobuz account migration for QBZ — snapshot one account's favorites and playlists, plan and apply them additively into another (headless, frontend-agnostic)" +authors = ["blitzkriegfc"] + +[dependencies] +qbz-models = { path = "../qbz-models" } +qbz-qobuz = { path = "../qbz-qobuz" } + +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +tokio = { workspace = true } +chrono = { workspace = true } +rusqlite = { version = "0.40", features = ["bundled"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/qbz-account-migration/src/api.rs b/crates/qbz-account-migration/src/api.rs new file mode 100644 index 000000000..2c095dd67 --- /dev/null +++ b/crates/qbz-account-migration/src/api.rs @@ -0,0 +1,78 @@ +//! The slice of the Qobuz client the migration needs, as a trait so the +//! planner and the applier are tested against a fake and so another host +//! (qbzd) can supply its own client. + +use qbz_models::{Playlist, PlaylistWithTrackIds}; +use qbz_qobuz::QobuzClient; +use serde_json::Value; + +/// Reads and writes on the ACTIVE session's account. Writes are additive +/// by construction: there is no delete, no unsubscribe, no update here. +#[allow(async_fn_in_trait)] +pub trait MigrationApi: Send + Sync { + /// `favorite/getUserFavorites?type=&limit&offset` — the raw + /// envelope `{ : { items, total } }`. + async fn favorites_page(&self, plural: &str, limit: u32, offset: u32) -> Result; + /// Every playlist the account owns or follows. + async fn user_playlists(&self) -> Result, String>; + /// A playlist's track ids in order (`extra=track_ids`). + async fn playlist_track_ids(&self, playlist_id: u64) -> Result; + /// `favorite/create` for one id; `singular` is `album|track|artist|label|award`. + async fn add_favorite(&self, singular: &str, id: &str) -> Result<(), String>; + async fn create_playlist( + &self, + name: &str, + description: Option<&str>, + is_public: bool, + ) -> Result; + /// Appends `track_ids` in order (the caller chunks). + async fn add_tracks(&self, playlist_id: u64, track_ids: &[u64]) -> Result<(), String>; + async fn subscribe(&self, playlist_id: u64) -> Result<(), String>; +} + +impl MigrationApi for QobuzClient { + async fn favorites_page(&self, plural: &str, limit: u32, offset: u32) -> Result { + self.get_favorites(plural, limit, offset) + .await + .map_err(|e| e.to_string()) + } + + async fn user_playlists(&self) -> Result, String> { + self.get_user_playlists().await.map_err(|e| e.to_string()) + } + + async fn playlist_track_ids(&self, playlist_id: u64) -> Result { + self.get_playlist_track_ids(playlist_id) + .await + .map_err(|e| e.to_string()) + } + + async fn add_favorite(&self, singular: &str, id: &str) -> Result<(), String> { + QobuzClient::add_favorite(self, singular, id) + .await + .map_err(|e| e.to_string()) + } + + async fn create_playlist( + &self, + name: &str, + description: Option<&str>, + is_public: bool, + ) -> Result { + QobuzClient::create_playlist(self, name, description, is_public) + .await + .map_err(|e| e.to_string()) + } + + async fn add_tracks(&self, playlist_id: u64, track_ids: &[u64]) -> Result<(), String> { + self.add_tracks_to_playlist(playlist_id, track_ids) + .await + .map_err(|e| e.to_string()) + } + + async fn subscribe(&self, playlist_id: u64) -> Result<(), String> { + self.subscribe_playlist(playlist_id) + .await + .map_err(|e| e.to_string()) + } +} diff --git a/crates/qbz-account-migration/src/apply.rs b/crates/qbz-account-migration/src/apply.rs new file mode 100644 index 000000000..cf2b5669b --- /dev/null +++ b/crates/qbz-account-migration/src/apply.rs @@ -0,0 +1,377 @@ +//! Execute a [`CloudPlan`] against the target account: one favorite per +//! call with pacing and retries, playlists created and filled in 50-track +//! chunks in order, subscriptions re-followed. Every success is written to +//! the ledger before the next write, so a crash mid-run resumes. + +use std::path::Path; +use std::time::Duration; + +use serde::Serialize; + +use crate::api::MigrationApi; +use crate::ledger::Ledger; +use crate::plan::{CloudPlan, PlaylistAction}; +use crate::sink::{MigrationEvent, MigrationPhase, MigrationSink}; + +/// The importer's chunk (`qbz-playlist-import`): 50 ids per `addTracks`. +const ADD_CHUNK: usize = 50; +/// Breathing room between writes; the 403 breaker is the real ceiling. +const PACE: Duration = Duration::from_millis(100); +/// Transient retries per write: 1 s, 2 s, 4 s. +const RETRY_BACKOFF: [Duration; 3] = [ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), +]; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct SectionReport { + pub added: usize, + pub already: usize, + pub failed: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct ApplyReport { + pub favorites: SectionReport, + pub playlists: SectionReport, + /// Tracks appended across all playlists. + pub tracks_added: usize, + pub subscriptions: SectionReport, +} + +impl ApplyReport { + pub fn failed(&self) -> usize { + self.favorites.failed.len() + self.playlists.failed.len() + self.subscriptions.failed.len() + } +} + +/// A write with bounded retries. Only the last error is reported. +async fn with_retry(mut op: F) -> Result<(), String> +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let mut last = String::new(); + for (attempt, backoff) in RETRY_BACKOFF.iter().enumerate() { + match op().await { + Ok(()) => return Ok(()), + Err(e) => { + log::warn!( + "[account-migration] write failed (attempt {}): {e}", + attempt + 1 + ); + last = e; + tokio::time::sleep(*backoff).await; + } + } + } + match op().await { + Ok(()) => Ok(()), + Err(e) => Err(if e.is_empty() { last } else { e }), + } +} + +/// Run the plan. `profile_dir` is the TARGET profile (the ledger lives in +/// it). Failures are collected per item and the run continues; the report +/// says exactly what did not happen. +pub async fn apply( + plan: &CloudPlan, + api: &A, + profile_dir: &Path, + source_user_id: u64, + sink: &S, +) -> Result { + let mut ledger = Ledger::load(profile_dir)?; + ledger.sources.insert(source_user_id); + let mut report = ApplyReport::default(); + + // --- Favorites, oldest first ----------------------------------------- + sink.emit(MigrationEvent::Phase(MigrationPhase::Favorites)); + let total: usize = plan.favorites_to_add(); + let mut done = 0usize; + for (kind, items) in &plan.favorites.to_add { + for item in items { + sink.emit(MigrationEvent::Progress { + done, + total, + label: format!("{}: {}", kind.plural(), item.title), + }); + let id = item.id.clone(); + let singular = kind.singular(); + match with_retry(|| api.add_favorite(singular, &id)).await { + Ok(()) => { + ledger.mark_favorite(kind.plural(), &item.id); + ledger.save(profile_dir)?; + report.favorites.added += 1; + } + Err(e) => { + report + .favorites + .failed + .push(format!("{} {}: {e}", kind.singular(), item.id)) + } + } + done += 1; + tokio::time::sleep(PACE).await; + } + } + report.favorites.already = plan.favorites.already.iter().map(|(_, n)| n).sum(); + + // --- Playlists --------------------------------------------------------- + sink.emit(MigrationEvent::Phase(MigrationPhase::Playlists)); + let total = plan.playlists.len(); + for (i, action) in plan.playlists.iter().enumerate() { + sink.emit(MigrationEvent::Progress { + done: i, + total, + label: action.name().to_string(), + }); + let (target_id, track_ids): (u64, &[u64]) = match action { + PlaylistAction::Merge { + target_id, + missing_track_ids, + .. + } => (*target_id, missing_track_ids), + PlaylistAction::Create { + name, + description, + is_public, + track_ids, + .. + } + | PlaylistAction::CreateCopy { + name, + description, + is_public, + track_ids, + .. + } => { + let created = match api + .create_playlist(name, description.as_deref(), *is_public) + .await + { + Ok(p) => p, + Err(e) => { + report + .playlists + .failed + .push(format!("{name}: create failed: {e}")); + continue; + } + }; + // Map it before filling it: a crash while adding tracks + // must not create a second copy on the next run. + ledger.playlist_map.insert(action.source_id(), created.id); + ledger.save(profile_dir)?; + (created.id, track_ids) + } + }; + let mut failed_chunk = None; + for chunk in track_ids.chunks(ADD_CHUNK) { + if let Err(e) = with_retry(|| api.add_tracks(target_id, chunk)).await { + failed_chunk = Some(e); + break; + } + report.tracks_added += chunk.len(); + tokio::time::sleep(PACE).await; + } + match failed_chunk { + Some(e) => report + .playlists + .failed + .push(format!("{}: adding tracks failed: {e}", action.name())), + None => { + ledger.playlist_map.insert(action.source_id(), target_id); + ledger.save(profile_dir)?; + report.playlists.added += 1; + } + } + } + report.playlists.already = plan.playlists_already; + + // --- Subscriptions ----------------------------------------------------- + sink.emit(MigrationEvent::Phase(MigrationPhase::Subscriptions)); + let total = plan.subscriptions.len(); + for (i, id) in plan.subscriptions.iter().enumerate() { + sink.emit(MigrationEvent::Progress { + done: i, + total, + label: id.to_string(), + }); + match with_retry(|| api.subscribe(*id)).await { + Ok(()) => { + ledger.subscriptions_done.insert(*id); + ledger.playlist_map.insert(*id, *id); + ledger.save(profile_dir)?; + report.subscriptions.added += 1; + } + Err(e) => report + .subscriptions + .failed + .push(format!("playlist {id}: subscribe failed: {e}")), + } + tokio::time::sleep(PACE).await; + } + report.subscriptions.already = plan.subscriptions_already; + + sink.emit(MigrationEvent::Phase(MigrationPhase::Done)); + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plan::plan; + use crate::sink::NullSink; + use crate::snapshot::{AccountSnapshot, FavoriteItem, OwnedPlaylist, SubscribedPlaylist}; + use qbz_models::{Playlist, PlaylistOwner, PlaylistWithTrackIds}; + use serde_json::{json, Value}; + use std::sync::Mutex; + + /// Records every write; `fail_track` makes one track id fail forever. + #[derive(Default)] + struct Fake { + writes: Mutex>, + next_playlist_id: Mutex, + fail_add_favorite: Option, + } + + impl MigrationApi for Fake { + async fn favorites_page(&self, plural: &str, _: u32, _: u32) -> Result { + Ok(json!({ plural: { "items": [], "total": 0 } })) + } + async fn user_playlists(&self) -> Result, String> { + Ok(vec![]) + } + async fn playlist_track_ids(&self, _: u64) -> Result { + Err("unused".into()) + } + async fn add_favorite(&self, singular: &str, id: &str) -> Result<(), String> { + if self.fail_add_favorite.as_deref() == Some(id) { + return Err("boom".into()); + } + self.writes + .lock() + .unwrap() + .push(format!("fav {singular} {id}")); + Ok(()) + } + async fn create_playlist( + &self, + name: &str, + _: Option<&str>, + _: bool, + ) -> Result { + let mut next = self.next_playlist_id.lock().unwrap(); + *next += 1; + self.writes + .lock() + .unwrap() + .push(format!("create {name} -> {}", *next)); + Ok(Playlist { + id: *next, + name: name.to_string(), + owner: PlaylistOwner::default(), + ..serde_json::from_value(json!({"id": *next, "name": name})).unwrap() + }) + } + async fn add_tracks(&self, playlist_id: u64, track_ids: &[u64]) -> Result<(), String> { + self.writes + .lock() + .unwrap() + .push(format!("add {playlist_id} {track_ids:?}")); + Ok(()) + } + async fn subscribe(&self, playlist_id: u64) -> Result<(), String> { + self.writes + .lock() + .unwrap() + .push(format!("subscribe {playlist_id}")); + Ok(()) + } + } + + fn source() -> AccountSnapshot { + let mut s = AccountSnapshot::empty(1, "src"); + s.favorites.albums = vec![ + FavoriteItem { + id: "b".into(), + favorited_at: Some(2), + title: "B".into(), + server_index: 0, + }, + FavoriteItem { + id: "a".into(), + favorited_at: Some(1), + title: "A".into(), + server_index: 1, + }, + ]; + s.playlists = vec![OwnedPlaylist { + id: 10, + name: "Mix".into(), + description: Some("d".into()), + is_public: false, + track_ids: (1..=60).collect(), + }]; + s.subscriptions = vec![SubscribedPlaylist { + id: 77, + name: "Theirs".into(), + owner_id: 9, + }]; + s + } + + #[tokio::test] + async fn applies_in_order_and_a_second_run_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + let api = Fake::default(); + let target = AccountSnapshot::empty(2, "dst"); + let p = plan(&source(), &target, &Ledger::load(dir.path()).unwrap()); + let report = apply(&p, &api, dir.path(), 1, &NullSink).await.unwrap(); + assert_eq!(report.favorites.added, 2); + assert_eq!(report.playlists.added, 1); + assert_eq!(report.tracks_added, 60); + assert_eq!(report.subscriptions.added, 1); + assert_eq!(report.failed(), 0); + let writes = api.writes.lock().unwrap().clone(); + assert_eq!(writes[0], "fav album a"); // oldest first + assert_eq!(writes[1], "fav album b"); + assert_eq!(writes[2], "create Mix -> 1"); + assert!(writes[3].starts_with("add 1 [1, 2,")); // chunk of 50 + assert!(writes[4].starts_with("add 1 [51,")); // then the 10 left + assert_eq!(writes[5], "subscribe 77"); + + // The ledger now knows everything: the same plan against the same + // (still empty) target reads as done. + let ledger = Ledger::load(dir.path()).unwrap(); + assert_eq!(ledger.playlist_map.get(&10), Some(&1)); + assert!(ledger.favorite_done("albums", "a")); + let again = plan(&source(), &target, &ledger); + assert_eq!(again.favorites_to_add(), 0); + assert!(again.subscriptions.is_empty()); + // The mapped playlist is absent from the (empty) target: planned as + // new, which is honest — the target really has no such playlist. + assert_eq!(again.playlists.len(), 1); + } + + #[tokio::test] + async fn a_failing_favorite_is_reported_and_the_rest_continues() { + let dir = tempfile::tempdir().unwrap(); + let api = Fake { + fail_add_favorite: Some("a".into()), + ..Fake::default() + }; + let target = AccountSnapshot::empty(2, "dst"); + let p = plan(&source(), &target, &Ledger::default()); + let report = apply(&p, &api, dir.path(), 1, &NullSink).await.unwrap(); + assert_eq!(report.favorites.added, 1); + assert_eq!(report.favorites.failed.len(), 1); + assert!(report.favorites.failed[0].contains("album a")); + assert_eq!(report.playlists.added, 1); + let ledger = Ledger::load(dir.path()).unwrap(); + assert!(!ledger.favorite_done("albums", "a")); + assert!(ledger.favorite_done("albums", "b")); + } +} diff --git a/crates/qbz-account-migration/src/ledger.rs b/crates/qbz-account-migration/src/ledger.rs new file mode 100644 index 000000000..956b90730 --- /dev/null +++ b/crates/qbz-account-migration/src/ledger.rs @@ -0,0 +1,71 @@ +//! The ledger: what earlier runs already wrote into the TARGET account, kept +//! in the target's profile directory. Two jobs: resume/re-run to "0 +//! changes", and the old→new playlist id map the local-profile copy needs +//! to remap everything keyed by `qobuz_playlist_id`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::snapshot::SNAPSHOT_DIR; + +pub const LEDGER_FILE: &str = "ledger.json"; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Ledger { + /// Source playlist id → target playlist id (created, merged into, or + /// re-subscribed; the last two map an id onto itself or the merge + /// target). + #[serde(default)] + pub playlist_map: BTreeMap, + /// Favorite ids already written, per kind (`albums`, `tracks`, ...). + #[serde(default)] + pub favorites_done: BTreeMap>, + #[serde(default)] + pub subscriptions_done: BTreeSet, + /// Source account ids this ledger has seen, for the report. + #[serde(default)] + pub sources: BTreeSet, +} + +impl Ledger { + pub fn path(profile_dir: &Path) -> PathBuf { + profile_dir.join(SNAPSHOT_DIR).join(LEDGER_FILE) + } + + /// Missing file = empty ledger; a corrupt file is an error, never + /// silently a fresh start (that would re-write everything). + pub fn load(profile_dir: &Path) -> Result { + let path = Self::path(profile_dir); + match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text) + .map_err(|e| format!("ledger {} is corrupt: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(format!("{}: {e}", path.display())), + } + } + + pub fn save(&self, profile_dir: &Path) -> Result<(), String> { + let path = Self::path(profile_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; + std::fs::write(&path, json).map_err(|e| format!("{}: {e}", path.display())) + } + + pub fn favorite_done(&self, plural: &str, id: &str) -> bool { + self.favorites_done + .get(plural) + .map(|set| set.contains(id)) + .unwrap_or(false) + } + + pub fn mark_favorite(&mut self, plural: &str, id: &str) { + self.favorites_done + .entry(plural.to_string()) + .or_default() + .insert(id.to_string()); + } +} diff --git a/crates/qbz-account-migration/src/lib.rs b/crates/qbz-account-migration/src/lib.rs new file mode 100644 index 000000000..250b35e31 --- /dev/null +++ b/crates/qbz-account-migration/src/lib.rs @@ -0,0 +1,38 @@ +//! Qobuz account migration (contract: +//! `qbz-nix-docs/specs/2026-09-02-account-migration-contract.md`, §9). +//! +//! Two halves, both frontend-agnostic (ADR 006): +//! +//! 1. [`snapshot`]: while signed in with the SOURCE account, capture its +//! favorites (five kinds, with `favorited_at`), its own playlists with +//! their track ids in order, and the playlists it follows, into one JSON +//! file in that account's profile directory. +//! 2. [`plan`] + [`apply`]: while signed in with the TARGET account, read a +//! snapshot, compute the delta against the target's live state (captured +//! with the same code) and the [`ledger`] of earlier runs, and write it +//! — additively, never deleting on either side. Every write lands in the +//! ledger before the next one starts, so a run can be resumed and re-run +//! to "0 changes". +//! +//! Nothing here touches subscriptions, purchases or account data: the +//! writes are the same `favorite/create`, `playlist/create`, +//! `playlist/addTracks` and `playlist/subscribe` the app already issues +//! when the user hearts or creates something. + +pub mod api; +pub mod apply; +pub mod ledger; +pub mod local; +pub mod plan; +pub mod sink; +pub mod snapshot; + +pub use api::MigrationApi; +pub use apply::{apply, ApplyReport, SectionReport}; +pub use ledger::Ledger; +pub use local::{copy_profile, LocalOptions, LocalReport}; +pub use plan::{plan, CloudPlan, PlaylistAction}; +pub use sink::{MigrationEvent, MigrationPhase, MigrationSink}; +pub use snapshot::{ + capture, AccountSnapshot, FavoriteItem, FavoriteKind, OwnedPlaylist, SubscribedPlaylist, +}; diff --git a/crates/qbz-account-migration/src/local.rs b/crates/qbz-account-migration/src/local.rs new file mode 100644 index 000000000..0a0626801 --- /dev/null +++ b/crates/qbz-account-migration/src/local.rs @@ -0,0 +1,1088 @@ +//! The local half: copy a QBZ profile directory (`users//`) into +//! another (`users//`), row by row, remapping everything keyed by a +//! Qobuz playlist id through the [`Ledger`]'s old→new map. +//! +//! Rules (contract §2.2, §3.7): +//! - the SOURCE is opened read-only and never modified; +//! - the DESTINATION is never replaced wholesale: rows are inserted with +//! `INSERT OR IGNORE` (collections) or `INSERT OR REPLACE` (single-row +//! preference tables), files only when absent; +//! - anything keyed by `qobuz_playlist_id` is remapped; a row whose old id +//! the ledger does not know is skipped and counted; +//! - the caches of the server's truth, the compliance clock, the session +//! and the derived catalog generations are never copied. +//! +//! Copies are by column intersection (`PRAGMA table_info` on both sides), +//! so a schema that grew a column on one side still copies what both have. + +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +use rusqlite::types::Value; +use rusqlite::{Connection, OpenFlags}; +use serde::{Deserialize, Serialize}; + +use crate::ledger::Ledger; + +/// What the user opted into beyond settings and library. All default on; +/// the panel asks (owner decision 2026-09-02). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalOptions { + /// Plex / Jellyfin / Subsonic connections (carry credentials). + pub media_servers: bool, + /// Last.fm / ListenBrainz accounts (carry credentials). + pub scrobblers: bool, + /// Listen log and recommendation events. + pub listening_history: bool, +} + +impl Default for LocalOptions { + fn default() -> Self { + Self { + media_servers: true, + scrobblers: true, + listening_history: true, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct LocalReport { + /// `file/table` → rows inserted. + pub copied: BTreeMap, + /// Rows keyed by a playlist id the ledger does not map. + pub unmapped_playlist_rows: usize, + /// The destination already had library folders: only missing folders + /// were added; tracks and per-track links were not copied. + pub needs_rescan: bool, + /// Meaningful Plex / Jellyfin / Subsonic connection records present in + /// the source stores. Default placeholder rows are deliberately excluded. + pub media_connections_found: usize, + /// Source connection records whose containing store copied successfully. + pub media_connections_copied: usize, + /// Human-readable notes (missing source files, skipped tables). + pub notes: Vec, +} + +impl LocalReport { + pub fn total_rows(&self) -> usize { + self.copied.values().sum() + } + + fn add(&mut self, key: impl Into, n: usize) { + if n > 0 { + *self.copied.entry(key.into()).or_default() += n; + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Conflict { + /// Keep the destination's row (collections, ids). + Ignore, + /// The source's row wins (single-row preference tables). + Replace, +} + +fn open_ro(path: &Path) -> Result { + Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|e| format!("{}: {e}", path.display())) +} + +fn open_rw(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + let conn = Connection::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;") + .map_err(|e| e.to_string())?; + Ok(conn) +} + +fn columns(conn: &Connection, table: &str) -> Result, String> { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info(\"{table}\")")) + .map_err(|e| e.to_string())?; + let cols = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| e.to_string())? + .filter_map(Result::ok) + .collect(); + Ok(cols) +} + +fn user_tables(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") + .map_err(|e| e.to_string())?; + let names = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .filter_map(Result::ok) + .collect(); + Ok(names) +} + +/// Count rows that contain actual connection state rather than the empty +/// placeholder inserted when a settings store is first opened. The candidate +/// columns are intersected with the live schema so this also works with older +/// QBZ profile databases. +fn meaningful_connection_rows( + src_dir: &Path, + file: &str, + table: &str, + candidate_columns: &[&str], +) -> Result { + let path = src_dir.join(file); + if !path.is_file() { + return Ok(0); + } + let conn = open_ro(&path)?; + let present = columns(&conn, table)?; + let present: HashSet<&str> = present.iter().map(String::as_str).collect(); + let selected: Vec<&str> = candidate_columns + .iter() + .copied() + .filter(|column| present.contains(column)) + .collect(); + if selected.is_empty() { + return Ok(0); + } + let projection = selected + .iter() + .map(|column| format!("\"{column}\"")) + .collect::>() + .join(", "); + let mut statement = conn + .prepare(&format!("SELECT {projection} FROM \"{table}\"")) + .map_err(|e| e.to_string())?; + let mut rows = statement.query([]).map_err(|e| e.to_string())?; + let mut count = 0; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + let mut meaningful = false; + for index in 0..selected.len() { + meaningful |= match row.get::<_, Value>(index).map_err(|e| e.to_string())? { + Value::Null => false, + Value::Integer(value) => value != 0, + Value::Real(value) => value != 0.0, + Value::Text(value) => { + let value = value.trim(); + !value.is_empty() && value != "[]" + } + Value::Blob(value) => !value.is_empty(), + }; + } + count += usize::from(meaningful); + } + Ok(count) +} + +/// Copy one table by column intersection. `remap` names a column holding +/// a source playlist id and the map to translate it; rows whose id is not +/// in the map are skipped and counted in `unmapped`. +fn copy_table( + src: &Connection, + dst: &Connection, + table: &str, + conflict: Conflict, + remap: Option<(&str, &BTreeMap)>, + unmapped: &mut usize, +) -> Result { + let src_cols = columns(src, table)?; + let dst_cols = columns(dst, table)?; + if src_cols.is_empty() || dst_cols.is_empty() { + return Ok(0); + } + let dst_set: HashSet<&str> = dst_cols.iter().map(String::as_str).collect(); + let cols: Vec<&str> = src_cols + .iter() + .map(String::as_str) + .filter(|c| dst_set.contains(c)) + .collect(); + if cols.is_empty() { + return Ok(0); + } + let remap_index = remap.and_then(|(col, _)| cols.iter().position(|c| *c == col)); + if remap.is_some() && remap_index.is_none() { + return Ok(0); + } + let quoted: Vec = cols.iter().map(|c| format!("\"{c}\"")).collect(); + let select = format!("SELECT {} FROM \"{table}\"", quoted.join(", ")); + let verb = match conflict { + Conflict::Ignore => "INSERT OR IGNORE", + Conflict::Replace => "INSERT OR REPLACE", + }; + let placeholders: Vec<&str> = cols.iter().map(|_| "?").collect(); + let insert = format!( + "{verb} INTO \"{table}\" ({}) VALUES ({})", + quoted.join(", "), + placeholders.join(", ") + ); + + let mut read = src.prepare(&select).map_err(|e| e.to_string())?; + let mut write = dst.prepare(&insert).map_err(|e| e.to_string())?; + let mut rows = read.query([]).map_err(|e| e.to_string())?; + let mut inserted = 0usize; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + let mut values: Vec = Vec::with_capacity(cols.len()); + for i in 0..cols.len() { + values.push(row.get::<_, Value>(i).map_err(|e| e.to_string())?); + } + if let (Some(idx), Some((_, map))) = (remap_index, remap) { + let old = match &values[idx] { + Value::Integer(n) => Some(*n as u64), + Value::Text(s) => s.parse::().ok(), + _ => None, + }; + match old.and_then(|o| map.get(&o)) { + Some(new) => { + values[idx] = match &values[idx] { + Value::Text(_) => Value::Text(new.to_string()), + _ => Value::Integer(*new as i64), + } + } + None => { + *unmapped += 1; + continue; + } + } + } + inserted += write + .execute(rusqlite::params_from_iter(values.iter())) + .map_err(|e| format!("{table}: {e}"))?; + } + Ok(inserted) +} + +/// Copy every user table of a store, one conflict policy for all. +fn copy_store( + src_dir: &Path, + dst_dir: &Path, + file: &str, + conflict: Conflict, + skip_tables: &[&str], + report: &mut LocalReport, +) -> Result<(), String> { + let src_path = src_dir.join(file); + if !src_path.is_file() { + report + .notes + .push(format!("{file}: not in the source profile")); + return Ok(()); + } + let src = open_ro(&src_path)?; + let dst = open_rw(&dst_dir.join(file))?; + // Make sure the destination has the same tables; a store the target + // app has not opened yet is an empty file, so create from the source + // DDL (tables only — indexes come back when the app opens it). + for table in user_tables(&src)? { + if skip_tables.contains(&table.as_str()) { + continue; + } + if columns(&dst, &table)?.is_empty() { + let ddl: String = src + .query_row( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1", + [&table], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + dst.execute_batch(&ddl) + .map_err(|e| format!("{file}/{table}: {e}"))?; + } + let mut unmapped = 0; + let n = copy_table(&src, &dst, &table, conflict, None, &mut unmapped)?; + report.add(format!("{file}/{table}"), n); + } + Ok(()) +} + +/// Copy a file only when the destination does not have it. +fn copy_file_if_absent(src_dir: &Path, dst_dir: &Path, rel: &str, report: &mut LocalReport) { + let src = src_dir.join(rel); + let dst = dst_dir.join(rel); + if !src.is_file() { + return; + } + if dst.exists() { + report.notes.push(format!("{rel}: kept the existing one")); + return; + } + if let Some(parent) = dst.parent() { + let _ = std::fs::create_dir_all(parent); + } + match std::fs::copy(&src, &dst) { + Ok(_) => report.add(rel, 1), + Err(e) => report.notes.push(format!("{rel}: copy failed: {e}")), + } +} + +/// The LOCAL half of `library.db` (folders, scanned tracks, mixtapes...). +const LIBRARY_LOCAL_TABLES: &[&str] = &[ + "library_folders", + "local_tracks", + "local_scan_cue_refs", + "local_sacd_images", + "local_sacd_tracks", + "album_settings", + "custom_album_covers", + "artist_images", + "library_kv", + "local_playlists", + "local_playlist_tracks", + "mixtape_collections", + "mixtape_collection_items", +]; + +/// The ACCOUNT half of `library.db`: tables keyed by `qobuz_playlist_id`. +/// `playlist_local_tracks` rides on `local_tracks` ids, so only on the fast +/// path. +const LIBRARY_PLAYLIST_TABLES: &[&str] = &[ + "playlist_settings", + "playlist_track_custom_order", + "playlist_plex_tracks", + "playlist_remote_tracks", + "playlist_stats", + "copied_playlists", +]; + +/// Preference stores copied whole, the source's rows winning on conflict +/// (single-row tables). +const PREF_STORES: &[&str] = &[ + "discover_prefs.db", + "favorites_preferences.db", + "tray_settings.db", + "remote_control_settings.db", + "playback_preferences.db", +]; + +/// Collection stores copied additively (ids are catalog-global). +const COLLECTION_STORES: &[(&str, &[&str])] = &[ + ("artist_blacklist.db", &["blacklist_settings"]), + ("local_favorites.db", &[]), +]; + +/// JSON sidecars copied only when the destination lacks them. +const JSON_IF_ABSENT: &[&str] = &[ + "lyrics_prefs.json", + "reco_dismiss.json", + "collection_view_prefs.json", + "myqbz_branding.json", + "collection_open_rows.json", +]; + +fn library_folder_count(conn: &Connection) -> usize { + conn.query_row("SELECT COUNT(*) FROM library_folders", [], |r| { + r.get::<_, i64>(0) + }) + .unwrap_or(0) as usize +} + +fn copy_library( + src_dir: &Path, + dst_dir: &Path, + map: &BTreeMap, + report: &mut LocalReport, +) -> Result<(), String> { + let src_path = src_dir.join("library.db"); + if !src_path.is_file() { + report + .notes + .push("library.db: not in the source profile".into()); + return Ok(()); + } + let src = open_ro(&src_path)?; + let dst = open_rw(&dst_dir.join("library.db"))?; + let src_tables: HashSet = user_tables(&src)?.into_iter().collect(); + let dst_tables: HashSet = user_tables(&dst)?.into_iter().collect(); + // Tables the destination app has not created yet come from the source + // DDL, so a profile that never opened Local Library still receives it. + for table in LIBRARY_LOCAL_TABLES + .iter() + .chain(LIBRARY_PLAYLIST_TABLES.iter()) + .chain(["playlist_folders", "playlist_local_tracks"].iter()) + { + if src_tables.contains(*table) && !dst_tables.contains(*table) { + let ddl: String = src + .query_row( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + dst.execute_batch(&ddl) + .map_err(|e| format!("library.db/{table}: {e}"))?; + } + } + + let fast_path = library_folder_count(&dst) == 0; + let mut unmapped = 0usize; + if fast_path { + for table in LIBRARY_LOCAL_TABLES { + if !src_tables.contains(*table) { + continue; + } + let n = copy_table(&src, &dst, table, Conflict::Ignore, None, &mut unmapped)?; + report.add(format!("library.db/{table}"), n); + } + } else { + report.needs_rescan = true; + report.notes.push( + "library.db: the destination already has library folders; only missing folders were added, run a rescan" + .into(), + ); + if src_tables.contains("library_folders") { + let n = copy_table( + &src, + &dst, + "library_folders", + Conflict::Ignore, + None, + &mut unmapped, + )?; + report.add("library.db/library_folders", n); + } + for table in [ + "album_settings", + "custom_album_covers", + "artist_images", + "mixtape_collections", + "mixtape_collection_items", + ] { + if src_tables.contains(table) { + let n = copy_table(&src, &dst, table, Conflict::Ignore, None, &mut unmapped)?; + report.add(format!("library.db/{table}"), n); + } + } + } + + // Playlist folders keep their uuids; the playlist rows they hold are + // remapped below. + if src_tables.contains("playlist_folders") { + let n = copy_table( + &src, + &dst, + "playlist_folders", + Conflict::Ignore, + None, + &mut unmapped, + )?; + report.add("library.db/playlist_folders", n); + } + for table in LIBRARY_PLAYLIST_TABLES { + if !src_tables.contains(*table) { + continue; + } + let n = copy_table( + &src, + &dst, + table, + Conflict::Ignore, + Some(("qobuz_playlist_id", map)), + &mut unmapped, + )?; + report.add(format!("library.db/{table}"), n); + } + if fast_path && src_tables.contains("playlist_local_tracks") { + let n = copy_table( + &src, + &dst, + "playlist_local_tracks", + Conflict::Ignore, + Some(("qobuz_playlist_id", map)), + &mut unmapped, + )?; + report.add("library.db/playlist_local_tracks", n); + } + report.unmapped_playlist_rows += unmapped; + Ok(()) +} + +fn copy_pinned( + src_dir: &Path, + dst_dir: &Path, + map: &BTreeMap, + report: &mut LocalReport, +) -> Result<(), String> { + let src_path = src_dir.join("pinned_items.db"); + if !src_path.is_file() { + return Ok(()); + } + let src = open_ro(&src_path)?; + let dst = open_rw(&dst_dir.join("pinned_items.db"))?; + if columns(&dst, "pinned_items")?.is_empty() { + let ddl: String = src + .query_row( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'pinned_items'", + [], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + dst.execute_batch(&ddl).map_err(|e| e.to_string())?; + } + let mut read = src + .prepare("SELECT kind, id, title, subtitle, artwork_url, pinned_at FROM pinned_items") + .map_err(|e| e.to_string())?; + let mut write = dst + .prepare( + "INSERT OR IGNORE INTO pinned_items (kind, id, title, subtitle, artwork_url, pinned_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .map_err(|e| e.to_string())?; + let mut rows = read.query([]).map_err(|e| e.to_string())?; + let mut n = 0usize; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + let kind: String = row.get(0).map_err(|e| e.to_string())?; + let mut id: String = row.get(1).map_err(|e| e.to_string())?; + if kind == "playlist" { + match id.parse::().ok().and_then(|old| map.get(&old)) { + Some(new) => id = new.to_string(), + None => { + report.unmapped_playlist_rows += 1; + continue; + } + } + } + let title: String = row.get(2).map_err(|e| e.to_string())?; + let subtitle: Option = row.get(3).map_err(|e| e.to_string())?; + let artwork: Option = row.get(4).map_err(|e| e.to_string())?; + let pinned_at: i64 = row.get(5).map_err(|e| e.to_string())?; + n += write + .execute(rusqlite::params![ + kind, id, title, subtitle, artwork, pinned_at + ]) + .map_err(|e| e.to_string())?; + } + report.add("pinned_items.db/pinned_items", n); + Ok(()) +} + +/// `playlist_orders.json`: `{ "": [track ids] }` — remap the +/// keys, keep the destination's entry when it already has one. +fn copy_playlist_orders( + src_dir: &Path, + dst_dir: &Path, + map: &BTreeMap, + report: &mut LocalReport, +) -> Result<(), String> { + let src_path = src_dir.join("playlist_orders.json"); + if !src_path.is_file() { + return Ok(()); + } + let src: serde_json::Map = + serde_json::from_str(&std::fs::read_to_string(&src_path).map_err(|e| e.to_string())?) + .map_err(|e| format!("playlist_orders.json: {e}"))?; + let dst_path = dst_dir.join("playlist_orders.json"); + let mut dst: serde_json::Map = if dst_path.is_file() { + serde_json::from_str(&std::fs::read_to_string(&dst_path).map_err(|e| e.to_string())?) + .unwrap_or_default() + } else { + serde_json::Map::new() + }; + let mut n = 0usize; + for (old, order) in src { + let Some(new) = old.parse::().ok().and_then(|o| map.get(&o)) else { + report.unmapped_playlist_rows += 1; + continue; + }; + let key = new.to_string(); + if !dst.contains_key(&key) { + dst.insert(key, order); + n += 1; + } + } + if n > 0 { + std::fs::write( + &dst_path, + serde_json::to_string_pretty(&serde_json::Value::Object(dst)) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + } + report.add("playlist_orders.json", n); + Ok(()) +} + +/// Run the local copy. `map` is the ledger's `playlist_map` (source id → +/// target id; subscriptions map onto themselves). Each store is +/// best-effort: a failure is a note, the rest proceeds. +pub fn copy_profile( + src_dir: &Path, + dst_dir: &Path, + ledger: &Ledger, + options: LocalOptions, +) -> Result { + if !src_dir.is_dir() { + return Err(format!("{}: not a profile directory", src_dir.display())); + } + if src_dir == dst_dir { + return Err("source and destination are the same profile".into()); + } + std::fs::create_dir_all(dst_dir).map_err(|e| format!("{}: {e}", dst_dir.display()))?; + let map = &ledger.playlist_map; + let mut report = LocalReport::default(); + + if let Err(e) = copy_library(src_dir, dst_dir, map, &mut report) { + report.notes.push(format!("library.db: {e}")); + } + if let Err(e) = copy_pinned(src_dir, dst_dir, map, &mut report) { + report.notes.push(format!("pinned_items.db: {e}")); + } + if let Err(e) = copy_playlist_orders(src_dir, dst_dir, map, &mut report) { + report.notes.push(format!("playlist_orders.json: {e}")); + } + for file in PREF_STORES { + if let Err(e) = copy_store(src_dir, dst_dir, file, Conflict::Replace, &[], &mut report) { + report.notes.push(format!("{file}: {e}")); + } + } + // offline_settings.db: the single preference row only, never the + // scrobble queue or a pending playlist sync of another account. + if let Err(e) = copy_store( + src_dir, + dst_dir, + "offline_settings.db", + Conflict::Replace, + &["scrobble_queue", "pending_playlist_sync", "sqlite_sequence"], + &mut report, + ) { + report.notes.push(format!("offline_settings.db: {e}")); + } + for (file, skip) in COLLECTION_STORES { + if let Err(e) = copy_store(src_dir, dst_dir, file, Conflict::Ignore, skip, &mut report) { + report.notes.push(format!("{file}: {e}")); + } + } + if options.media_servers { + const CONNECTION_STORES: [(&str, &str, &[&str]); 2] = [ + ( + "media_servers.db", + "media_server_settings", + &[ + "enabled", + "base_url", + "server_name", + "server_id", + "username", + "token", + "password", + "selected_libraries", + ], + ), + ( + "plex_settings.db", + "plex_settings", + &[ + "enabled", + "base_url", + // Pre-profile schema used this name. + "server_url", + "token", + "selected_section_key", + "selected_section_keys", + "machine_id", + ], + ), + ]; + for (file, table, candidate_columns) in CONNECTION_STORES { + let found = match meaningful_connection_rows(src_dir, file, table, candidate_columns) { + Ok(found) => found, + Err(error) => { + report.notes.push(format!( + "{file}: could not inspect connection records: {error}" + )); + 0 + } + }; + report.media_connections_found += found; + match copy_store(src_dir, dst_dir, file, Conflict::Replace, &[], &mut report) { + Ok(()) => report.media_connections_copied += found, + Err(e) => report.notes.push(format!("{file}: {e}")), + } + } + } + if options.scrobblers { + if let Err(e) = copy_store( + src_dir, + dst_dir, + "scrobbler_settings.db", + Conflict::Replace, + &[], + &mut report, + ) { + report.notes.push(format!("scrobbler_settings.db: {e}")); + } + } + if options.listening_history { + for file in [ + "listen/listen_log.db", + "reco/events.db", + "external_reco_cache.db", + ] { + if let Err(e) = copy_store( + src_dir, + dst_dir, + file, + Conflict::Ignore, + &["sqlite_sequence"], + &mut report, + ) { + report.notes.push(format!("{file}: {e}")); + } + } + } + for rel in JSON_IF_ABSENT { + copy_file_if_absent(src_dir, dst_dir, rel, &mut report); + } + Ok(report) +} + +/// Profile directories under `/users/` other than `current`, +/// with their last-modified time, newest first. +pub fn other_profiles(data_root: &Path, current: u64) -> Vec<(u64, PathBuf)> { + let Ok(users) = std::fs::read_dir(data_root.join("users")) else { + return Vec::new(); + }; + let mut found: Vec<(u64, PathBuf, std::time::SystemTime)> = users + .flatten() + .filter_map(|entry| { + let uid = entry.file_name().to_str()?.parse::().ok()?; + if uid == current || uid == 0 || !entry.path().is_dir() { + return None; + } + let modified = entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + Some((uid, entry.path(), modified)) + }) + .collect(); + found.sort_by(|a, b| b.2.cmp(&a.2)); + found.into_iter().map(|(uid, p, _)| (uid, p)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn db(path: &Path, ddl: &str) -> Connection { + let c = Connection::open(path).unwrap(); + c.execute_batch(ddl).unwrap(); + c + } + + const LIBRARY_DDL: &str = " + CREATE TABLE library_folders (id INTEGER PRIMARY KEY, path TEXT UNIQUE NOT NULL, enabled INTEGER DEFAULT 1, last_scan INTEGER); + CREATE TABLE local_tracks (id INTEGER PRIMARY KEY, file_path TEXT NOT NULL, title TEXT NOT NULL, UNIQUE(file_path)); + CREATE TABLE playlist_folders (id TEXT PRIMARY KEY, name TEXT NOT NULL, position INTEGER DEFAULT 0); + CREATE TABLE playlist_settings (qobuz_playlist_id INTEGER PRIMARY KEY, folder_id TEXT, position INTEGER DEFAULT 0, is_favorite INTEGER DEFAULT 0); + CREATE TABLE playlist_local_tracks (id INTEGER PRIMARY KEY, qobuz_playlist_id INTEGER NOT NULL, local_track_id INTEGER NOT NULL, position INTEGER NOT NULL, UNIQUE(qobuz_playlist_id, local_track_id)); + CREATE TABLE downloaded_purchases (track_id INTEGER PRIMARY KEY, file_path TEXT NOT NULL); + "; + + fn ledger(pairs: &[(u64, u64)]) -> Ledger { + let mut l = Ledger::default(); + for (a, b) in pairs { + l.playlist_map.insert(*a, *b); + } + l + } + + #[test] + fn fast_path_copies_the_library_and_remaps_playlist_rows() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("10"); + let dst_dir = tmp.path().join("20"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&dst_dir).unwrap(); + let src = db(&src_dir.join("library.db"), LIBRARY_DDL); + src.execute_batch( + "INSERT INTO library_folders (id, path) VALUES (2, '/mnt/nas/music'), (4, '/home/u/Music'); + INSERT INTO local_tracks (id, file_path, title) VALUES (7, '/mnt/nas/music/a.flac', 'A'); + INSERT INTO playlist_folders (id, name, position) VALUES ('f1', 'Rock', 3); + INSERT INTO playlist_settings (qobuz_playlist_id, folder_id, position, is_favorite) VALUES (100, 'f1', 1, 1), (200, NULL, 2, 0); + INSERT INTO playlist_local_tracks (id, qobuz_playlist_id, local_track_id, position) VALUES (1, 100, 7, 0); + INSERT INTO downloaded_purchases (track_id, file_path) VALUES (9, '/x');", + ) + .unwrap(); + drop(src); + // The destination app created the schema but holds nothing yet. + db(&dst_dir.join("library.db"), LIBRARY_DDL); + + let report = copy_profile( + &src_dir, + &dst_dir, + &ledger(&[(100, 555)]), + LocalOptions::default(), + ) + .unwrap(); + assert!(!report.needs_rescan); + assert_eq!(report.copied["library.db/library_folders"], 2); + assert_eq!(report.copied["library.db/local_tracks"], 1); + assert_eq!(report.copied["library.db/playlist_folders"], 1); + assert_eq!(report.copied["library.db/playlist_settings"], 1); + assert_eq!(report.copied["library.db/playlist_local_tracks"], 1); + // 200 had no mapping: skipped and counted. + assert_eq!(report.unmapped_playlist_rows, 1); + + let dst = Connection::open(dst_dir.join("library.db")).unwrap(); + let mapped: (i64, String, i64) = dst + .query_row( + "SELECT qobuz_playlist_id, folder_id, is_favorite FROM playlist_settings", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(mapped, (555, "f1".into(), 1)); + let link: i64 = dst + .query_row( + "SELECT qobuz_playlist_id FROM playlist_local_tracks", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(link, 555); + // Purchases belong to the account: never copied. + let purchases: i64 = dst + .query_row("SELECT COUNT(*) FROM downloaded_purchases", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(purchases, 0); + // Source untouched (still 2 folders, opened read-only). + let src = Connection::open(src_dir.join("library.db")).unwrap(); + assert_eq!(library_folder_count(&src), 2); + } + + #[test] + fn slow_path_adds_missing_folders_only_and_flags_a_rescan() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("10"); + let dst_dir = tmp.path().join("20"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&dst_dir).unwrap(); + let src = db(&src_dir.join("library.db"), LIBRARY_DDL); + src.execute_batch( + "INSERT INTO library_folders (id, path) VALUES (1, '/a'), (2, '/b'); + INSERT INTO local_tracks (id, file_path, title) VALUES (7, '/a/x.flac', 'X');", + ) + .unwrap(); + drop(src); + let dst = db(&dst_dir.join("library.db"), LIBRARY_DDL); + dst.execute_batch("INSERT INTO library_folders (id, path) VALUES (9, '/b');") + .unwrap(); + drop(dst); + + let report = copy_profile( + &src_dir, + &dst_dir, + &Ledger::default(), + LocalOptions::default(), + ) + .unwrap(); + assert!(report.needs_rescan); + // '/b' existed (UNIQUE path): only '/a' was added; tracks skipped. + assert_eq!(report.copied["library.db/library_folders"], 1); + assert!(!report.copied.contains_key("library.db/local_tracks")); + let dst = Connection::open(dst_dir.join("library.db")).unwrap(); + assert_eq!(library_folder_count(&dst), 2); + } + + #[test] + fn preferences_replace_collections_merge_and_options_gate_credentials() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("10"); + let dst_dir = tmp.path().join("20"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&dst_dir).unwrap(); + let prefs_ddl = "CREATE TABLE tray_settings (id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0);"; + db(&src_dir.join("tray_settings.db"), prefs_ddl) + .execute_batch("INSERT INTO tray_settings (id, enabled) VALUES (1, 1);") + .unwrap(); + db(&dst_dir.join("tray_settings.db"), prefs_ddl) + .execute_batch("INSERT INTO tray_settings (id, enabled) VALUES (1, 0);") + .unwrap(); + let bl_ddl = "CREATE TABLE artist_blacklist (artist_id INTEGER PRIMARY KEY, artist_name TEXT NOT NULL); + CREATE TABLE blacklist_settings (id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 1);"; + db(&src_dir.join("artist_blacklist.db"), bl_ddl) + .execute_batch("INSERT INTO artist_blacklist VALUES (1, 'A'), (2, 'B'); INSERT INTO blacklist_settings VALUES (1, 0);") + .unwrap(); + db(&dst_dir.join("artist_blacklist.db"), bl_ddl) + .execute_batch("INSERT INTO artist_blacklist VALUES (2, 'B mine'); INSERT INTO blacklist_settings VALUES (1, 1);") + .unwrap(); + let ms_ddl = "CREATE TABLE media_server_settings (server TEXT PRIMARY KEY, token TEXT NOT NULL DEFAULT '');"; + db(&src_dir.join("media_servers.db"), ms_ddl) + .execute_batch( + "INSERT INTO media_server_settings VALUES ('jellyfin', 'fixture-token');", + ) + .unwrap(); + let plex_ddl = "CREATE TABLE plex_settings (id INTEGER PRIMARY KEY CHECK (id = 1), server_url TEXT NOT NULL DEFAULT '', token TEXT NOT NULL DEFAULT '');"; + db(&src_dir.join("plex_settings.db"), plex_ddl) + .execute_batch( + "INSERT INTO plex_settings VALUES (1, 'http://plex.test', 'fixture-plex-token');", + ) + .unwrap(); + std::fs::write(src_dir.join("lyrics_prefs.json"), "{\"font\":\"x\"}").unwrap(); + + let report = copy_profile( + &src_dir, + &dst_dir, + &Ledger::default(), + LocalOptions { + media_servers: false, + ..LocalOptions::default() + }, + ) + .unwrap(); + // Preference row: the source wins. + let tray: i64 = Connection::open(dst_dir.join("tray_settings.db")) + .unwrap() + .query_row("SELECT enabled FROM tray_settings", [], |r| r.get(0)) + .unwrap(); + assert_eq!(tray, 1); + // Blacklist: additive, the destination's row and its flag kept. + let bl = Connection::open(dst_dir.join("artist_blacklist.db")).unwrap(); + let name: String = bl + .query_row( + "SELECT artist_name FROM artist_blacklist WHERE artist_id = 2", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(name, "B mine"); + let count: i64 = bl + .query_row("SELECT COUNT(*) FROM artist_blacklist", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 2); + let flag: i64 = bl + .query_row("SELECT enabled FROM blacklist_settings", [], |r| r.get(0)) + .unwrap(); + assert_eq!(flag, 1); + // Media servers were opted out: nothing written. + assert!(!dst_dir.join("media_servers.db").exists()); + assert!(!dst_dir.join("plex_settings.db").exists()); + // JSON sidecar copied because absent. + assert_eq!(report.copied["lyrics_prefs.json"], 1); + assert!(dst_dir.join("lyrics_prefs.json").is_file()); + // Never-copied stores are not even created. + assert!(!dst_dir.join("favorites_cache.db").exists()); + assert!(!dst_dir.join("subscription_state.db").exists()); + + // A later repair run with the option enabled must still copy both + // server stores, including credentials. This is the local-only rerun + // the UI now allows after the cloud plan reaches zero changes. + let repair = copy_profile( + &src_dir, + &dst_dir, + &Ledger::default(), + LocalOptions::default(), + ) + .unwrap(); + assert_eq!(repair.media_connections_found, 2); + assert_eq!(repair.media_connections_copied, 2); + let media_token: String = Connection::open(dst_dir.join("media_servers.db")) + .unwrap() + .query_row( + "SELECT token FROM media_server_settings WHERE server = 'jellyfin'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(media_token, "fixture-token"); + let plex: (String, String) = Connection::open(dst_dir.join("plex_settings.db")) + .unwrap() + .query_row("SELECT server_url, token FROM plex_settings", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert_eq!( + plex, + ("http://plex.test".into(), "fixture-plex-token".into()) + ); + } + + #[test] + fn empty_media_placeholders_are_not_reported_as_copied_connections() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("10"); + let dst_dir = tmp.path().join("20"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&dst_dir).unwrap(); + let media = "CREATE TABLE media_server_settings ( + server TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, + base_url TEXT NOT NULL DEFAULT '', token TEXT NOT NULL DEFAULT '', + username TEXT NOT NULL DEFAULT '', password TEXT NOT NULL DEFAULT '', + selected_libraries TEXT NOT NULL DEFAULT '');"; + db(&src_dir.join("media_servers.db"), media) + .execute_batch( + "INSERT INTO media_server_settings (server) VALUES ('jellyfin'), ('subsonic');", + ) + .unwrap(); + let plex = "CREATE TABLE plex_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0, + base_url TEXT NOT NULL DEFAULT '', token TEXT NOT NULL DEFAULT '', + selected_section_keys TEXT NOT NULL DEFAULT '[]');"; + db(&src_dir.join("plex_settings.db"), plex) + .execute_batch("INSERT INTO plex_settings (id) VALUES (1);") + .unwrap(); + + let report = copy_profile( + &src_dir, + &dst_dir, + &Ledger::default(), + LocalOptions::default(), + ) + .unwrap(); + + assert_eq!(report.media_connections_found, 0); + assert_eq!(report.media_connections_copied, 0); + } + + #[test] + fn pinned_playlists_and_orders_are_remapped() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("10"); + let dst_dir = tmp.path().join("20"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&dst_dir).unwrap(); + let ddl = "CREATE TABLE pinned_items (kind TEXT NOT NULL, id TEXT NOT NULL, title TEXT NOT NULL, subtitle TEXT, artwork_url TEXT, pinned_at INTEGER NOT NULL, PRIMARY KEY (kind, id));"; + db(&src_dir.join("pinned_items.db"), ddl) + .execute_batch( + "INSERT INTO pinned_items VALUES ('album', 'abc', 'Album', NULL, NULL, 1), ('playlist', '100', 'Mix', NULL, NULL, 2), ('playlist', '300', 'Lost', NULL, NULL, 3);", + ) + .unwrap(); + std::fs::write( + src_dir.join("playlist_orders.json"), + "{\"100\": [1, 2], \"300\": [3]}", + ) + .unwrap(); + let report = copy_profile( + &src_dir, + &dst_dir, + &ledger(&[(100, 555)]), + LocalOptions::default(), + ) + .unwrap(); + assert_eq!(report.copied["pinned_items.db/pinned_items"], 2); + assert_eq!(report.copied["playlist_orders.json"], 1); + assert_eq!(report.unmapped_playlist_rows, 2); + let ids: Vec = Connection::open(dst_dir.join("pinned_items.db")) + .unwrap() + .prepare("SELECT id FROM pinned_items ORDER BY pinned_at") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .map(Result::unwrap) + .collect(); + assert_eq!(ids, vec!["abc", "555"]); + let orders: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dst_dir.join("playlist_orders.json")).unwrap(), + ) + .unwrap(); + assert_eq!(orders["555"], serde_json::json!([1, 2])); + assert!(orders.get("100").is_none()); + } +} diff --git a/crates/qbz-account-migration/src/plan.rs b/crates/qbz-account-migration/src/plan.rs new file mode 100644 index 000000000..2ce3be6e5 --- /dev/null +++ b/crates/qbz-account-migration/src/plan.rs @@ -0,0 +1,420 @@ +//! The delta: what the target account is missing, given the source +//! snapshot, the target's live state and the ledger. Pure — no I/O — so +//! the rules are unit-tested here and the applier only executes. + +use std::collections::{HashMap, HashSet}; + +use serde::Serialize; + +use crate::ledger::Ledger; +use crate::snapshot::{AccountSnapshot, FavoriteItem, FavoriteKind, OwnedPlaylist}; + +/// One playlist the applier will act on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum PlaylistAction { + /// No playlist of that name on the target: create it and fill it. + Create { + source_id: u64, + name: String, + description: Option, + is_public: bool, + track_ids: Vec, + }, + /// A target playlist of the same name holds nothing the source lacks: + /// it is the same playlist. Append what it is missing, in source order. + Merge { + source_id: u64, + target_id: u64, + name: String, + missing_track_ids: Vec, + }, + /// A target playlist of the same name holds tracks the source does not: + /// a different playlist. Create a copy with a suffix rather than mix. + CreateCopy { + source_id: u64, + name: String, + description: Option, + is_public: bool, + track_ids: Vec, + }, +} + +impl PlaylistAction { + pub fn source_id(&self) -> u64 { + match self { + PlaylistAction::Create { source_id, .. } + | PlaylistAction::Merge { source_id, .. } + | PlaylistAction::CreateCopy { source_id, .. } => *source_id, + } + } + + pub fn name(&self) -> &str { + match self { + PlaylistAction::Create { name, .. } + | PlaylistAction::Merge { name, .. } + | PlaylistAction::CreateCopy { name, .. } => name, + } + } + + /// Tracks this action will write. + pub fn track_count(&self) -> usize { + match self { + PlaylistAction::Create { track_ids, .. } + | PlaylistAction::CreateCopy { track_ids, .. } => track_ids.len(), + PlaylistAction::Merge { + missing_track_ids, .. + } => missing_track_ids.len(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct FavoritesPlan { + pub kind_counts: Vec<(FavoriteKind, usize)>, + /// Per kind, in write order (oldest favorite first). + pub to_add: Vec<(FavoriteKind, Vec)>, + /// Already on the target (or in the ledger), per kind. + pub already: Vec<(FavoriteKind, usize)>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct CloudPlan { + pub favorites: FavoritesPlan, + pub playlists: Vec, + /// Owned source playlists that need nothing (fully present already). + pub playlists_already: usize, + pub subscriptions: Vec, + pub subscriptions_already: usize, +} + +impl CloudPlan { + pub fn favorites_to_add(&self) -> usize { + self.favorites.to_add.iter().map(|(_, v)| v.len()).sum() + } + + pub fn is_empty(&self) -> bool { + self.favorites_to_add() == 0 && self.playlists.is_empty() && self.subscriptions.is_empty() + } +} + +/// The suffix a copy gets when the target already has a DIFFERENT +/// playlist of the same name. +pub const COPY_SUFFIX: &str = " (migrated)"; + +/// Write order for favorites: oldest first, so the target's own +/// `favorited_at` stamps keep the source's relative chronology. With no +/// stamp, the server listing is newest-first, so the fallback is the +/// reverse of the listing order. +fn chronological(mut items: Vec) -> Vec { + let all_stamped = items.iter().all(|f| f.favorited_at.is_some()); + if all_stamped { + items.sort_by_key(|f| { + ( + f.favorited_at.unwrap_or(0), + std::cmp::Reverse(f.server_index), + ) + }); + } else { + items.sort_by_key(|f| std::cmp::Reverse(f.server_index)); + } + items +} + +fn normalized(name: &str) -> String { + name.trim().to_lowercase() +} + +pub fn plan(source: &AccountSnapshot, target: &AccountSnapshot, ledger: &Ledger) -> CloudPlan { + let mut plan = CloudPlan::default(); + + // --- Favorites ------------------------------------------------------- + for kind in FavoriteKind::ALL { + let have = target.favorites.ids(kind); + let mut add = Vec::new(); + let mut already = 0usize; + for item in source.favorites.of(kind) { + if have.contains(item.id.as_str()) || ledger.favorite_done(kind.plural(), &item.id) { + already += 1; + } else { + add.push(item.clone()); + } + } + let add = chronological(add); + plan.favorites + .kind_counts + .push((kind, source.favorites.of(kind).len())); + plan.favorites.already.push((kind, already)); + plan.favorites.to_add.push((kind, add)); + } + + // --- Owned playlists --------------------------------------------------- + let target_by_id: HashMap = + target.playlists.iter().map(|p| (p.id, p)).collect(); + let mut target_by_name: HashMap> = HashMap::new(); + for p in &target.playlists { + target_by_name + .entry(normalized(&p.name)) + .or_default() + .push(p); + } + for src in &source.playlists { + let src_set: HashSet = src.track_ids.iter().copied().collect(); + // An earlier run already mapped it: keep syncing the SAME target. + if let Some(mapped) = ledger.playlist_map.get(&src.id) { + if let Some(tgt) = target_by_id.get(mapped) { + let tgt_set: HashSet = tgt.track_ids.iter().copied().collect(); + let missing: Vec = src + .track_ids + .iter() + .copied() + .filter(|id| !tgt_set.contains(id)) + .collect(); + if missing.is_empty() { + plan.playlists_already += 1; + } else { + plan.playlists.push(PlaylistAction::Merge { + source_id: src.id, + target_id: tgt.id, + name: src.name.clone(), + missing_track_ids: missing, + }); + } + continue; + } + // The mapped playlist is gone from the target: fall through and + // treat the source playlist as new. + } + let same_name = target_by_name + .get(&normalized(&src.name)) + .map(Vec::as_slice) + .unwrap_or(&[]); + // §9.3: same name and nothing foreign in the target copy = the same + // playlist (possibly behind); anything foreign = a different one. + let twin = same_name + .iter() + .find(|tgt| tgt.track_ids.iter().all(|id| src_set.contains(id))); + match twin { + Some(tgt) => { + let tgt_set: HashSet = tgt.track_ids.iter().copied().collect(); + let missing: Vec = src + .track_ids + .iter() + .copied() + .filter(|id| !tgt_set.contains(id)) + .collect(); + if missing.is_empty() { + plan.playlists_already += 1; + } else { + plan.playlists.push(PlaylistAction::Merge { + source_id: src.id, + target_id: tgt.id, + name: src.name.clone(), + missing_track_ids: missing, + }); + } + } + None if same_name.is_empty() => plan.playlists.push(PlaylistAction::Create { + source_id: src.id, + name: src.name.clone(), + description: src.description.clone(), + is_public: src.is_public, + track_ids: src.track_ids.clone(), + }), + None => plan.playlists.push(PlaylistAction::CreateCopy { + source_id: src.id, + name: format!("{}{}", src.name.trim(), COPY_SUFFIX), + description: src.description.clone(), + is_public: src.is_public, + track_ids: src.track_ids.clone(), + }), + } + } + + // --- Subscriptions ----------------------------------------------------- + let target_has: HashSet = target + .subscribed_ids() + .union(&target.owned_ids()) + .copied() + .collect(); + for sub in &source.subscriptions { + if target_has.contains(&sub.id) || ledger.subscriptions_done.contains(&sub.id) { + plan.subscriptions_already += 1; + } else { + plan.subscriptions.push(sub.id); + } + } + + plan +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::snapshot::SubscribedPlaylist; + + fn fav(id: &str, at: Option, idx: usize) -> FavoriteItem { + FavoriteItem { + id: id.into(), + favorited_at: at, + title: id.into(), + server_index: idx, + } + } + + fn owned(id: u64, name: &str, tracks: &[u64]) -> OwnedPlaylist { + OwnedPlaylist { + id, + name: name.into(), + description: None, + is_public: false, + track_ids: tracks.to_vec(), + } + } + + fn snap() -> AccountSnapshot { + AccountSnapshot::empty(1, "src") + } + + #[test] + fn favorites_are_a_delta_written_oldest_first() { + let mut source = snap(); + source.favorites.albums = vec![ + fav("new", Some(300), 0), + fav("mid", Some(200), 1), + fav("old", Some(100), 2), + fav("have", Some(50), 3), + ]; + let mut target = snap(); + target.favorites.albums = vec![fav("have", None, 0)]; + let p = plan(&source, &target, &Ledger::default()); + let albums = &p.favorites.to_add[0].1; + let ids: Vec<&str> = albums.iter().map(|f| f.id.as_str()).collect(); + assert_eq!(ids, vec!["old", "mid", "new"]); + assert_eq!(p.favorites.already[0], (FavoriteKind::Albums, 1)); + } + + #[test] + fn favorites_without_stamps_reverse_the_server_listing() { + let mut source = snap(); + source.favorites.tracks = vec![fav("newest", None, 0), fav("older", None, 1)]; + let p = plan(&source, &snap(), &Ledger::default()); + let ids: Vec<&str> = p.favorites.to_add[1] + .1 + .iter() + .map(|f| f.id.as_str()) + .collect(); + assert_eq!(ids, vec!["older", "newest"]); + } + + #[test] + fn ledger_entries_count_as_already_there() { + let mut source = snap(); + source.favorites.artists = vec![fav("7", None, 0)]; + let mut ledger = Ledger::default(); + ledger.mark_favorite("artists", "7"); + let p = plan(&source, &snap(), &ledger); + assert_eq!(p.favorites_to_add(), 0); + assert!(p.is_empty()); + } + + #[test] + fn playlist_rules_create_merge_and_copy() { + let mut source = snap(); + source.playlists = vec![ + owned(10, "Road Trip", &[1, 2, 3]), + owned(11, "Sleep", &[4, 5]), + owned(12, "Metal", &[6, 7]), + owned(13, "Done", &[8]), + ]; + let mut target = snap(); + target.playlists = vec![ + // same name, subset of the source: the same playlist, behind + owned(100, "road trip ", &[1, 2]), + // same name, holds a foreign track: a different playlist + owned(101, "Metal", &[6, 99]), + // same name, identical: nothing to do + owned(102, "Done", &[8]), + ]; + let p = plan(&source, &target, &Ledger::default()); + assert_eq!(p.playlists_already, 1); + assert_eq!(p.playlists.len(), 3); + assert_eq!( + p.playlists[0], + PlaylistAction::Merge { + source_id: 10, + target_id: 100, + name: "Road Trip".into(), + missing_track_ids: vec![3], + } + ); + assert!(matches!( + &p.playlists[1], + PlaylistAction::Create { source_id: 11, .. } + )); + match &p.playlists[2] { + PlaylistAction::CreateCopy { + name, track_ids, .. + } => { + assert_eq!(name, "Metal (migrated)"); + assert_eq!(track_ids, &vec![6, 7]); + } + other => panic!("expected a copy, got {other:?}"), + } + } + + #[test] + fn a_mapped_playlist_keeps_syncing_its_target_even_under_another_name() { + let mut source = snap(); + source.playlists = vec![owned(10, "Renamed since", &[1, 2, 3])]; + let mut target = snap(); + target.playlists = vec![owned(500, "Old name", &[1])]; + let mut ledger = Ledger::default(); + ledger.playlist_map.insert(10, 500); + let p = plan(&source, &target, &ledger); + assert_eq!( + p.playlists, + vec![PlaylistAction::Merge { + source_id: 10, + target_id: 500, + name: "Renamed since".into(), + missing_track_ids: vec![2, 3], + }] + ); + // Mapped target deleted on Qobuz: treated as new again. + target.playlists.clear(); + let p = plan(&source, &target, &ledger); + assert!(matches!(&p.playlists[0], PlaylistAction::Create { .. })); + } + + #[test] + fn subscriptions_skip_what_the_target_already_follows_or_owns() { + let mut source = snap(); + source.subscriptions = vec![ + SubscribedPlaylist { + id: 1, + name: "a".into(), + owner_id: 9, + }, + SubscribedPlaylist { + id: 2, + name: "b".into(), + owner_id: 9, + }, + SubscribedPlaylist { + id: 3, + name: "c".into(), + owner_id: 9, + }, + ]; + let mut target = snap(); + target.subscriptions = vec![SubscribedPlaylist { + id: 1, + name: "a".into(), + owner_id: 9, + }]; + target.playlists = vec![owned(2, "b", &[])]; + let p = plan(&source, &target, &Ledger::default()); + assert_eq!(p.subscriptions, vec![3]); + assert_eq!(p.subscriptions_already, 2); + } +} diff --git a/crates/qbz-account-migration/src/sink.rs b/crates/qbz-account-migration/src/sink.rs new file mode 100644 index 000000000..31c7a3205 --- /dev/null +++ b/crates/qbz-account-migration/src/sink.rs @@ -0,0 +1,43 @@ +//! Frontend-agnostic progress reporting (the playlist importer's shape). + +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationPhase { + ReadingSource, + ReadingTarget, + Favorites, + Playlists, + Subscriptions, + Done, +} + +#[derive(Debug, Clone, Serialize)] +pub enum MigrationEvent { + Phase(MigrationPhase), + /// `done` of `total` units in the current phase, with a short label for + /// the status line (a playlist name, a favorites kind). + Progress { + done: usize, + total: usize, + label: String, + }, +} + +pub trait MigrationSink: Send + Sync { + fn emit(&self, event: MigrationEvent); +} + +impl MigrationSink for F { + fn emit(&self, event: MigrationEvent) { + self(event) + } +} + +/// A sink that drops everything (tests, the CLI's quiet mode). +pub struct NullSink; + +impl MigrationSink for NullSink { + fn emit(&self, _event: MigrationEvent) {} +} diff --git a/crates/qbz-account-migration/src/snapshot.rs b/crates/qbz-account-migration/src/snapshot.rs new file mode 100644 index 000000000..e89a3f453 --- /dev/null +++ b/crates/qbz-account-migration/src/snapshot.rs @@ -0,0 +1,496 @@ +//! The account snapshot: everything the migration moves, captured while +//! signed in with that account, as one JSON file. +//! +//! The same capture serves both sides: taken on the SOURCE it is the +//! snapshot the user keeps; taken on the TARGET (in memory, right before +//! applying) it is the live state the delta is computed against. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::MigrationApi; +use crate::sink::{MigrationEvent, MigrationPhase, MigrationSink}; + +pub const KIND: &str = "qbz-account-snapshot"; +pub const SCHEMA_VERSION: u32 = 1; + +/// Favorites page size and the per-kind cap, the same policy as the +/// Library's reader (`library_qt::fetch_favorites`). +const PAGE_SIZE: u32 = 500; +const MAX_ITEMS: usize = 10_000; + +/// The five favorite kinds Qobuz keeps, in the order they are migrated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FavoriteKind { + Albums, + Tracks, + Artists, + Labels, + Awards, +} + +impl FavoriteKind { + pub const ALL: [FavoriteKind; 5] = [ + FavoriteKind::Albums, + FavoriteKind::Tracks, + FavoriteKind::Artists, + FavoriteKind::Labels, + FavoriteKind::Awards, + ]; + + /// The `type` query value of `getUserFavorites` and the envelope key. + pub fn plural(self) -> &'static str { + match self { + FavoriteKind::Albums => "albums", + FavoriteKind::Tracks => "tracks", + FavoriteKind::Artists => "artists", + FavoriteKind::Labels => "labels", + FavoriteKind::Awards => "awards", + } + } + + /// The `_ids` key of `favorite/create`. + pub fn singular(self) -> &'static str { + match self { + FavoriteKind::Albums => "album", + FavoriteKind::Tracks => "track", + FavoriteKind::Artists => "artist", + FavoriteKind::Labels => "label", + FavoriteKind::Awards => "award", + } + } +} + +/// One favorite. `id` is a string for every kind because album ids are +/// alphanumeric; numeric ids round-trip through their decimal form. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FavoriteItem { + pub id: String, + /// Unix seconds when the account favorited it, when Qobuz sends it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub favorited_at: Option, + /// A display name for reports: album/track title, artist/label name. + #[serde(default)] + pub title: String, + /// The position in the server's listing (newest first), the fallback + /// ordering when `favorited_at` is absent. + #[serde(default)] + pub server_index: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Favorites { + #[serde(default)] + pub albums: Vec, + #[serde(default)] + pub tracks: Vec, + #[serde(default)] + pub artists: Vec, + #[serde(default)] + pub labels: Vec, + #[serde(default)] + pub awards: Vec, +} + +impl Favorites { + pub fn of(&self, kind: FavoriteKind) -> &[FavoriteItem] { + match kind { + FavoriteKind::Albums => &self.albums, + FavoriteKind::Tracks => &self.tracks, + FavoriteKind::Artists => &self.artists, + FavoriteKind::Labels => &self.labels, + FavoriteKind::Awards => &self.awards, + } + } + + pub fn of_mut(&mut self, kind: FavoriteKind) -> &mut Vec { + match kind { + FavoriteKind::Albums => &mut self.albums, + FavoriteKind::Tracks => &mut self.tracks, + FavoriteKind::Artists => &mut self.artists, + FavoriteKind::Labels => &mut self.labels, + FavoriteKind::Awards => &mut self.awards, + } + } + + pub fn ids(&self, kind: FavoriteKind) -> HashSet<&str> { + self.of(kind).iter().map(|f| f.id.as_str()).collect() + } + + pub fn total(&self) -> usize { + FavoriteKind::ALL.iter().map(|k| self.of(*k).len()).sum() + } +} + +/// A playlist the account OWNS, with its track ids in order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OwnedPlaylist { + pub id: u64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub is_public: bool, + #[serde(default)] + pub track_ids: Vec, +} + +/// A playlist the account FOLLOWS (someone else's). Same id on every +/// account, so it is re-subscribed, never copied. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubscribedPlaylist { + pub id: u64, + pub name: String, + #[serde(default)] + pub owner_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SnapshotSource { + pub user_id: u64, + #[serde(default)] + pub display_name: String, + /// Stored only to help the owner identify bundles belonging to accounts + /// with similar display names. Older snapshots deserialize without it. + #[serde(default)] + pub email: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountSnapshot { + pub kind: String, + pub schema_version: u32, + /// RFC 3339. + pub created_at: String, + pub source: SnapshotSource, + #[serde(default)] + pub favorites: Favorites, + #[serde(default)] + pub playlists: Vec, + #[serde(default)] + pub subscriptions: Vec, +} + +impl AccountSnapshot { + pub fn empty(user_id: u64, display_name: &str) -> Self { + Self { + kind: KIND.to_string(), + schema_version: SCHEMA_VERSION, + created_at: chrono::Utc::now().to_rfc3339(), + source: SnapshotSource { + user_id, + display_name: display_name.to_string(), + email: String::new(), + }, + favorites: Favorites::default(), + playlists: Vec::new(), + subscriptions: Vec::new(), + } + } + + pub fn parse(text: &str) -> Result { + let snap: AccountSnapshot = + serde_json::from_str(text).map_err(|e| format!("not a QBZ account snapshot: {e}"))?; + if snap.kind != KIND { + return Err(format!("not a QBZ account snapshot (kind `{}`)", snap.kind)); + } + if snap.schema_version > SCHEMA_VERSION { + return Err(format!( + "snapshot schema {} is newer than this QBZ understands ({})", + snap.schema_version, SCHEMA_VERSION + )); + } + Ok(snap) + } + + pub fn read(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + Self::parse(&text) + } + + pub fn write(&self, path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; + std::fs::write(path, json).map_err(|e| format!("{}: {e}", path.display())) + } + + /// `created_at` in the machine's local time, `YYYY-MM-DD HH:MM`, for + /// listings; the raw RFC 3339 string when it does not parse. + pub fn created_at_local(&self) -> String { + chrono::DateTime::parse_from_rfc3339(&self.created_at) + .map(|t| { + t.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M") + .to_string() + }) + .unwrap_or_else(|_| self.created_at.clone()) + } + + pub fn owned_ids(&self) -> HashSet { + self.playlists.iter().map(|p| p.id).collect() + } + + pub fn subscribed_ids(&self) -> HashSet { + self.subscriptions.iter().map(|p| p.id).collect() + } +} + +/// Where snapshots live inside a profile directory (`users//`). +pub const SNAPSHOT_DIR: &str = "account_migration"; + +/// `/account_migration/snapshot-YYYYMMDD-HHMMSS.json`. +pub fn snapshot_path(profile_dir: &Path) -> PathBuf { + profile_dir.join(SNAPSHOT_DIR).join(format!( + "snapshot-{}.json", + chrono::Utc::now().format("%Y%m%d-%H%M%S") + )) +} + +fn id_string(v: Option<&Value>) -> Option { + match v? { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +fn item_title(item: &Value) -> String { + for key in ["title", "name"] { + if let Some(s) = item.get(key).and_then(Value::as_str) { + return s.to_string(); + } + } + String::new() +} + +/// Page one favorites kind until exhausted (short page, `total` reached, +/// or the cap), preserving the server order in `server_index`. +async fn fetch_favorites( + api: &A, + kind: FavoriteKind, +) -> Result, String> { + let mut items: Vec = Vec::new(); + let mut offset = 0u32; + loop { + let value = api.favorites_page(kind.plural(), PAGE_SIZE, offset).await?; + let branch = value.get(kind.plural()); + let total = branch + .and_then(|b| b.get("total")) + .and_then(Value::as_u64) + .unwrap_or(0) as usize; + let page: Vec = branch + .and_then(|b| b.get("items")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let page_len = page.len(); + for item in page { + let Some(id) = id_string(item.get("id")) else { + continue; + }; + items.push(FavoriteItem { + id, + favorited_at: item.get("favorited_at").and_then(Value::as_i64), + title: item_title(&item), + server_index: items.len(), + }); + } + offset += page_len as u32; + if page_len < PAGE_SIZE as usize + || (total > 0 && offset as usize >= total) + || items.len() >= MAX_ITEMS + { + break; + } + } + Ok(items) +} + +/// Capture the active session's account. `user_id` splits owned playlists +/// from followed ones (`owner.id == user_id`). A favorites kind that fails +/// (labels have 400'd for months at times) is logged and left empty; a +/// playlist whose track ids cannot be read is skipped with a log line — +/// the snapshot says what it holds, never guesses. +pub async fn capture( + api: &A, + user_id: u64, + display_name: &str, + email: &str, + sink: &S, +) -> Result { + sink.emit(MigrationEvent::Phase(MigrationPhase::ReadingSource)); + let mut snap = AccountSnapshot::empty(user_id, display_name); + snap.source.email = email.to_string(); + for (i, kind) in FavoriteKind::ALL.iter().enumerate() { + sink.emit(MigrationEvent::Progress { + done: i, + total: FavoriteKind::ALL.len(), + label: kind.plural().to_string(), + }); + match fetch_favorites(api, *kind).await { + Ok(items) => *snap.favorites.of_mut(*kind) = items, + Err(e) => log::warn!( + "[account-migration] favorites {} unreadable: {e}", + kind.plural() + ), + } + } + let playlists = api.user_playlists().await?; + let total = playlists.len(); + for (i, playlist) in playlists.into_iter().enumerate() { + sink.emit(MigrationEvent::Progress { + done: i, + total, + label: playlist.name.clone(), + }); + if playlist.owner.id == user_id { + match api.playlist_track_ids(playlist.id).await { + Ok(with_ids) => snap.playlists.push(OwnedPlaylist { + id: playlist.id, + name: playlist.name, + description: playlist.description, + is_public: playlist.is_public, + track_ids: with_ids.track_ids, + }), + Err(e) => log::warn!( + "[account-migration] playlist {} ({}) unreadable, skipped: {e}", + playlist.id, + playlist.name + ), + } + } else { + snap.subscriptions.push(SubscribedPlaylist { + id: playlist.id, + name: playlist.name, + owner_id: playlist.owner.id, + }); + } + } + Ok(snap) +} + +/// Every snapshot file under `/users/*/account_migration/`, +/// newest first, with the profile (uid) it was found in. +pub fn list_snapshots(data_root: &Path) -> Vec<(u64, PathBuf)> { + let mut found: Vec<(u64, PathBuf, std::time::SystemTime)> = Vec::new(); + let Ok(users) = std::fs::read_dir(data_root.join("users")) else { + return Vec::new(); + }; + for user in users.flatten() { + let Some(uid) = user + .file_name() + .to_str() + .and_then(|s| s.parse::().ok()) + else { + continue; + }; + let Ok(files) = std::fs::read_dir(user.path().join(SNAPSHOT_DIR)) else { + continue; + }; + for file in files.flatten() { + let path = file.path(); + let is_snapshot = path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("snapshot-") && n.ends_with(".json")) + .unwrap_or(false); + if !is_snapshot { + continue; + } + let modified = file + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + found.push((uid, path, modified)); + } + } + found.sort_by(|a, b| b.2.cmp(&a.2)); + found + .into_iter() + .map(|(uid, path, _)| (uid, path)) + .collect() +} + +/// Delete one snapshot returned by [`list_snapshots`]. +/// +/// The exact-membership check is intentional: the UI passes a path as a +/// string, so accepting an arbitrary path (or merely checking its suffix) +/// would turn a cleanup button into a general file-delete primitive. This +/// removes only the snapshot bundle; the source profile and all of its other +/// user data remain untouched. +pub fn delete_snapshot(data_root: &Path, requested: &Path) -> Result<(), String> { + let allowed = list_snapshots(data_root) + .into_iter() + .any(|(_, path)| path == requested); + if !allowed { + return Err(format!( + "refusing to delete a path outside the migration snapshot list: {}", + requested.display() + )); + } + std::fs::remove_file(requested).map_err(|e| format!("{}: {e}", requested.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "qbz-account-migration-{label}-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )) + } + + #[test] + fn snapshots_created_before_email_identity_remain_readable() { + let snapshot = AccountSnapshot::empty(42, "Old account"); + let mut value = serde_json::to_value(snapshot).unwrap(); + value["source"].as_object_mut().unwrap().remove("email"); + + let parsed = AccountSnapshot::parse(&value.to_string()).unwrap(); + + assert_eq!(parsed.source.display_name, "Old account"); + assert_eq!(parsed.source.user_id, 42); + assert!(parsed.source.email.is_empty()); + } + + #[test] + fn delete_snapshot_removes_only_a_listed_bundle() { + let root = temp_root("delete"); + let dir = root.join("users/42").join(SNAPSHOT_DIR); + std::fs::create_dir_all(&dir).unwrap(); + let snapshot = dir.join("snapshot-20260902-120000.json"); + let keep = root.join("users/42/profile.db"); + std::fs::write(&snapshot, "{}").unwrap(); + std::fs::write(&keep, "profile").unwrap(); + + delete_snapshot(&root, &snapshot).unwrap(); + + assert!(!snapshot.exists()); + assert!(keep.exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn delete_snapshot_refuses_an_unlisted_path() { + let root = temp_root("refuse"); + let profile = root.join("users/42"); + std::fs::create_dir_all(&profile).unwrap(); + let keep = profile.join("profile.db"); + std::fs::write(&keep, "profile").unwrap(); + + let error = delete_snapshot(&root, &keep).unwrap_err(); + + assert!(error.contains("outside the migration snapshot list")); + assert!(keep.exists()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/qbz-app/Cargo.toml b/crates/qbz-app/Cargo.toml index fe80866c1..55746a70f 100644 --- a/crates/qbz-app/Cargo.toml +++ b/crates/qbz-app/Cargo.toml @@ -12,15 +12,23 @@ qbz-player = { path = "../qbz-player" } qbz-core = { path = "../qbz-core" } qbz-qobuz = { path = "../qbz-qobuz" } qbz-credentials = { path = "../qbz-credentials" } +qbz-offline-cache = { path = "../qbz-offline-cache" } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } log = { workspace = true } -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } dirs = "6" uuid = { version = "1", features = ["v4"] } chrono = { workspace = true } md-5 = { workspace = true } base64 = { workspace = true } rand = "0.10" + +# TLS provider selection +rustls = { version = "0.23", features = ["aws_lc_rs"] } + +[dev-dependencies] +# A temp user dir for the settings-store tests. +tempfile = "3" diff --git a/crates/qbz-app/src/device_cap.rs b/crates/qbz-app/src/device_cap.rs new file mode 100644 index 000000000..cd256d9b2 --- /dev/null +++ b/crates/qbz-app/src/device_cap.rs @@ -0,0 +1,360 @@ +//! Local output-device quality cap (#638 fix 3). +//! +//! Caches the local DAC's detected rate ceiling, mapped to a Qobuz tier, so +//! the request-time resolution (the frontend's `local_playback_quality`) can +//! clamp the streaming-quality preference without probing per call. Detection +//! is the proven read-only `qbz_audio::query_dac_capabilities` (reads +//! `/proc/asound` and shells out to `pw-dump`; never opens a stream), so a +//! refresh runs inside `spawn_blocking` on EXPLICIT triggers only — startup, +//! the Settings toggle, an output-device/backend change, reset-to-defaults, +//! the device refresh/release button — never on the playback hot path or the +//! poll tick. A stale cap after a hot-unplug (until the next device change), +//! and an uncapped first track when a session-restore play beats the startup +//! refresh, are the accepted trades — same class as the HiFi wizard's +//! behavior; both self-heal. +//! +//! PRECEDENCE (owner decision, #638): the cap of the device ACTUALLY PLAYING +//! governs. This cache is for LOCAL playback only — the cast path must never +//! read it (the local DAC is not in a cast's signal path). +//! +//! Frontend-agnostic by design: it lives here rather than in a binary crate +//! because that is exactly why the row never reached the Qt frontend. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::RwLock; + +use qbz_audio::backend::AudioBackendType; +use qbz_models::Quality; + +/// The cached cap. `tier` is the coarse Qobuz-tier mapping of the detected +/// ceiling (`max_rate_hz`); `detected` false = the probe fell back to the +/// common rate set, so the Settings caveat must disclose that the cap may +/// not match the hardware (owner Decision B: it still applies). +#[derive(Clone)] +pub struct CapState { + pub tier: Quality, + pub detected: bool, + pub max_rate_hz: u32, + pub description: String, +} + +/// None = the cap is disabled (toggle off) or not refreshed yet. +static CAP: RwLock> = RwLock::new(None); + +/// Refresh generation, and it is load-bearing rather than defensive. +/// +/// The triggers are independently spawned tasks and the probe is a +/// `spawn_blocking` that shells out to `pw-dump` — so two refreshes CAN be in +/// flight, and the slow one can land last. Without this counter the sequence +/// "toggle ON (slow probe) → user toggles OFF" ends with the OFF committing +/// first and the stale probe RESURRECTING a cap the settings say is disabled: +/// silently capped playback with the toggle visibly off, and nothing re-runs +/// until the next device change. Every write below commits only if it is still +/// the newest refresh. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// False until the first refresh of the process commits. It exists to stop a +/// catastrophe, not to be tidy: the cap cache starts empty, so at boot +/// `None -> Some(tier)` looks exactly like "the user just changed the cap", +/// and the invalidation that answer triggers is `clear_audio_cache()`, which +/// unlinks EVERY `.audio` file on disk (hundreds of MB to GBs). Without +/// this flag, every single launch with the toggle on would silently wipe the +/// whole playback cache and re-download everything. +static SEEDED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// What a refresh did — the audio-cache invalidation decision, made where the +/// state lives instead of by a before/after comparison at the call site (which +/// cannot tell a boot from a change, and misattributes a concurrent refresh's +/// commit to itself). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapChange { + /// Same tier as before, or this refresh was superseded by a newer one. + /// Nothing to invalidate. + Unchanged, + /// The effective request tier MOVED. The audio cache is quality-blind, so + /// bytes fetched under the old tier must not keep serving. + Changed, + /// The first refresh of the process learned the cap. It is not a change: + /// nothing was fetched under a different tier *by this process*, and the + /// previous session already invalidated on its own transitions. Known + /// residual: bytes cached under a session whose DAC had a HIGHER ceiling + /// survive a reboot onto a smaller DAC until any in-session trigger fires. + /// That narrow hole is worth far less than deleting the disk cache on + /// every launch. + Seeded, +} + +/// The classification, lifted out of the lock so it is testable. +fn classify(first_refresh: bool, before: Option, after: Option) -> CapChange { + if first_refresh { + CapChange::Seeded + } else if before == after { + CapChange::Unchanged + } else { + CapChange::Changed + } +} + +/// Commit a new cap under the write lock, refusing a superseded write. +/// +/// The generation is re-checked WHILE HOLDING the lock. Checking it before +/// doing the work left a TOCTOU window wide enough to drive the exact bug the +/// counter exists for through: a slow probe passes the check, the user toggles +/// the cap off and commits `None`, then the stale probe writes its `Some` +/// back — a live cap with the toggle visibly off. +fn commit(next: Option, generation: u64) -> CapChange { + let mut guard = CAP.write().unwrap_or_else(|e| e.into_inner()); + if GENERATION.load(Ordering::SeqCst) != generation { + log::info!("[qbz] device cap: refresh superseded by a newer one, discarding"); + return CapChange::Unchanged; + } + let before = guard.as_ref().map(|c| c.tier); + let after = next.as_ref().map(|c| c.tier); + *guard = next; + classify(!SEEDED.swap(true, Ordering::SeqCst), before, after) +} + +/// Map a detected max rate to the tier we may REQUEST. Coarse by design: +/// Qobuz sells four discrete tiers, and the mapping must never name a tier +/// the device cannot play, because the tier is a CEILING the service is free +/// to deliver at: +/// +/// - Hi-Res+ can deliver up to 192 kHz, so it needs a 192 kHz device. A +/// 176.4 kHz ceiling resolves to Hi-Res, not Hi-Res+. +/// - Hi-Res can deliver up to 96 kHz, so it needs a 96 kHz device. An +/// 88.2 kHz ceiling resolves to CD, not Hi-Res. +/// - There is no 48 kHz tier, so anything below 96 kHz steps down to CD +/// 16/44.1 — bit depth included; the Settings summary says it plainly +/// instead of letting the user discover it. +/// +/// (Owner decision D4, 2026-08-17: these boundaries were the loose +/// `> 96_000` / `>= 88_200` pair, which handed an 88.2 kHz DAC a tier +/// requestable at 96 and a 176.4 kHz DAC one requestable at 192.) +fn tier_for_max_rate_hz(max_hz: u32) -> Quality { + if max_hz >= 192_000 { + Quality::UltraHiRes + } else if max_hz >= 96_000 { + Quality::HiRes + } else { + Quality::Lossless + } +} + +/// Cheap read for the request-time resolution: `(tier, detected)`. +/// None = no cap configured. Two uncontended lock reads — safe to call on +/// every play, which is what lets the cap live inside the play funnel. +pub fn cap() -> Option<(Quality, bool)> { + CAP.read() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(|c| (c.tier, c.detected)) +} + +/// The Settings "Detected device limit" value line: `(summary, detected)`, +/// e.g. `("192 kHz · Hi-Res+", true)`. Untranslated data composition — the +/// same convention as the quality badge's "24-bit / 96 kHz" (tier names are +/// product names). `("", true)` when no cap is active: the row hides on the +/// empty summary, and `true` keeps the fallback caveat from flashing before +/// the first refresh lands. +pub fn summary() -> (String, bool) { + match CAP.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + Some(c) => ( + format!( + "{} · {}", + rate_khz_label(c.max_rate_hz), + tier_display(c.tier) + ), + c.detected, + ), + None => (String::new(), true), + } +} + +/// Product-name tier label for the summary line. The CD entry spells out the +/// bit-depth cost (no 48 kHz tier exists, so the step below Hi-Res loses +/// depth too — say it, don't let the user discover it). +fn tier_display(tier: Quality) -> &'static str { + match tier { + Quality::UltraHiRes => "Hi-Res+", + Quality::HiRes => "Hi-Res", + Quality::Lossless => "CD 16-bit / 44.1 kHz", + // Unreachable from tier_for_max_rate_hz; total match for safety. + Quality::Mp3 => "MP3 320", + } +} + +/// "192 kHz" / "44.1 kHz" from Hz — integer when whole, one decimal +/// otherwise (matches the quality badge's rate formatting). +fn rate_khz_label(hz: u32) -> String { + let khz = hz as f64 / 1000.0; + if khz.fract().abs() < f64::EPSILON { + format!("{} kHz", khz as i64) + } else { + format!("{khz} kHz") + } +} + +/// Re-run detection and refresh the cache. `limit_enabled` off clears it +/// immediately (no probe). The probe runs in `spawn_blocking` (pw-dump +/// subprocess + /proc reads — never on the UI thread). Await-able so the +/// Settings controller can re-push the summary row right after. +/// +/// `backend` is the audio backend ACTUALLY in use (owner decision D7), and its +/// reach is narrower than it looks: it selects the backend that ENUMERATES the +/// default sink when `output_device` is None ("System default"). The probe +/// itself is PipeWire-backed regardless (`query_dac_capabilities` resolves the +/// description through PipeWire, then falls through to `/proc/asound`). `None` +/// means "no configured backend" and enumerates through PipeWire, the +/// historical behavior. +/// +/// Returns what changed, so the caller can decide about the audio cache +/// without re-deriving it from a racy before/after read. +pub async fn refresh( + limit_enabled: bool, + output_device: Option, + backend: Option, +) -> CapChange { + let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + if !limit_enabled { + log::info!("[qbz] device cap: disabled"); + return commit(None, generation); + } + let probed = tokio::task::spawn_blocking(move || { + // The configured device id, or the system-default sink when the + // selection is "System default" (None). An unresolvable default + // probes with an empty node name, which lands on the fallback set → + // detected=false → Hi-Res+ no-op cap with the caveat disclosed. + let node = output_device.unwrap_or_else(|| default_output_node(backend)); + qbz_audio::query_dac_capabilities(&node) + }) + .await; + let caps = match probed { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz] device cap: probe task failed: {e}"); + return CapChange::Unchanged; + } + }; + let max_rate_hz = caps.sample_rates.iter().copied().max().unwrap_or(0); + // assemble() always yields a non-empty rate list (fallback set), but + // never store a 0 Hz cap if that invariant ever breaks. + if max_rate_hz == 0 { + return commit(None, generation); + } + let state = CapState { + tier: tier_for_max_rate_hz(max_rate_hz), + detected: caps.detected, + max_rate_hz, + description: caps.description.unwrap_or_else(|| caps.node_name.clone()), + }; + log::info!( + "[qbz] device cap: {} -> max {} Hz -> {:?} ({})", + state.description, + state.max_rate_hz, + state.tier, + if state.detected { + "detected" + } else { + "fallback set" + }, + ); + commit(Some(state), generation) +} + +/// The default sink's node id for the "System default" device selection, +/// resolved through the backend that is actually playing (D7). Empty when +/// nothing enumerates — the probe then reports the fallback set honestly. +fn default_output_node(backend: Option) -> String { + qbz_audio::backend::BackendManager::create_backend( + backend.unwrap_or(AudioBackendType::PipeWire), + ) + .ok() + .and_then(|b| b.enumerate_devices().ok()) + .and_then(|devs| devs.into_iter().find(|d| d.is_default)) + .map(|d| d.id) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tier_mapping_never_names_a_tier_the_device_cannot_play() { + // >= 192 kHz → Hi-Res+ (no effective cap). + assert_eq!(tier_for_max_rate_hz(384_000), Quality::UltraHiRes); + assert_eq!(tier_for_max_rate_hz(192_000), Quality::UltraHiRes); + // 176.4 kHz → Hi-Res, NOT Hi-Res+ (D4): Hi-Res+ may be delivered at + // 192 kHz, which this device cannot play. + assert_eq!(tier_for_max_rate_hz(176_400), Quality::HiRes); + // 96 kHz → Hi-Res (its exact ceiling). + assert_eq!(tier_for_max_rate_hz(96_000), Quality::HiRes); + // 88.2 kHz → CD, NOT Hi-Res (D4): Hi-Res may be delivered at 96 kHz. + assert_eq!(tier_for_max_rate_hz(88_200), Quality::Lossless); + // <= 48 kHz → CD 16/44.1 (bit depth lost too — no 48 kHz tier). + assert_eq!(tier_for_max_rate_hz(48_000), Quality::Lossless); + assert_eq!(tier_for_max_rate_hz(44_100), Quality::Lossless); + } + + #[test] + fn rate_label_formats_whole_and_fractional_khz() { + assert_eq!(rate_khz_label(192_000), "192 kHz"); + assert_eq!(rate_khz_label(44_100), "44.1 kHz"); + assert_eq!(rate_khz_label(176_400), "176.4 kHz"); + } + + /// THE ONE THAT COST A DISK CACHE. `clear_audio_cache` unlinks every + /// `.audio` file on disk, and the cap cache starts empty — so the + /// first refresh of a process ALWAYS looks like `None -> Some(tier)`. + /// Classifying that as a change wiped the user's whole playback cache on + /// every launch with the toggle on, and logged it as legitimate. + #[test] + fn the_first_refresh_of_a_process_is_never_a_cache_invalidating_change() { + assert_eq!( + classify(true, None, Some(Quality::Lossless)), + CapChange::Seeded + ); + // Even a first refresh that lands on a DIFFERENT tier than some past + // session's is Seeded: this process fetched nothing under the old one. + assert_eq!( + classify(true, Some(Quality::UltraHiRes), Some(Quality::Lossless)), + CapChange::Seeded + ); + } + + /// ...but once seeded, `None -> Some` is the toggle being switched ON, + /// which is precisely when uncapped bytes must stop serving. Collapsing + /// this into the boot case would make the feature inert on every track + /// played before the toggle. + #[test] + fn turning_the_cap_on_after_boot_does_invalidate() { + assert_eq!( + classify(false, None, Some(Quality::Lossless)), + CapChange::Changed + ); + // And off again — bytes fetched under the cap are below what the + // preference now requests, so they must not keep serving either. + assert_eq!( + classify(false, Some(Quality::Lossless), None), + CapChange::Changed + ); + } + + #[test] + fn a_refresh_that_lands_on_the_same_tier_invalidates_nothing() { + assert_eq!( + classify(false, Some(Quality::HiRes), Some(Quality::HiRes)), + CapChange::Unchanged + ); + assert_eq!(classify(false, None, None), CapChange::Unchanged); + } + + /// The fallback rate set tops out at 192 kHz, so an UNDETECTED device is + /// capped in name only (contract D6: accept and document). This test + /// exists so the day someone tightens the fallback set, the consequence + /// for the "The cap still applies" copy is visible instead of implicit. + #[test] + fn an_undetected_device_caps_at_hi_res_plus_which_is_no_clamp() { + assert_eq!(tier_for_max_rate_hz(192_000), Quality::UltraHiRes); + } +} diff --git a/crates/qbz-app/src/diagnostics.rs b/crates/qbz-app/src/diagnostics.rs index 30f5de09a..4dfd5f46b 100644 --- a/crates/qbz-app/src/diagnostics.rs +++ b/crates/qbz-app/src/diagnostics.rs @@ -28,6 +28,14 @@ pub struct RuntimeDiagnostics { pub audio_preferred_sample_rate: Option, pub audio_alsa_plugin: Option, pub audio_alsa_hardware_volume: bool, + pub audio_alsa_hardware_volume_controls: std::collections::HashMap< + qbz_audio::alsa_hardware_volume::StableAlsaRouteKey, + qbz_audio::alsa_hardware_volume::AlsaMixerControlId, + >, + /// Full read-only inventory for the active direct route, including + /// rejected controls and their typed reasons. + pub audio_alsa_hardware_volume_probe: + Option, pub audio_normalization_enabled: bool, pub audio_normalization_target_lufs: f32, pub audio_gapless_enabled: bool, @@ -121,6 +129,16 @@ pub fn runtime_diagnostics(i: &DiagnosticsInputs<'_>) -> RuntimeDiagnostics { audio_preferred_sample_rate: audio.preferred_sample_rate, audio_alsa_plugin: audio.alsa_plugin.map(|p| format!("{:?}", p)), audio_alsa_hardware_volume: audio.alsa_hardware_volume, + audio_alsa_hardware_volume_controls: audio.alsa_hardware_volume_controls.clone(), + audio_alsa_hardware_volume_probe: qbz_audio::alsa_direct::uses_alsa_direct_route(audio) + .then(|| { + qbz_audio::alsa_hardware_volume::enumerate_hardware_volume_controls( + audio + .output_device + .as_deref() + .expect("direct ALSA route has a device id"), + ) + }), audio_normalization_enabled: audio.normalization_enabled, audio_normalization_target_lufs: audio.normalization_target_lufs, audio_gapless_enabled: audio.gapless_enabled, diff --git a/crates/qbz-app/src/lib.rs b/crates/qbz-app/src/lib.rs index 9e375788d..8825d27c9 100644 --- a/crates/qbz-app/src/lib.rs +++ b/crates/qbz-app/src/lib.rs @@ -1,12 +1,34 @@ +pub mod device_cap; pub mod diagnostics; +pub mod memory_watchdog; pub mod offline_mode; pub mod graphics_autoconfig; +pub mod listen_log; pub mod playback_context; pub mod playback_driver; pub mod qconnect_identity; pub mod runtime; pub mod scrobble_timing; +pub mod session_persist; pub mod session_store; +pub mod subscription_lifecycle; pub mod shell; pub mod settings; pub mod user_data; + +/// Install the rustls process-level `CryptoProvider` (aws-lc-rs) exactly once. +/// +/// Idempotent (a second call is a no-op); harmless if some other component +/// already installed a default (`install_default` then returns Err, ignored). +pub fn ensure_crypto_provider() { + use std::sync::Once; + static INIT: Once = Once::new(); + INIT.call_once(|| { + if rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .is_err() + { + log::debug!("[app] rustls CryptoProvider already installed"); + } + }); +} diff --git a/crates/qbz-app/src/listen_log/logger.rs b/crates/qbz-app/src/listen_log/logger.rs new file mode 100644 index 000000000..230d50444 --- /dev/null +++ b/crates/qbz-app/src/listen_log/logger.rs @@ -0,0 +1,414 @@ +//! The async facade both hosts drive: a [`ListenTracker`] plus a +//! [`ListenStore`], every store call inside `spawn_blocking`. +//! +//! Hosts call explicit lifecycle verbs (`track_started`, `tick`, +//! `ended_naturally`, `stopped`, `handoff`, `shutdown`) and never touch SQLite +//! or the state machine themselves. "Listening history OFF" (`paused`) is +//! honoured HERE, in one place: a paused logger opens no row, so nothing +//! downstream has to ask. + +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::store::ListenStore; +use super::tracker::{ListenMeta, ListenTracker}; + +/// Where this process's rows come from (`origin_id`). +pub enum Origin { + /// The desktop app: a uuid generated once per install and kept in + /// `listen_meta`. + Install, + /// The daemon: `qbzd:`, fixed. + Daemon { hostname: String }, +} + +pub struct ListenLogger { + store: Arc>, + tracker: Mutex, + app_session_id: String, + origin_id: String, + /// Mirror of `listen_meta.paused`, so the hot path (a tick per second) + /// never opens the database to ask. + paused: std::sync::atomic::AtomicBool, +} + +pub fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +impl ListenLogger { + /// Open the per-user store, close any rows a previous run left open + /// (`shutdown`), and mint this run's `app_session_id`. + pub async fn open(user_dir: std::path::PathBuf, origin: Origin) -> Result, String> { + let (store, origin_id, paused, orphans) = tokio::task::spawn_blocking(move || { + let store = ListenStore::open_at(&user_dir)?; + let origin_id = match origin { + Origin::Install => store.origin_id_or_init(|| uuid::Uuid::new_v4().to_string())?, + Origin::Daemon { hostname } => format!("qbzd:{hostname}"), + }; + let paused = store.is_paused()?; + let orphans = store.close_orphans_as_shutdown()?; + Ok::<_, String>((store, origin_id, paused, orphans)) + }) + .await + .map_err(|e| format!("listen log open task failed: {e}"))??; + if orphans > 0 { + log::info!( + "[listen-log] closed {orphans} row(s) left open by a previous run as shutdown" + ); + } + log::info!("[listen-log] open (origin {origin_id}, paused={paused})"); + Ok(Arc::new(Self { + store: Arc::new(Mutex::new(store)), + tracker: Mutex::new(ListenTracker::new()), + app_session_id: uuid::Uuid::new_v4().to_string(), + origin_id, + paused: std::sync::atomic::AtomicBool::new(paused), + })) + } + + pub fn is_paused(&self) -> bool { + self.paused.load(std::sync::atomic::Ordering::Relaxed) + } + + /// The "Listening history" toggle. Turning it OFF closes the row in + /// flight (as `stop`) so nothing is written after the user said no. + pub async fn set_paused(&self, paused: bool) -> Result<(), String> { + self.paused + .store(paused, std::sync::atomic::Ordering::Relaxed); + if paused { + let closed = self.tracker.lock().unwrap().stopped(now_unix(), false); + if let Some(c) = closed { + self.with_store(move |s| s.close_event(&c)).await?; + } + } + self.with_store(move |s| s.set_paused(paused)).await + } + + /// "Clear listening history": DELETE + VACUUM. The row in flight is + /// closed first so it does not survive the clear. + pub async fn clear(&self) -> Result<(), String> { + let closed = self.tracker.lock().unwrap().stopped(now_unix(), false); + self.with_store(move |s| { + if let Some(c) = closed { + s.close_event(&c)?; + } + s.clear() + }) + .await + } + + pub async fn count(&self) -> Result { + self.with_store(|s| s.count()).await + } + + /// A new track started (the host's de-duped track edge). Closes the row + /// in flight as skip (or, with `infer_end`, as natural when it sat within + /// 2 s of its end — for hosts without an explicit end edge). + pub async fn track_started(&self, meta: ListenMeta, infer_end: bool) { + let now = now_unix(); + if self.is_paused() { + // Still close whatever was open before the pause landed. + let closed = self.tracker.lock().unwrap().stopped(now, infer_end); + if let Some(c) = closed { + let _ = self.with_store(move |s| s.close_event(&c)).await; + } + return; + } + let (closed, meta) = { + let mut t = self.tracker.lock().unwrap(); + if infer_end { + t.track_started_inferring_end(meta, now) + } else { + t.track_started(meta, now) + } + }; + let session = self.app_session_id.clone(); + let origin = self.origin_id.clone(); + let result = self + .with_store(move |s| { + if let Some(c) = closed { + s.close_event(&c)?; + } + s.open_event(&session, &origin, &meta, now) + }) + .await; + match result { + Ok(id) => self.tracker.lock().unwrap().opened(id), + Err(e) => log::warn!("[listen-log] open failed: {e}"), + } + } + + /// One playback observation (1 Hz). Flushes to disk every ~10 s of play. + pub async fn tick(&self, position_ms: u64, playing: bool) { + let flush = self.tracker.lock().unwrap().tick(position_ms, playing); + if let Some(f) = flush { + if let Err(e) = self.with_store(move |s| s.flush_progress(&f)).await { + log::warn!("[listen-log] flush failed: {e}"); + } + } + } + + pub async fn ended_naturally(&self) { + let closed = self.tracker.lock().unwrap().ended_naturally(now_unix()); + self.close(closed).await; + } + + pub async fn stopped(&self, infer_natural: bool) { + let closed = self + .tracker + .lock() + .unwrap() + .stopped(now_unix(), infer_natural); + self.close(closed).await; + } + + pub async fn errored(&self) { + let closed = self.tracker.lock().unwrap().errored(now_unix()); + self.close(closed).await; + } + + /// Playback authority moved away from this process. Close the owner row + /// as `handoff`; delegated tracks are intentionally never opened here. + /// Idempotent so repeated lifecycle observations cannot rewrite the row. + pub async fn handoff(&self) { + let closed = self.tracker.lock().unwrap().handoff(now_unix()); + self.close(closed).await; + } + + /// Orderly exit: close the row in flight as `shutdown`. SYNCHRONOUS on + /// purpose — the hosts call it after their event loop is gone. + pub fn shutdown_blocking(&self) { + let closed = self.tracker.lock().unwrap().shutdown(now_unix()); + if let Some(c) = closed { + if let Ok(store) = self.store.lock() { + if let Err(e) = store.close_event(&c) { + log::warn!("[listen-log] shutdown close failed: {e}"); + } + } + } + } + + /// Whether a row is in flight (tests + diagnostics). + pub fn has_open_row(&self) -> bool { + self.tracker.lock().unwrap().open().is_some() + } + + /// Developer inspection: every row in id order. + pub async fn rows(&self) -> Result, String> { + self.with_store(|s| s.rows()).await + } + + async fn close(&self, closed: Option) { + if let Some(c) = closed { + if let Err(e) = self.with_store(move |s| s.close_event(&c)).await { + log::warn!("[listen-log] close failed: {e}"); + } + } + } + + async fn with_store(&self, f: F) -> Result + where + T: Send + 'static, + F: FnOnce(&ListenStore) -> Result + Send + 'static, + { + let store = Arc::clone(&self.store); + tokio::task::spawn_blocking(move || { + let guard = store + .lock() + .map_err(|_| "listen log store lock poisoned".to_string())?; + f(&guard) + }) + .await + .map_err(|e| format!("listen log task failed: {e}"))? + } +} + +#[cfg(test)] +mod tests { + use super::super::rules::EndReason; + use super::*; + + fn meta(id: &str, duration_ms: u64) -> ListenMeta { + ListenMeta { + source: "qobuz".into(), + source_item_id: id.into(), + title: format!("T{id}"), + artist: "A".into(), + duration_ms, + ..Default::default() + } + } + + #[tokio::test] + async fn play_through_then_skip_then_shutdown() { + let dir = tempfile::tempdir().unwrap(); + let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install) + .await + .unwrap(); + assert_eq!(l.origin_id.len(), 36); + + l.track_started(meta("1", 30_000), false).await; + for s in 0..=29u64 { + l.tick(s * 1_000, true).await; + } + l.ended_naturally().await; + + l.track_started(meta("2", 200_000), false).await; + for s in 0..=10u64 { + l.tick(s * 1_000, true).await; + } + l.track_started(meta("3", 200_000), false).await; + for s in 0..=25u64 { + l.tick(s * 1_000, true).await; + } + l.shutdown_blocking(); + + let rows = l.rows().await.unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].end_reason, Some(EndReason::Natural)); + assert_eq!(rows[0].played_ms, 29_000); + assert_eq!(rows[1].end_reason, Some(EndReason::Skip)); + assert_eq!(rows[1].played_ms, 10_000); + assert_eq!(rows[2].end_reason, Some(EndReason::Shutdown)); + assert_eq!(rows[2].played_ms, 25_000); + assert!(rows.iter().all(|r| r.app_session_id == l.app_session_id)); + } + + #[tokio::test] + async fn crash_leaves_flushed_progress_and_reopen_closes_it() { + let dir = tempfile::tempdir().unwrap(); + let origin = { + let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install) + .await + .unwrap(); + l.track_started(meta("1", 200_000), false).await; + for s in 0..=23u64 { + l.tick(s * 1_000, true).await; + } + // No shutdown: the process died here. The last flush was at 20 s. + l.origin_id.clone() + }; + let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install) + .await + .unwrap(); + assert_eq!(l.origin_id, origin, "origin id survives restarts"); + let rows = l.rows().await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].end_reason, Some(EndReason::Shutdown)); + assert_eq!(rows[0].played_ms, 20_000); + } + + #[tokio::test] + async fn paused_writes_nothing_and_resumes() { + let dir = tempfile::tempdir().unwrap(); + let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install) + .await + .unwrap(); + l.set_paused(true).await.unwrap(); + l.track_started(meta("1", 100_000), false).await; + l.tick(0, true).await; + l.tick(15_000, true).await; + l.ended_naturally().await; + assert_eq!(l.count().await.unwrap(), 0); + assert!(!l.has_open_row()); + + // The flag is persisted: a reopen stays paused. + let l2 = ListenLogger::open(dir.path().to_path_buf(), Origin::Install) + .await + .unwrap(); + assert!(l2.is_paused()); + l2.set_paused(false).await.unwrap(); + l2.track_started(meta("2", 100_000), false).await; + l2.tick(0, true).await; + l2.tick(1_000, true).await; + l2.stopped(false).await; + let rows = l2.rows().await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].end_reason, Some(EndReason::Stop)); + assert_eq!(rows[0].played_ms, 1_000); + } + + #[tokio::test] + async fn pausing_mid_track_closes_the_open_row_and_stops_writing() { + let dir = tempfile::tempdir().unwrap(); + let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install) + .await + .unwrap(); + l.track_started(meta("1", 100_000), false).await; + l.tick(0, true).await; + l.tick(1_000, true).await; + l.set_paused(true).await.unwrap(); + l.tick(2_000, true).await; + l.track_started(meta("2", 100_000), false).await; + let rows = l.rows().await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].end_reason, Some(EndReason::Stop)); + assert_eq!(rows[0].played_ms, 1_000); + } + + #[tokio::test] + async fn clear_removes_everything_including_the_open_row() { + let dir = tempfile::tempdir().unwrap(); + let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install) + .await + .unwrap(); + l.track_started(meta("1", 100_000), false).await; + l.ended_naturally().await; + l.track_started(meta("2", 100_000), false).await; + l.clear().await.unwrap(); + assert_eq!(l.count().await.unwrap(), 0); + assert!(!l.has_open_row()); + // Logging continues after a clear. + l.track_started(meta("3", 100_000), false).await; + assert_eq!(l.count().await.unwrap(), 1); + } + + #[tokio::test] + async fn daemon_origin_and_inferred_end() { + let dir = tempfile::tempdir().unwrap(); + let l = ListenLogger::open( + dir.path().to_path_buf(), + Origin::Daemon { + hostname: "pi".into(), + }, + ) + .await + .unwrap(); + l.track_started(meta("1", 100_000), true).await; + l.tick(0, true).await; + l.tick(99_000, true).await; + // The bus only says "another track started": infer natural. + l.track_started(meta("2", 100_000), true).await; + l.tick(0, true).await; + l.tick(5_000, true).await; + l.stopped(true).await; + let rows = l.rows().await.unwrap(); + assert_eq!(rows[0].origin_id, "qbzd:pi"); + assert_eq!(rows[0].end_reason, Some(EndReason::Natural)); + assert_eq!(rows[1].end_reason, Some(EndReason::Stop)); + } + + #[tokio::test] + async fn handoff_closes_the_owner_row_once() { + let dir = tempfile::tempdir().unwrap(); + let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install) + .await + .unwrap(); + l.track_started(meta("1", 100_000), false).await; + l.tick(0, true).await; + l.tick(1_000, true).await; + + l.handoff().await; + l.handoff().await; + + assert!(!l.has_open_row()); + let rows = l.rows().await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].end_reason, Some(EndReason::Handoff)); + assert_eq!(rows[0].played_ms, 1_000); + } +} diff --git a/crates/qbz-app/src/listen_log/mod.rs b/crates/qbz-app/src/listen_log/mod.rs new file mode 100644 index 000000000..3c6aa34cf --- /dev/null +++ b/crates/qbz-app/src/listen_log/mod.rs @@ -0,0 +1,154 @@ +//! Listen log — the per-user record of what was actually heard. +//! +//! Contract: `qbz-nix-docs/research/2026-08-28-listening-log-offline-reco-analysis.md` +//! §12.1. Capture only: this module has NO consumer in the app. No rail, +//! mix, insight or suggestion reads it; the older stores +//! (`recently_played.json`, `album_play_history.db`, +//! `playlist_play_history.db`) keep running unchanged beside it. +//! +//! - [`store`] — the SQLite schema (PK = event) and its migrations. +//! - [`tracker`] — the pure state machine (accumulator, end reasons). +//! - [`rules`] — the reading thresholds (`is_play`, `is_skip`). +//! - [`logger`] — the async facade the Qt poll loop and `qbzd` drive. + +pub mod logger; +pub mod rules; +pub mod store; +pub mod tracker; + +pub use logger::{now_unix, ListenLogger, Origin}; +pub use rules::{is_play, is_skip, EndReason}; +pub use store::{ListenRow, ListenStore}; +pub use tracker::{ListenMeta, ListenTracker}; + +/// Ephemeral ids (CD / ad-hoc folder rows) live above 2^48 and never reach +/// the log (owner rule, mirrored from `recently_qt.rs:329`). +pub const EPHEMERAL_ID_FLOOR: u64 = 1 << 48; + +/// Build the row snapshot from a queue track plus what the host knows about +/// the stream. `source_item_id` is the NATIVE id when the queue carries one +/// (media-server item ids, Plex rating keys, offline row ids travel in +/// `source_item_id_hint` for local-tier rows); for Qobuz the hint is a +/// container id and the track id IS the native id. +pub fn meta_from_queue_track( + track: &qbz_models::QueueTrack, + bit_depth: Option, + sample_rate_hz: Option, + output_backend: Option, +) -> ListenMeta { + let source = track + .source + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + if track.is_local { + "local".into() + } else { + "qobuz".into() + } + }); + // "qobuz_download" is the offline cache's own source word in the local + // library; the contract's vocabulary calls that tier `offline`. + let source = if source == "qobuz_download" { + "offline".to_string() + } else { + source + }; + let source_item_id = match (&track.source_item_id_hint, track.is_local) { + (Some(hint), true) if !hint.is_empty() => hint.clone(), + _ => track.id.to_string(), + }; + ListenMeta { + source, + source_item_id, + track_id: Some(track.id as i64), + album_id: track.album_id.clone().filter(|a| !a.is_empty()), + artist_id: track.artist_id.map(|id| id.to_string()), + isrc: track.isrc.clone().filter(|s| !s.is_empty()), + recording_mbid: track.recording_mbid.clone().filter(|s| !s.is_empty()), + title: track.title.clone(), + artist: track.artist.clone(), + album: Some(track.album.clone()).filter(|a| !a.is_empty()), + album_artist: None, + artwork_key: track.artwork_url.clone().filter(|a| !a.is_empty()), + duration_ms: track.duration_secs * 1_000, + context_kind: track.context_kind.clone().unwrap_or_default(), + context_id: track.context_id.clone().unwrap_or_default(), + bit_depth: bit_depth.or(track.bit_depth), + sample_rate: sample_rate_hz + .or_else(|| track.sample_rate.map(|khz| (khz * 1000.0).round() as u32)), + output_backend, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track() -> qbz_models::QueueTrack { + qbz_models::QueueTrack { + id: 7, + title: "Song".into(), + version: None, + artist: "Band".into(), + album: "LP".into(), + album_version: None, + duration_secs: 200, + artwork_url: Some("http://art".into()), + hires: true, + bit_depth: Some(24), + sample_rate: Some(96.0), + is_local: false, + album_id: Some("alb".into()), + artist_id: Some(9), + streamable: true, + source: Some("qobuz".into()), + parental_warning: false, + source_item_id_hint: Some("alb".into()), + context_kind: Some("playlist".into()), + context_id: Some("pl-1".into()), + isrc: Some("USRC17607839".into()), + recording_mbid: None, + } + } + + #[test] + fn qobuz_uses_the_track_id_not_the_container_hint() { + let m = meta_from_queue_track(&track(), None, None, None); + assert_eq!(m.source, "qobuz"); + assert_eq!(m.source_item_id, "7"); + assert_eq!(m.track_id, Some(7)); + assert_eq!(m.duration_ms, 200_000); + assert_eq!(m.context_kind, "playlist"); + assert_eq!(m.context_id, "pl-1"); + assert_eq!(m.isrc.as_deref(), Some("USRC17607839")); + assert_eq!(m.sample_rate, Some(96_000)); + assert_eq!(m.bit_depth, Some(24)); + } + + #[test] + fn local_tier_rows_use_the_native_hint_and_offline_is_renamed() { + let mut t = track(); + t.is_local = true; + t.source = Some("jellyfin".into()); + t.source_item_id_hint = Some("item-abc".into()); + let m = meta_from_queue_track(&t, Some(16), Some(44_100), Some("alsa".into())); + assert_eq!(m.source, "jellyfin"); + assert_eq!(m.source_item_id, "item-abc"); + assert_eq!(m.bit_depth, Some(16)); + assert_eq!(m.sample_rate, Some(44_100)); + assert_eq!(m.output_backend.as_deref(), Some("alsa")); + + t.source = Some("qobuz_download".into()); + assert_eq!( + meta_from_queue_track(&t, None, None, None).source, + "offline" + ); + + t.source = None; + t.source_item_id_hint = None; + let m = meta_from_queue_track(&t, None, None, None); + assert_eq!(m.source, "local"); + assert_eq!(m.source_item_id, "7"); + } +} diff --git a/crates/qbz-app/src/listen_log/rules.rs b/crates/qbz-app/src/listen_log/rules.rs new file mode 100644 index 000000000..fbcaa5daf --- /dev/null +++ b/crates/qbz-app/src/listen_log/rules.rs @@ -0,0 +1,123 @@ +//! Pure reading rules over listen events. +//! +//! The store applies NO threshold when it writes (LMS/Astra): a skip is a row +//! with little `played_ms`, never an absent row. Whatever wants to count +//! "plays" or "skips" applies its bar here, so two consumers can never +//! disagree about what a play is because one of them wrote and the other +//! read (the Amperfy/Retro trap). + +/// Why a listen event closed. `Open` is the in-flight state (NULL on disk). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndReason { + /// The track played to its end. + Natural, + /// Replaced by another track before its end (next / previous / play X). + Skip, + /// Playback stopped with nothing following. + Stop, + /// The stream failed. + Error, + /// The app went away with the row open (closed on the next start, or on + /// an orderly exit). + Shutdown, + /// Playback moved to another device (cast / peer). Reserved — v1 never + /// records those tracks, but the word is fixed so readers can rely on it. + Handoff, +} + +/// ONE list, read by both directions: `as_str` and `parse` must never drift +/// (the `AudioFormat::Dsd` → `Unknown` lesson: a writer and a reader that each +/// spell the enum are two lists). +const END_REASONS: &[(EndReason, &str)] = &[ + (EndReason::Natural, "natural"), + (EndReason::Skip, "skip"), + (EndReason::Stop, "stop"), + (EndReason::Error, "error"), + (EndReason::Shutdown, "shutdown"), + (EndReason::Handoff, "handoff"), +]; + +impl EndReason { + pub fn as_str(self) -> &'static str { + END_REASONS + .iter() + .find(|(r, _)| *r == self) + .map(|(_, s)| *s) + .expect("every EndReason is in END_REASONS") + } + + pub fn parse(s: &str) -> Option { + END_REASONS.iter().find(|(_, t)| *t == s).map(|(r, _)| *r) + } +} + +/// Last.fm / MusicBee bar: the track is longer than 30 s and at least half of +/// it (capped at 4 minutes) was audible. +pub fn is_play(played_ms: u64, duration_ms: u64) -> bool { + duration_ms > 30_000 && played_ms >= (duration_ms / 2).min(240_000) +} + +/// beets bar: it was replaced before 85 % of it was heard. A track that was +/// replaced at 90 % is a play that happened to end early, not a skip. +pub fn is_skip(end_reason: Option, played_ms: u64, duration_ms: u64) -> bool { + end_reason == Some(EndReason::Skip) && (played_ms as f64) < 0.85 * duration_ms as f64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn end_reason_round_trips_every_variant() { + for (reason, word) in END_REASONS { + assert_eq!(reason.as_str(), *word); + assert_eq!(EndReason::parse(word), Some(*reason)); + } + assert_eq!(EndReason::parse("nope"), None); + assert_eq!(END_REASONS.len(), 6); + } + + #[test] + fn is_play_thirty_second_floor() { + // 30 s exactly is NOT longer than 30 s. + assert!(!is_play(30_000, 30_000)); + // 30.001 s: half is 15 s (integer ms). + assert!(is_play(15_000, 30_001)); + assert!(!is_play(14_999, 30_001)); + } + + #[test] + fn is_play_half_or_four_minutes() { + // 100 s track: half = 50 s. + assert!(is_play(50_000, 100_000)); + assert!(!is_play(49_999, 100_000)); + // 10 min track: half = 300 s, capped at 240 s. + assert!(is_play(240_000, 600_000)); + assert!(!is_play(239_999, 600_000)); + // Exactly 480 s: half == cap. + assert!(is_play(240_000, 480_000)); + } + + #[test] + fn is_play_unknown_duration_never_counts() { + assert!(!is_play(1_000_000, 0)); + } + + #[test] + fn is_skip_needs_skip_reason_and_under_85_percent() { + let d = 200_000; + assert!(is_skip(Some(EndReason::Skip), 10_000, d)); + assert!(is_skip(Some(EndReason::Skip), 169_999, d)); + // 85 % exactly is not a skip. + assert!(!is_skip(Some(EndReason::Skip), 170_000, d)); + assert!(!is_skip(Some(EndReason::Natural), 10_000, d)); + assert!(!is_skip(Some(EndReason::Stop), 10_000, d)); + assert!(!is_skip(None, 10_000, d)); + } + + #[test] + fn is_skip_zero_duration_is_never_a_skip() { + // 0 < 0.85 * 0 is false: an unknown-duration row cannot be a skip. + assert!(!is_skip(Some(EndReason::Skip), 0, 0)); + } +} diff --git a/crates/qbz-app/src/listen_log/store.rs b/crates/qbz-app/src/listen_log/store.rs new file mode 100644 index 000000000..ee4328ef8 --- /dev/null +++ b/crates/qbz-app/src/listen_log/store.rs @@ -0,0 +1,521 @@ +//! The per-user listen-event store: `/listen/listen_log.db`. +//! +//! One row per listen EVENT (PK = event, never the track — the Tempo trap: +//! a track-keyed table with REPLACE can only ever count to one). Counters +//! are derived by readers, never written here. +//! +//! Synchronous `rusqlite`; the async facade (`logger.rs`) wraps every call +//! in `spawn_blocking`. WAL + `synchronous=NORMAL` (ADR-002). + +use std::path::{Path, PathBuf}; + +use rusqlite::{params, Connection, OptionalExtension}; + +use super::rules::EndReason; +use super::tracker::{Closed, Flush, ListenMeta}; + +pub const SCHEMA_VERSION: i64 = 1; + +/// Sub-directory + file under the user dir. +pub const DB_SUBDIR: &str = "listen"; +pub const DB_FILE: &str = "listen_log.db"; + +pub struct ListenStore { + conn: Connection, + #[allow(dead_code)] + path: Option, +} + +/// One persisted row, as readers see it (tests + the developer query). +#[derive(Debug, Clone, PartialEq)] +pub struct ListenRow { + pub id: i64, + pub app_session_id: String, + pub origin_id: String, + pub source: String, + pub source_item_id: String, + pub track_id: Option, + pub isrc: Option, + pub recording_mbid: Option, + pub title: String, + pub artist: String, + pub duration_ms: u64, + pub started_at: i64, + pub ended_at: Option, + pub played_ms: u64, + pub end_position_ms: Option, + pub end_reason: Option, + pub context_kind: String, + pub context_id: String, +} + +impl ListenStore { + /// Open (or create) the store under `user_dir`. + pub fn open_at(user_dir: &Path) -> Result { + let dir = user_dir.join(DB_SUBDIR); + std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create listen dir: {e}"))?; + let path = dir.join(DB_FILE); + let conn = + Connection::open(&path).map_err(|e| format!("Failed to open listen log: {e}"))?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=2000;", + ) + .map_err(|e| format!("Failed to set listen log pragmas: {e}"))?; + let store = Self { + conn, + path: Some(path), + }; + store.migrate()?; + Ok(store) + } + + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory() + .map_err(|e| format!("Failed to open in-memory listen log: {e}"))?; + let store = Self { conn, path: None }; + store.migrate()?; + Ok(store) + } + + /// Idempotent: creates the schema on a fresh file and records + /// `schema_version`; a later version adds its `ALTER`s behind a version + /// check here (a table is only new once — see `playlist_play_history`). + fn migrate(&self) -> Result<(), String> { + self.conn + .execute_batch( + r#" + CREATE TABLE IF NOT EXISTS listen_meta ( + key TEXT PRIMARY KEY, + value TEXT + ); + CREATE TABLE IF NOT EXISTS listen_events ( + id INTEGER PRIMARY KEY, + app_session_id TEXT NOT NULL, + origin_id TEXT NOT NULL, + source TEXT NOT NULL, + source_item_id TEXT NOT NULL, + track_id INTEGER, + album_id TEXT, + artist_id TEXT, + isrc TEXT, + recording_mbid TEXT, + title TEXT NOT NULL, + artist TEXT NOT NULL, + album TEXT, + album_artist TEXT, + artwork_key TEXT, + duration_ms INTEGER NOT NULL DEFAULT 0, + started_at INTEGER NOT NULL, + ended_at INTEGER, + played_ms INTEGER NOT NULL DEFAULT 0, + end_position_ms INTEGER, + end_reason TEXT, + context_kind TEXT NOT NULL DEFAULT '', + context_id TEXT NOT NULL DEFAULT '', + bit_depth INTEGER, + sample_rate INTEGER, + output_backend TEXT + ); + CREATE INDEX IF NOT EXISTS ix_le_started ON listen_events(started_at); + CREATE INDEX IF NOT EXISTS ix_le_item ON listen_events(source, source_item_id, started_at); + CREATE INDEX IF NOT EXISTS ix_le_isrc ON listen_events(isrc) WHERE isrc IS NOT NULL; + CREATE INDEX IF NOT EXISTS ix_le_open ON listen_events(id) WHERE end_reason IS NULL; + "#, + ) + .map_err(|e| format!("Failed to create listen log schema: {e}"))?; + + let current = self + .meta("schema_version")? + .and_then(|v| v.parse::().ok()); + match current { + None => self.set_meta("schema_version", &SCHEMA_VERSION.to_string())?, + Some(v) if v > SCHEMA_VERSION => { + return Err(format!( + "listen log schema {v} is newer than this build ({SCHEMA_VERSION})" + )); + } + // Future: `Some(v) if v < SCHEMA_VERSION` → ALTERs, then bump. + Some(_) => {} + } + // Defaults that must exist from day one so readers never special-case + // an absent key: unlimited retention (owner decision §10.4). + if self.meta("retention_days")?.is_none() { + self.set_meta("retention_days", "0")?; + } + Ok(()) + } + + // ----- meta ----------------------------------------------------------- + + pub fn meta(&self, key: &str) -> Result, String> { + self.conn + .query_row( + "SELECT value FROM listen_meta WHERE key = ?1", + params![key], + |row| row.get::<_, Option>(0), + ) + .optional() + .map(|v| v.flatten()) + .map_err(|e| format!("Failed to read listen meta {key}: {e}")) + } + + pub fn set_meta(&self, key: &str, value: &str) -> Result<(), String> { + self.conn + .execute( + "INSERT INTO listen_meta (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + ) + .map(|_| ()) + .map_err(|e| format!("Failed to write listen meta {key}: {e}")) + } + + pub fn schema_version(&self) -> Result { + Ok(self + .meta("schema_version")? + .and_then(|v| v.parse().ok()) + .unwrap_or(0)) + } + + /// The install's origin id, generated once and kept in `listen_meta`. + /// `qbzd` passes its own fixed id instead (`qbzd:`). + pub fn origin_id_or_init(&self, generate: impl FnOnce() -> String) -> Result { + if let Some(id) = self.meta("origin_id")? { + return Ok(id); + } + let id = generate(); + self.set_meta("origin_id", &id)?; + Ok(id) + } + + /// `paused = 1` means "Listening history" is OFF: nothing is written + /// (and nothing is deleted). + pub fn is_paused(&self) -> Result { + Ok(self.meta("paused")?.as_deref() == Some("1")) + } + + pub fn set_paused(&self, paused: bool) -> Result<(), String> { + self.set_meta("paused", if paused { "1" } else { "0" }) + } + + pub fn retention_days(&self) -> Result { + Ok(self + .meta("retention_days")? + .and_then(|v| v.parse().ok()) + .unwrap_or(0)) + } + + // ----- events --------------------------------------------------------- + + /// Insert an open row; returns its id. + pub fn open_event( + &self, + app_session_id: &str, + origin_id: &str, + meta: &ListenMeta, + started_at: i64, + ) -> Result { + self.conn + .execute( + "INSERT INTO listen_events ( + app_session_id, origin_id, source, source_item_id, track_id, + album_id, artist_id, isrc, recording_mbid, + title, artist, album, album_artist, artwork_key, + duration_ms, started_at, context_kind, context_id, + bit_depth, sample_rate, output_backend + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", + params![ + app_session_id, + origin_id, + meta.source, + meta.source_item_id, + meta.track_id, + meta.album_id, + meta.artist_id, + meta.isrc, + meta.recording_mbid, + meta.title, + meta.artist, + meta.album, + meta.album_artist, + meta.artwork_key, + meta.duration_ms as i64, + started_at, + meta.context_kind, + meta.context_id, + meta.bit_depth, + meta.sample_rate, + meta.output_backend, + ], + ) + .map_err(|e| format!("Failed to open listen event: {e}"))?; + Ok(self.conn.last_insert_rowid()) + } + + /// Persist the accumulator of an OPEN row (a crash then loses at most + /// one flush interval). Never touches a closed row. + pub fn flush_progress(&self, f: &Flush) -> Result<(), String> { + self.conn + .execute( + "UPDATE listen_events SET played_ms = ?1, end_position_ms = ?2 + WHERE id = ?3 AND end_reason IS NULL", + params![ + f.played_ms as i64, + f.end_position_ms.map(|v| v as i64), + f.event_id + ], + ) + .map(|_| ()) + .map_err(|e| format!("Failed to flush listen progress: {e}")) + } + + /// Close a row. Idempotent on an already-closed row (no-op). + pub fn close_event(&self, c: &Closed) -> Result<(), String> { + self.conn + .execute( + "UPDATE listen_events + SET ended_at = ?1, played_ms = ?2, end_position_ms = ?3, end_reason = ?4 + WHERE id = ?5 AND end_reason IS NULL", + params![ + c.ended_at, + c.played_ms as i64, + c.end_position_ms.map(|v| v as i64), + c.reason.as_str(), + c.event_id + ], + ) + .map(|_| ()) + .map_err(|e| format!("Failed to close listen event: {e}")) + } + + /// Rows left open by a crash (or a kill) close as `shutdown` with the + /// `played_ms` that reached disk, at startup. `ended_at` is unknowable — + /// it is set to `started_at + played_ms` as the best lower bound. + pub fn close_orphans_as_shutdown(&self) -> Result { + self.conn + .execute( + "UPDATE listen_events + SET end_reason = ?1, ended_at = started_at + (played_ms / 1000) + WHERE end_reason IS NULL", + params![EndReason::Shutdown.as_str()], + ) + .map_err(|e| format!("Failed to close orphan listen events: {e}")) + } + + /// "Clear listening history": every event goes, the file is compacted. + /// `listen_meta` (origin id, paused flag, retention) is kept. + pub fn clear(&self) -> Result<(), String> { + // Under WAL the vacuumed image lands in the -wal file first; the + // TRUNCATE checkpoint is what makes the main file actually shrink + // (otherwise "Clear" leaves the old size on disk until a later + // checkpoint happens to run). + self.conn + .execute_batch("DELETE FROM listen_events; VACUUM; PRAGMA wal_checkpoint(TRUNCATE);") + .map_err(|e| format!("Failed to clear listen log: {e}")) + } + + pub fn count(&self) -> Result { + self.conn + .query_row("SELECT COUNT(*) FROM listen_events", [], |row| { + row.get::<_, i64>(0) + }) + .map(|n| n as u64) + .map_err(|e| format!("Failed to count listen events: {e}")) + } + + /// Every row in id order (tests + developer inspection; never a rail). + pub fn rows(&self) -> Result, String> { + let mut stmt = self + .conn + .prepare( + "SELECT id, app_session_id, origin_id, source, source_item_id, track_id, + isrc, recording_mbid, title, artist, duration_ms, started_at, + ended_at, played_ms, end_position_ms, end_reason, + context_kind, context_id + FROM listen_events ORDER BY id", + ) + .map_err(|e| format!("Failed to prepare listen query: {e}"))?; + let rows = stmt + .query_map([], |row| { + Ok(ListenRow { + id: row.get(0)?, + app_session_id: row.get(1)?, + origin_id: row.get(2)?, + source: row.get(3)?, + source_item_id: row.get(4)?, + track_id: row.get(5)?, + isrc: row.get(6)?, + recording_mbid: row.get(7)?, + title: row.get(8)?, + artist: row.get(9)?, + duration_ms: row.get::<_, i64>(10)? as u64, + started_at: row.get(11)?, + ended_at: row.get(12)?, + played_ms: row.get::<_, i64>(13)? as u64, + end_position_ms: row.get::<_, Option>(14)?.map(|v| v as u64), + end_reason: row + .get::<_, Option>(15)? + .as_deref() + .and_then(EndReason::parse), + context_kind: row.get(16)?, + context_id: row.get(17)?, + }) + }) + .map_err(|e| format!("Failed to query listen events: {e}"))?; + Ok(rows.flatten().collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta() -> ListenMeta { + ListenMeta { + source: "qobuz".into(), + source_item_id: "42".into(), + track_id: Some(42), + title: "Song".into(), + artist: "Band".into(), + album: Some("LP".into()), + duration_ms: 200_000, + context_kind: "album".into(), + context_id: "abc".into(), + isrc: Some("USRC17607839".into()), + ..Default::default() + } + } + + #[test] + fn schema_version_is_recorded_and_reopen_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + { + let s = ListenStore::open_at(dir.path()).unwrap(); + assert_eq!(s.schema_version().unwrap(), SCHEMA_VERSION); + assert_eq!(s.retention_days().unwrap(), 0); + assert!(!s.is_paused().unwrap()); + } + let s = ListenStore::open_at(dir.path()).unwrap(); + assert_eq!(s.schema_version().unwrap(), SCHEMA_VERSION); + assert!(dir.path().join(DB_SUBDIR).join(DB_FILE).exists()); + } + + #[test] + fn a_newer_schema_refuses_to_open() { + let dir = tempfile::tempdir().unwrap(); + { + let s = ListenStore::open_at(dir.path()).unwrap(); + s.set_meta("schema_version", &(SCHEMA_VERSION + 1).to_string()) + .unwrap(); + } + assert!(ListenStore::open_at(dir.path()).is_err()); + } + + #[test] + fn origin_id_is_generated_once() { + let s = ListenStore::open_in_memory().unwrap(); + let a = s.origin_id_or_init(|| "first".into()).unwrap(); + let b = s.origin_id_or_init(|| "second".into()).unwrap(); + assert_eq!(a, "first"); + assert_eq!(b, "first"); + } + + #[test] + fn open_flush_close_round_trip() { + let s = ListenStore::open_in_memory().unwrap(); + let id = s.open_event("sess", "origin", &meta(), 1_000).unwrap(); + s.flush_progress(&Flush { + event_id: id, + played_ms: 10_000, + end_position_ms: Some(10_000), + }) + .unwrap(); + let row = &s.rows().unwrap()[0]; + assert_eq!(row.played_ms, 10_000); + assert_eq!(row.end_reason, None); + assert_eq!(row.isrc.as_deref(), Some("USRC17607839")); + assert_eq!(row.context_kind, "album"); + s.close_event(&Closed { + event_id: id, + reason: EndReason::Skip, + played_ms: 12_000, + end_position_ms: Some(12_000), + ended_at: 1_012, + }) + .unwrap(); + let row = &s.rows().unwrap()[0]; + assert_eq!(row.end_reason, Some(EndReason::Skip)); + assert_eq!(row.played_ms, 12_000); + assert_eq!(row.ended_at, Some(1_012)); + // A late flush must not reopen or alter a closed row. + s.flush_progress(&Flush { + event_id: id, + played_ms: 99_000, + end_position_ms: None, + }) + .unwrap(); + assert_eq!(s.rows().unwrap()[0].played_ms, 12_000); + } + + #[test] + fn orphans_close_as_shutdown_with_the_flushed_progress() { + let s = ListenStore::open_in_memory().unwrap(); + let a = s.open_event("sess", "o", &meta(), 1_000).unwrap(); + s.flush_progress(&Flush { + event_id: a, + played_ms: 30_000, + end_position_ms: Some(30_000), + }) + .unwrap(); + let b = s.open_event("sess", "o", &meta(), 2_000).unwrap(); + s.close_event(&Closed { + event_id: b, + reason: EndReason::Natural, + played_ms: 200_000, + end_position_ms: Some(200_000), + ended_at: 2_200, + }) + .unwrap(); + assert_eq!(s.close_orphans_as_shutdown().unwrap(), 1); + let rows = s.rows().unwrap(); + assert_eq!(rows[0].end_reason, Some(EndReason::Shutdown)); + assert_eq!(rows[0].played_ms, 30_000); + assert_eq!(rows[0].ended_at, Some(1_030)); + assert_eq!(rows[1].end_reason, Some(EndReason::Natural)); + } + + #[test] + fn clear_empties_events_and_keeps_meta() { + let s = ListenStore::open_in_memory().unwrap(); + s.set_paused(false).unwrap(); + let _ = s.origin_id_or_init(|| "keep-me".into()).unwrap(); + s.open_event("sess", "o", &meta(), 1).unwrap(); + s.open_event("sess", "o", &meta(), 2).unwrap(); + assert_eq!(s.count().unwrap(), 2); + s.clear().unwrap(); + assert_eq!(s.count().unwrap(), 0); + assert_eq!(s.meta("origin_id").unwrap().as_deref(), Some("keep-me")); + assert_eq!(s.schema_version().unwrap(), SCHEMA_VERSION); + } + + #[test] + fn vacuum_compacts_the_file() { + let dir = tempfile::tempdir().unwrap(); + let s = ListenStore::open_at(dir.path()).unwrap(); + for i in 0..2_000 { + s.open_event("sess", "o", &meta(), i).unwrap(); + } + let path = dir.path().join(DB_SUBDIR).join(DB_FILE); + // Checkpoint so the rows are in the main file, not only the WAL. + s.conn + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .unwrap(); + let before = std::fs::metadata(&path).unwrap().len(); + s.clear().unwrap(); + let after = std::fs::metadata(&path).unwrap().len(); + assert!( + after < before, + "vacuum did not shrink the file ({before} -> {after})" + ); + } +} diff --git a/crates/qbz-app/src/listen_log/tracker.rs b/crates/qbz-app/src/listen_log/tracker.rs new file mode 100644 index 000000000..466798a60 --- /dev/null +++ b/crates/qbz-app/src/listen_log/tracker.rs @@ -0,0 +1,407 @@ +//! The listen tracker: a pure state machine over playback observations. +//! +//! No clock, no I/O — every input carries its own `now`, and every output is +//! a value the caller persists. That is what makes the rules testable and +//! what keeps the two hosts (the Qt poll loop and the `qbzd` event bus) +//! from drifting: both feed the same machine. +//! +//! The one rule that matters (Codex §9, Feishin): `played_ms` accumulates +//! ONLY while playing, from position deltas that are monotonic and at most +//! [`MAX_DELTA_MS`]. A pause adds nothing; a seek (forward or back) is a +//! delta outside that window and adds nothing; a stall that skips reporting +//! for a while adds nothing. Wall-clock and the final position are never +//! consulted — pauses, seeks, repeat and the old scrobble bug all made them +//! lie. + +use super::rules::EndReason; + +/// A position jump larger than this is a seek or a stall, not audible time. +/// The hosts observe at 1 Hz, so a genuine tick is ~1 s. +pub const MAX_DELTA_MS: u64 = 5_000; + +/// Persist the accumulator at least this often (in audible ms) so a crash +/// loses at most this much of the open row. +pub const FLUSH_EVERY_MS: u64 = 10_000; + +/// Snapshot of the track a row is opened for. Text is captured here on +/// purpose (Astra): the row must still make sense after the track leaves +/// Qobuz or the Plex server it came from is gone. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ListenMeta { + pub source: String, + pub source_item_id: String, + pub track_id: Option, + pub album_id: Option, + pub artist_id: Option, + pub isrc: Option, + pub recording_mbid: Option, + pub title: String, + pub artist: String, + pub album: Option, + pub album_artist: Option, + pub artwork_key: Option, + pub duration_ms: u64, + pub context_kind: String, + pub context_id: String, + pub bit_depth: Option, + pub sample_rate: Option, + pub output_backend: Option, +} + +/// The open row, as the machine sees it. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenListen { + /// Store row id, set by the host once the row is inserted. + pub event_id: i64, + pub meta: ListenMeta, + pub started_at: i64, + pub played_ms: u64, + /// Last position observed, whatever the play state. + pub last_position_ms: Option, + /// `played_ms` at the last flush the host acknowledged. + flushed_ms: u64, +} + +/// A row to close, with everything the store needs. +#[derive(Debug, Clone, PartialEq)] +pub struct Closed { + pub event_id: i64, + pub reason: EndReason, + pub played_ms: u64, + pub end_position_ms: Option, + pub ended_at: i64, +} + +/// A progress checkpoint the host should persist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Flush { + pub event_id: i64, + pub played_ms: u64, + pub end_position_ms: Option, +} + +#[derive(Debug, Default)] +pub struct ListenTracker { + open: Option, +} + +impl ListenTracker { + pub fn new() -> Self { + Self::default() + } + + pub fn open(&self) -> Option<&OpenListen> { + self.open.as_ref() + } + + /// A new track surfaced. Closes the previous row (if any) as a SKIP — + /// unless the host already closed it as natural — and returns the meta + /// the host must insert; call [`Self::opened`] with the row id. + pub fn track_started(&mut self, meta: ListenMeta, now: i64) -> (Option, ListenMeta) { + let closed = self.close_with(EndReason::Skip, now); + self.open = Some(OpenListen { + event_id: 0, + meta: meta.clone(), + started_at: now, + played_ms: 0, + last_position_ms: None, + flushed_ms: 0, + }); + (closed, meta) + } + + /// The host inserted the row for the track from the last + /// [`Self::track_started`]; from now on progress has somewhere to go. + pub fn opened(&mut self, event_id: i64) { + if let Some(open) = self.open.as_mut() { + open.event_id = event_id; + } + } + + /// One playback observation. Returns a flush when the accumulator has + /// advanced by [`FLUSH_EVERY_MS`] since the last one. + pub fn tick(&mut self, position_ms: u64, playing: bool) -> Option { + let open = self.open.as_mut()?; + if let (true, Some(last)) = (playing, open.last_position_ms) { + if position_ms > last { + let delta = position_ms - last; + if delta <= MAX_DELTA_MS { + open.played_ms += delta; + } + } + } + open.last_position_ms = Some(position_ms); + if open.event_id != 0 && open.played_ms.saturating_sub(open.flushed_ms) >= FLUSH_EVERY_MS { + open.flushed_ms = open.played_ms; + return Some(Flush { + event_id: open.event_id, + played_ms: open.played_ms, + end_position_ms: open.last_position_ms, + }); + } + None + } + + /// The host's own natural-end predicate fired. + pub fn ended_naturally(&mut self, now: i64) -> Option { + self.close_with(EndReason::Natural, now) + } + + /// Playback stopped with nothing following. A host without an explicit + /// natural-end edge (qbzd) passes `infer_natural = true`: a stop whose + /// last position sat within 2 s of the duration is the track ending. + pub fn stopped(&mut self, now: i64, infer_natural: bool) -> Option { + let reason = if infer_natural && self.near_end() { + EndReason::Natural + } else { + EndReason::Stop + }; + self.close_with(reason, now) + } + + pub fn errored(&mut self, now: i64) -> Option { + self.close_with(EndReason::Error, now) + } + + pub fn shutdown(&mut self, now: i64) -> Option { + self.close_with(EndReason::Shutdown, now) + } + + /// Playback authority moved to another renderer. The owner row is closed + /// explicitly and no delegated playback is folded into it. Calling this + /// more than once is harmless: after the first close there is no open row. + pub fn handoff(&mut self, now: i64) -> Option { + self.close_with(EndReason::Handoff, now) + } + + /// For hosts that only see "a different track started": was the previous + /// one within 2 s of its end? Mirrors the desktop's + /// `seen_position + 2 >= duration` (playback_driver.rs:226-264). + pub fn near_end(&self) -> bool { + let Some(open) = self.open.as_ref() else { + return false; + }; + let duration = open.meta.duration_ms; + match open.last_position_ms { + Some(pos) if duration > 0 => pos + 2_000 >= duration, + _ => false, + } + } + + /// Close with an inferred reason: natural when near the end, else skip. + /// The qbzd bus has no TrackEnded (nobody emits it), so this is its + /// track-edge close. + pub fn track_started_inferring_end( + &mut self, + meta: ListenMeta, + now: i64, + ) -> (Option, ListenMeta) { + let reason = if self.near_end() { + EndReason::Natural + } else { + EndReason::Skip + }; + let closed = self.close_with(reason, now); + self.open = Some(OpenListen { + event_id: 0, + meta: meta.clone(), + started_at: now, + played_ms: 0, + last_position_ms: None, + flushed_ms: 0, + }); + (closed, meta) + } + + fn close_with(&mut self, reason: EndReason, now: i64) -> Option { + let open = self.open.take()?; + // A row the host never managed to insert has nothing to close. + if open.event_id == 0 { + return None; + } + Some(Closed { + event_id: open.event_id, + reason, + played_ms: open.played_ms, + end_position_ms: open.last_position_ms, + ended_at: now, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta(duration_ms: u64) -> ListenMeta { + ListenMeta { + source: "qobuz".into(), + source_item_id: "1".into(), + title: "T".into(), + artist: "A".into(), + duration_ms, + ..Default::default() + } + } + + fn started(t: &mut ListenTracker, id: i64) { + let (_, _) = t.track_started(meta(200_000), 1_000); + t.opened(id); + } + + #[test] + fn accumulates_only_monotonic_small_deltas_while_playing() { + let mut t = ListenTracker::new(); + started(&mut t, 7); + assert_eq!(t.tick(0, true), None); // first observation: no delta + t.tick(1_000, true); + t.tick(2_000, true); + assert_eq!(t.open().unwrap().played_ms, 2_000); + // Seek forward 60 s: ignored, but the position is remembered. + t.tick(62_000, true); + assert_eq!(t.open().unwrap().played_ms, 2_000); + t.tick(63_000, true); + assert_eq!(t.open().unwrap().played_ms, 3_000); + // Seek back: negative delta ignored. + t.tick(10_000, true); + assert_eq!(t.open().unwrap().played_ms, 3_000); + t.tick(11_000, true); + assert_eq!(t.open().unwrap().played_ms, 4_000); + // Exactly MAX_DELTA counts; one more does not. + t.tick(16_000, true); + assert_eq!(t.open().unwrap().played_ms, 9_000); + t.tick(21_001, true); + assert_eq!(t.open().unwrap().played_ms, 9_000); + } + + #[test] + fn paused_ticks_add_nothing_and_survive_the_pause() { + let mut t = ListenTracker::new(); + started(&mut t, 7); + t.tick(0, true); + t.tick(1_000, true); + // Two minutes paused at the same position. + for _ in 0..120 { + t.tick(1_000, false); + } + assert_eq!(t.open().unwrap().played_ms, 1_000); + // Resume: the next delta is 1 s again. + t.tick(2_000, true); + assert_eq!(t.open().unwrap().played_ms, 2_000); + // A paused tick whose position moved (a seek while paused) counts nothing. + t.tick(50_000, false); + t.tick(51_000, true); + assert_eq!(t.open().unwrap().played_ms, 3_000); + } + + #[test] + fn flushes_every_ten_audible_seconds() { + let mut t = ListenTracker::new(); + started(&mut t, 7); + t.tick(0, true); + let mut flushes = Vec::new(); + for s in 1..=25u64 { + if let Some(f) = t.tick(s * 1_000, true) { + flushes.push(f); + } + } + assert_eq!( + flushes, + vec![ + Flush { + event_id: 7, + played_ms: 10_000, + end_position_ms: Some(10_000) + }, + Flush { + event_id: 7, + played_ms: 20_000, + end_position_ms: Some(20_000) + }, + ] + ); + } + + #[test] + fn next_track_closes_the_previous_as_skip_with_its_accumulator() { + let mut t = ListenTracker::new(); + started(&mut t, 7); + t.tick(0, true); + for s in 1..=10u64 { + t.tick(s * 1_000, true); + } + let (closed, next) = t.track_started(meta(100_000), 5_000); + assert_eq!( + closed, + Some(Closed { + event_id: 7, + reason: EndReason::Skip, + played_ms: 10_000, + end_position_ms: Some(10_000), + ended_at: 5_000, + }) + ); + assert_eq!(next.duration_ms, 100_000); + assert_eq!(t.open().unwrap().event_id, 0); + t.opened(8); + assert_eq!(t.open().unwrap().event_id, 8); + } + + #[test] + fn natural_end_then_next_track_opens_without_a_second_close() { + let mut t = ListenTracker::new(); + started(&mut t, 7); + t.tick(0, true); + t.tick(199_000, true); + let closed = t.ended_naturally(9_000).unwrap(); + assert_eq!(closed.reason, EndReason::Natural); + let (closed_again, _) = t.track_started(meta(100_000), 9_001); + assert_eq!(closed_again, None); + } + + #[test] + fn stop_shutdown_error_handoff_reasons() { + let mut t = ListenTracker::new(); + started(&mut t, 1); + assert_eq!(t.stopped(2, false).unwrap().reason, EndReason::Stop); + started(&mut t, 2); + assert_eq!(t.shutdown(3).unwrap().reason, EndReason::Shutdown); + started(&mut t, 3); + assert_eq!(t.errored(4).unwrap().reason, EndReason::Error); + started(&mut t, 4); + assert_eq!(t.handoff(5).unwrap().reason, EndReason::Handoff); + assert_eq!(t.handoff(6), None, "handoff is idempotent"); + assert_eq!(t.shutdown(7), None); + } + + #[test] + fn inferred_end_uses_the_two_second_window() { + let mut t = ListenTracker::new(); + started(&mut t, 1); + t.tick(0, true); + t.tick(198_000, true); + assert!(t.near_end()); + let (closed, _) = t.track_started_inferring_end(meta(50_000), 9); + assert_eq!(closed.unwrap().reason, EndReason::Natural); + t.opened(2); + t.tick(0, true); + t.tick(10_000, true); + assert!(!t.near_end()); + assert_eq!(t.stopped(10, true).unwrap().reason, EndReason::Stop); + started(&mut t, 3); + t.tick(198_500, true); + assert_eq!(t.stopped(11, true).unwrap().reason, EndReason::Natural); + } + + #[test] + fn a_row_that_was_never_inserted_closes_to_nothing() { + let mut t = ListenTracker::new(); + let _ = t.track_started(meta(1), 1); + // no `opened` call: the insert failed or paused + t.tick(0, true); + t.tick(1_000, true); + assert_eq!(t.tick(11_000, true), None); + assert_eq!(t.ended_naturally(2), None); + assert!(t.open().is_none()); + } +} diff --git a/crates/qbz-app/src/memory_watchdog.rs b/crates/qbz-app/src/memory_watchdog.rs new file mode 100644 index 000000000..c3e2065c3 --- /dev/null +++ b/crates/qbz-app/src/memory_watchdog.rs @@ -0,0 +1,87 @@ +//! Memory-pressure watchdog. +//! +//! Every 15 s, reads the host's memory pressure from `qbz-core`'s +//! `system_capabilities` and reacts: +//! +//! * `is_critical` (< 5 % available) — evict the L1 in-memory audio cache +//! (the L2 disk files stay; disk is not the scarce resource) and latch +//! [`prefetch_halted`] so no new prefetch downloads start. +//! * `is_low` (< 15 % available) — pause HiRes prefetch for that tick via +//! [`hires_prefetch_paused`]; a normal-lossless prefetch is ~4x smaller. +//! * healthy again — clear both latches and log the recovery at debug. +//! There is deliberately no fancier unlatching logic. +//! +//! Both frontends (`qbz`, `qbzd`) spawn the loop once at startup with the +//! shared [`Player`] handle; the prefetch path in +//! [`crate::playback_driver`] consults the latches before starting work. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use qbz_player::Player; + +/// How often the watchdog samples /proc/meminfo. +const TICK_SECS: u64 = 15; + +/// Latched on critical pressure: no new prefetch downloads may start. +static PREFETCH_HALTED: AtomicBool = AtomicBool::new(false); + +/// Set on low/critical pressure ticks: prefetch downgrades HiRes to +/// Lossless. Follows the latest tick rather than latching. +static HIRES_PREFETCH_PAUSED: AtomicBool = AtomicBool::new(false); + +/// True while the watchdog has halted prefetch downloads (critical +/// pressure). Checked by the prefetch path before starting any download. +pub fn prefetch_halted() -> bool { + PREFETCH_HALTED.load(Ordering::Relaxed) +} + +/// True when the latest watchdog tick saw low (or critical) memory +/// pressure — prefetch should stay at Lossless this round. +pub fn hires_prefetch_paused() -> bool { + HIRES_PREFETCH_PAUSED.load(Ordering::Relaxed) +} + +/// Spawn the watchdog loop on the current tokio runtime. Runs for the +/// process lifetime; the returned handle lets a caller with an ordered +/// shutdown abort it explicitly. +pub fn spawn(player: Arc) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(TICK_SECS)); + loop { + interval.tick().await; + // None where no memory probe exists (/proc/meminfo on unix, + // GlobalMemoryStatusEx on Windows) — nothing to do. + let Some(pressure) = qbz_core::system_capabilities::read_memory_pressure() else { + continue; + }; + if pressure.is_critical { + log::warn!( + "[memwatch] critical memory pressure: {:.1}% available ({} MB of {} MB) — evicting L1 audio cache, halting prefetch", + pressure.available_pct, + pressure.mem_available_kb / 1024, + pressure.mem_total_kb / 1024, + ); + player.evict_l1_audio_cache(); + PREFETCH_HALTED.store(true, Ordering::Relaxed); + HIRES_PREFETCH_PAUSED.store(true, Ordering::Relaxed); + } else if pressure.is_low { + HIRES_PREFETCH_PAUSED.store(true, Ordering::Relaxed); + log::debug!( + "[memwatch] low memory pressure: {:.1}% available — hires prefetch paused this tick", + pressure.available_pct, + ); + } else { + let was_halted = PREFETCH_HALTED.swap(false, Ordering::Relaxed); + let was_paused = HIRES_PREFETCH_PAUSED.swap(false, Ordering::Relaxed); + if was_halted || was_paused { + log::debug!( + "[memwatch] memory pressure recovered ({:.1}% available) — prefetch re-enabled", + pressure.available_pct, + ); + } + } + } + }) +} diff --git a/crates/qbz-app/src/offline_mode/connectivity.rs b/crates/qbz-app/src/offline_mode/connectivity.rs index 659cdd10d..d260c4442 100644 --- a/crates/qbz-app/src/offline_mode/connectivity.rs +++ b/crates/qbz-app/src/offline_mode/connectivity.rs @@ -86,7 +86,11 @@ const PROBES: &[ProbeEndpoint] = &[ expect: ProbeExpect::Status204, }, ProbeEndpoint { - url: "https://www.msftconnecttest.com/connecttest.txt", + // Microsoft NCSI defines this probe as plain HTTP. HTTPS is not a + // supported equivalent: the CDN can answer with an Akamai certificate + // that is not valid for www.msftconnecttest.com, producing a rustls + // error before the strict body check can run. + url: "http://www.msftconnecttest.com/connecttest.txt", expect: ProbeExpect::BodyContains("Microsoft Connect Test"), }, ]; @@ -595,4 +599,21 @@ mod tests { assert!(!ipv6_has_default_route(non_default)); assert!(!ipv6_has_default_route(lo_default)); } + + #[test] + fn microsoft_ncsi_probe_uses_its_defined_http_transport() { + let endpoint = PROBES + .iter() + .find(|endpoint| { + matches!( + endpoint.expect, + ProbeExpect::BodyContains("Microsoft Connect Test") + ) + }) + .expect("Microsoft NCSI probe"); + assert_eq!( + endpoint.url, + "http://www.msftconnecttest.com/connecttest.txt" + ); + } } diff --git a/crates/qbz-app/src/offline_mode/mod.rs b/crates/qbz-app/src/offline_mode/mod.rs index 404aa78e9..a89d8dcbc 100644 --- a/crates/qbz-app/src/offline_mode/mod.rs +++ b/crates/qbz-app/src/offline_mode/mod.rs @@ -160,20 +160,27 @@ impl OfflineModeEngine { store.get_settings() } - /// Persist the network-folders-in-manual-offline policy flag. - /// - /// NOTE (2026-06-10): no UI calls this anymore — the Slint "Show Network - /// Folder Content" toggle was removed when library visibility stopped - /// depending on offline mode (owner verdict; see qbz-slint's - /// NETWORK-FOLDER VISIBILITY note). Kept (pub, no dead-code warning in a - /// lib crate) because the store column must stay Tauri-DB-compatible. - pub fn set_show_network_folders(&self, enabled: bool) -> Result<(), String> { + /// Restore the Tauri manual-offline policy: submit scrobbles through the + /// network immediately even while the Qobuz offline gate is induced. + /// Enabling it atomically disables accumulated mode in the shared store. + pub fn set_allow_immediate_scrobbling(&self, enabled: bool) -> Result<(), String> { let guard = self .store .lock() .map_err(|e| format!("offline store lock poisoned: {}", e))?; let store = guard.as_ref().ok_or("No active session")?; - store.set_show_network_folders_in_manual_offline(enabled) + store.set_allow_immediate_scrobbling(enabled) + } + + /// Restore the Tauri physical-offline policy: retain scrobbles until the + /// network is usable. Enabling it atomically disables immediate mode. + pub fn set_allow_accumulated_scrobbling(&self, enabled: bool) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|e| format!("offline store lock poisoned: {}", e))?; + let store = guard.as_ref().ok_or("No active session")?; + store.set_allow_accumulated_scrobbling(enabled) } /// Flip induced offline (Settings toggle). Always succeeds in either diff --git a/crates/qbz-app/src/offline_mode/store.rs b/crates/qbz-app/src/offline_mode/store.rs index a70677675..e1a12b257 100644 --- a/crates/qbz-app/src/offline_mode/store.rs +++ b/crates/qbz-app/src/offline_mode/store.rs @@ -3,29 +3,45 @@ //! Opens the SAME `offline_settings.db` Tauri's `src-tauri/src/offline/mod.rs` //! uses (identical creation SQL + additive migrations), so the file stays //! frontend-portable like `library.db`/`index.db`. This shared store exposes -//! only the subset the offline-MODE port consumes: +//! the subset the current desktop frontends consume: //! //! - `manual_offline_mode` — the induced-offline flag (persisted; D1). //! - `show_network_folders_in_manual_offline` — network-mount policy (D9). //! - `pre_offline_stream_first_track` — the issue #279 snapshot of //! `audio_settings.stream_first_track` taken on entering induced offline. +//! - the Tauri-era immediate/accumulated scrobbling policy flags. //! -//! The legacy columns/tables (cast/scrobbling flags, `pending_playlist_sync`, -//! `scrobble_queue`, `cache_limit_bytes`) are still CREATED for byte-level -//! compatibility with the Tauri schema, but get no API here: the dead toggles -//! are not ported (spec §1) and offline playlist creation is replaced by -//! first-class local playlists (D7/D8). +//! The remaining legacy columns/tables (`allow_cast_while_offline`, +//! `pending_playlist_sync`, `cache_limit_bytes`) are still CREATED for +//! byte-level compatibility with the Tauri schema. Offline playlist creation +//! is replaced by first-class local playlists (D7/D8). use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use std::path::Path; /// The offline-mode settings the port consumes. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OfflineModeSettings { pub manual_offline_mode: bool, pub show_network_folders_in_manual_offline: bool, + /// Permit network submission while induced/manual offline is active. + pub allow_immediate_scrobbling: bool, + /// Retain scrobbles when they cannot be sent and flush them later. + pub allow_accumulated_scrobbling: bool, +} + +impl Default for OfflineModeSettings { + fn default() -> Self { + Self { + manual_offline_mode: false, + show_network_folders_in_manual_offline: false, + // Historical Tauri defaults (f9ca1bd25). + allow_immediate_scrobbling: false, + allow_accumulated_scrobbling: true, + } + } } /// One row of the Last.fm offline scrobble queue (`scrobble_queue`). Mirrors @@ -120,13 +136,17 @@ impl OfflineModeStore { self.conn .query_row( "SELECT manual_offline_mode, - COALESCE(show_network_folders_in_manual_offline, 0) + COALESCE(show_network_folders_in_manual_offline, 0), + COALESCE(allow_immediate_scrobbling, 0), + COALESCE(allow_accumulated_scrobbling, 1) FROM offline_settings WHERE id = 1", [], |row| { Ok(OfflineModeSettings { manual_offline_mode: row.get::<_, i64>(0)? != 0, show_network_folders_in_manual_offline: row.get::<_, i64>(1)? != 0, + allow_immediate_scrobbling: row.get::<_, i64>(2)? != 0, + allow_accumulated_scrobbling: row.get::<_, i64>(3)? != 0, }) }, ) @@ -153,6 +173,38 @@ impl OfflineModeStore { Ok(()) } + /// Tauri `set_allow_immediate_scrobbling`, with the later a68230fad + /// mutual-exclusion rule made atomic instead of two UI-side writes. + pub fn set_allow_immediate_scrobbling(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE offline_settings + SET allow_immediate_scrobbling = ?1, + allow_accumulated_scrobbling = CASE + WHEN ?1 != 0 THEN 0 ELSE allow_accumulated_scrobbling END + WHERE id = 1", + params![enabled as i64], + ) + .map_err(|e| format!("Failed to set allow immediate scrobbling: {}", e))?; + Ok(()) + } + + /// Tauri `set_allow_accumulated_scrobbling`, with the later a68230fad + /// mutual-exclusion rule made atomic instead of two UI-side writes. + pub fn set_allow_accumulated_scrobbling(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE offline_settings + SET allow_accumulated_scrobbling = ?1, + allow_immediate_scrobbling = CASE + WHEN ?1 != 0 THEN 0 ELSE allow_immediate_scrobbling END + WHERE id = 1", + params![enabled as i64], + ) + .map_err(|e| format!("Failed to set allow accumulated scrobbling: {}", e))?; + Ok(()) + } + /// Issue #279 snapshot: the user's `stream_first_track` preference stashed /// when entering induced offline. `None` = no snapshot active. pub fn get_pre_offline_stream_first_track(&self) -> Result, String> { @@ -313,6 +365,8 @@ mod tests { let settings = store.get_settings().unwrap(); assert!(!settings.manual_offline_mode); assert!(!settings.show_network_folders_in_manual_offline); + assert!(!settings.allow_immediate_scrobbling); + assert!(settings.allow_accumulated_scrobbling); assert_eq!(store.get_pre_offline_stream_first_track().unwrap(), None); let _ = std::fs::remove_dir_all(dir); @@ -347,6 +401,30 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn scrobbling_policies_round_trip_and_are_mutually_exclusive() { + let dir = unique_test_dir("offline-store-scrobble-policy"); + let store = OfflineModeStore::new_at(&dir).unwrap(); + + store.set_allow_immediate_scrobbling(true).unwrap(); + let settings = store.get_settings().unwrap(); + assert!(settings.allow_immediate_scrobbling); + assert!(!settings.allow_accumulated_scrobbling); + + // Turning a mode OFF does not silently opt into the other one. + store.set_allow_immediate_scrobbling(false).unwrap(); + let settings = store.get_settings().unwrap(); + assert!(!settings.allow_immediate_scrobbling); + assert!(!settings.allow_accumulated_scrobbling); + + store.set_allow_accumulated_scrobbling(true).unwrap(); + let settings = store.get_settings().unwrap(); + assert!(!settings.allow_immediate_scrobbling); + assert!(settings.allow_accumulated_scrobbling); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn scrobble_queue_round_trips_and_marks_sent() { let dir = unique_test_dir("offline-store-scrobbles"); @@ -420,6 +498,8 @@ mod tests { let settings = store.get_settings().unwrap(); assert!(settings.manual_offline_mode, "Tauri-era flag must survive"); assert!(!settings.show_network_folders_in_manual_offline); + assert!(!settings.allow_immediate_scrobbling); + assert!(settings.allow_accumulated_scrobbling); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/qbz-app/src/playback_driver.rs b/crates/qbz-app/src/playback_driver.rs index 01e359d61..540278e0e 100644 --- a/crates/qbz-app/src/playback_driver.rs +++ b/crates/qbz-app/src/playback_driver.rs @@ -19,12 +19,14 @@ // * `advance_and_play` — the full advance ritual (skip-walk → play → prefetch // → persist), reused verbatim by the CLI next/prev routes +use std::any::Any; +use std::fmt; use std::sync::Arc; use std::time::Duration; use qbz_core::{FrontendAdapter, QbzCore}; use qbz_models::{Quality, QueueTrack, RepeatMode}; -use qbz_player::PlaybackEvent; +use qbz_player::{PlaybackBufferState, PlaybackEvent}; use crate::session_store::{ PersistedPlaybackSession, PersistedQueueTrack, PersistedSessionSnapshot, @@ -59,6 +61,13 @@ pub enum DriverAction { /// Pre-queue this upcoming track's bytes for a gapless transition /// (`playback.rs:4387`). ArmGapless(u64), + /// A track started (by ANY entry: API, QConnect, CLI, natural advance or + /// a seamless hand-off) — warm its successor into the player cache now, + /// while the whole track duration is still ahead. Fired once per current + /// track id. Without it the daemon only warmed after a gapped advance, so + /// every edge after a seamless one was cold and had to fit a whole-file + /// download inside the fixed 10 s gapless window (#699 follow-up, 1 GB Pi). + WarmSuccessor, /// The current track ended and there is a next playable track — run the /// full advance ritual (`playback.rs:4743`). AdvanceAndPlay, @@ -78,6 +87,18 @@ pub enum DriverAction { QueueFinished, } +/// Whether an action may run under the current authority. Playback/queue +/// mutation, owner-side resolution and session persistence are fenced while a +/// delegated renderer owns playback; diagnostics and renderer reports remain +/// live so the guest session stays observable. +fn action_allowed(action: &DriverAction, owner_actions_allowed: bool) -> bool { + owner_actions_allowed + || matches!( + action, + DriverAction::LatchError(_) | DriverAction::ReportEdge + ) +} + /// The previous tick's snapshot: the desktop loop's `last_track_id` / /// `seen_position` / `was_playing` (plus duration for [`DriverState::after`]). #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -86,6 +107,8 @@ pub struct LastTick { pub position: u64, pub duration: u64, pub is_playing: bool, + pub engine_empty_generation: u64, + pub source_failure_generation: u64, } impl LastTick { @@ -95,10 +118,20 @@ impl LastTick { position: ev.position, duration: ev.duration, is_playing: ev.is_playing, + engine_empty_generation: ev.engine_empty_generation, + source_failure_generation: ev.source_failure_generation, } } } +fn report_track_id(ev: &PlaybackEvent) -> u64 { + if !matches!(ev.buffer_state, PlaybackBufferState::Idle) && ev.buffer_track_id != 0 { + ev.buffer_track_id + } else { + ev.track_id + } +} + /// The driver's carried-over state between ticks — the loop-local `mut` /// variables of `start_poll_loop`, hoisted into a value so the decision is a /// pure function of it. @@ -111,12 +144,16 @@ pub struct DriverState { /// Track id an `ArmGapless` already fired for, so the ticker does not /// re-request it every tick (`gapless_requested_for`). pub gapless_requested_for: u64, + /// Track id a `WarmSuccessor` already fired for (once per current track). + pub warmed_for: u64, /// ~4-tick throttle counter for the periodic QConnect report. pub report_tick: u64, /// Last track id we emitted a `ReportEdge` for (`last_reported_track_id`). pub last_reported_track_id: u64, /// Last play-state we emitted a `ReportEdge` for (`last_reported_playing`). pub last_reported_playing: bool, + /// Last player-owned buffer state emitted to QConnect. + pub last_reported_buffer_state: PlaybackBufferState, } impl DriverState { @@ -128,9 +165,11 @@ impl DriverState { last: LastTick::from_event(ev), save_pos_tick: 0, gapless_requested_for: 0, + warmed_for: 0, report_tick: 0, - last_reported_track_id: ev.track_id, + last_reported_track_id: report_track_id(ev), last_reported_playing: ev.is_playing, + last_reported_buffer_state: ev.buffer_state, } } } @@ -191,6 +230,13 @@ pub fn plan_tick( actions.push(DriverAction::SavePosition(ev.position)); } + // 2b. Successor warm-up: a new current track is audible — warm the next + // one once, regardless of which entry started it. Placed BEFORE the + // seamless early-return so a hand-off warms its own successor too. + if ev.is_playing && ev.track_id != 0 && state.warmed_for != ev.track_id { + actions.push(DriverAction::WarmSuccessor); + } + // 3. Seamless gapless transition (playback.rs:4324-4371): the engine advanced // to a new track WITHOUT a stop (track-id change while still playing). // Sync the cursor and STOP — the desktop `continue`s before every block @@ -209,7 +255,8 @@ pub fn plan_tick( // next track pre-queued and none is armed; arm the first playable upcoming // exactly once per current track, suppressed when the current track is // stop-after-marked (so it ends naturally and the marker can fire). - if ev.gapless_ready + if ev.is_playing + && ev.gapless_ready && ev.gapless_next_track_id == 0 && ev.track_id != 0 && state.gapless_requested_for != ev.track_id @@ -222,26 +269,41 @@ pub fn plan_tick( } } - // 5. End-of-track edge (playback.rs:4489-4496): the previous tick was - // playing, this tick is not, the track id held (or went to 0), and the - // previous position was within 2 s of the (current) duration. Uses the - // live `ev.duration` guard + previous `last.position` exactly as the - // desktop's `duration > 0 && seen_position + 2 >= duration`. - let track_ended = last.is_playing + // 5. End-of-track/recovery edge. The engine and CMAF feeder now publish + // durable generations because a complete playing -> stopped pulse can + // fit between two 450 ms ticks. Keep the sampled predicate as a fallback + // for non-CMAF sources, but never require it for an explicit edge. + let engine_empty = ev.engine_empty_generation != last.engine_empty_generation + && ev.engine_empty_track_id != 0 + && ev.engine_empty_track_id == queue.current + && !ev.is_playing + && ev.gapless_next_track_id == 0; + let source_failed = ev.source_failure_generation != last.source_failure_generation + && ev.source_failure_track_id != 0 + && ev.source_failure_track_id == queue.current; + let sampled_track_end = last.is_playing && !ev.is_playing && last.track_id != 0 && (ev.track_id == 0 || ev.track_id == last.track_id) && ev.duration > 0 && last.position + 2 >= ev.duration; + let track_ended = source_failed || engine_empty || sampled_track_end; // 6. QConnect report edge (playback.rs:4648-4673): report on a track/play // transition OR the ~2 s periodic cadence while playing. Runs regardless // of `track_ended` (the desktop report block precedes the advance block). let next_report_tick = state.report_tick.wrapping_add(1); - if ev.track_id != 0 { - let transition = ev.track_id != state.last_reported_track_id - || ev.is_playing != state.last_reported_playing; - let periodic = ev.is_playing && next_report_tick % QCONNECT_REPORT_EVERY_N_TICKS == 0; + let report_track_id = report_track_id(ev); + if report_track_id != 0 { + let transition = report_track_id != state.last_reported_track_id + || ev.is_playing != state.last_reported_playing + || ev.buffer_state != state.last_reported_buffer_state; + let buffer_active = matches!( + ev.buffer_state, + PlaybackBufferState::InitialBuffering | PlaybackBufferState::Underrun + ); + let periodic = (ev.is_playing || buffer_active) + && next_report_tick % QCONNECT_REPORT_EVERY_N_TICKS == 0; if transition || periodic { actions.push(DriverAction::ReportEdge); } @@ -288,6 +350,10 @@ pub fn advance_state( ) }); let reported = actions.iter().any(|a| matches!(a, DriverAction::ReportEdge)); + let warmed = actions + .iter() + .any(|a| matches!(a, DriverAction::WarmSuccessor)); + let warmed_for = if warmed { ev.track_id } else { prev.warmed_for }; // save_pos_tick advances every tick — playback.rs:4305 runs before the // seamless `continue`. @@ -301,19 +367,26 @@ pub fn advance_state( last: LastTick::from_event(ev), save_pos_tick, gapless_requested_for: 0, + warmed_for, report_tick: prev.report_tick, last_reported_track_id: prev.last_reported_track_id, last_reported_playing: prev.last_reported_playing, + last_reported_buffer_state: prev.last_reported_buffer_state, }; } // Non-seamless: the report block runs (playback.rs:4648) so report_tick // advances; the report trackers move only when a ReportEdge fired. let report_tick = prev.report_tick.wrapping_add(1); - let (last_reported_track_id, last_reported_playing) = if reported { - (ev.track_id, ev.is_playing) + let report_track_id = report_track_id(ev); + let (last_reported_track_id, last_reported_playing, last_reported_buffer_state) = if reported { + (report_track_id, ev.is_playing, ev.buffer_state) } else { - (prev.last_reported_track_id, prev.last_reported_playing) + ( + prev.last_reported_track_id, + prev.last_reported_playing, + prev.last_reported_buffer_state, + ) }; // Edge trackers (playback.rs:4676-4700): last_track_id/seen_position update @@ -326,6 +399,8 @@ pub fn advance_state( position: prev.last.position, duration: prev.last.duration, is_playing: ev.is_playing, + engine_empty_generation: ev.engine_empty_generation, + source_failure_generation: ev.source_failure_generation, } }; let mut gapless_requested_for = if armed { @@ -337,7 +412,15 @@ pub fn advance_state( // Track-end handler resets the edge trackers + the gapless guard // (playback.rs:4728-4742, both the stop-after and advance branches). if ended { - last = LastTick::default(); + // Keep the durable player generations consumed while resetting the + // sampled edge fields. Resetting the generations to zero would replay + // the same engine-empty/source-failure edge on every tick until the + // next track surfaced. + last = LastTick { + engine_empty_generation: ev.engine_empty_generation, + source_failure_generation: ev.source_failure_generation, + ..LastTick::default() + }; gapless_requested_for = 0; } @@ -345,9 +428,31 @@ pub fn advance_state( last, save_pos_tick, gapless_requested_for, + warmed_for, report_tick, last_reported_track_id, last_reported_playing, + last_reported_buffer_state, + } +} + +/// Keep delegated observations useful for reports without ever promoting them +/// into the owner's carried playback state. The delegated state is discarded +/// on the first owner-admitted tick; restore then plans only from the last +/// owner snapshot captured before handoff. +fn advance_authority_state( + owner_state: &mut DriverState, + delegated_state: &mut Option, + ev: &PlaybackEvent, + actions: &[DriverAction], + owner_actions_allowed: bool, +) { + if owner_actions_allowed { + *owner_state = advance_state(owner_state, ev, actions); + *delegated_state = None; + } else { + let previous = delegated_state.take().unwrap_or_default(); + *delegated_state = Some(advance_state(&previous, ev, actions)); } } @@ -374,6 +479,19 @@ pub fn quality_from_key(key: &str) -> Quality { pub struct DriverDeps { /// Resolve the streaming quality at play time (qbzd passes the daemon prefs). pub quality: Arc Quality + Send + Sync>, + /// True only while the daemon's owner authority may mutate/resolve/persist. + /// Delegated playback keeps the driver alive for diagnostics and reports, + /// but fences every owner-side action. + pub owner_actions_allowed: Arc bool + Send + Sync>, + /// Atomically classify authority before reading player/queue state. An + /// owner result carries the exact generation and a drain-visible permit; + /// delegated ticks remain report-only, while fenced ticks retry unread. + pub observe_authority: Arc DriverAuthorityObservation + Send + Sync>, + /// Re-admit a spawned continuation only under the exact owner generation + /// that produced it. A guest round-trip must never promote stale work into + /// a later owner merely because owner actions are allowed again. + pub readmit_owner_action: + Arc Option + Send + Sync>, /// Report-edge signal (T10 wires the QConnect renderer report). pub on_edge: Arc, /// Latch a drained error under a category ("stream" | "transport" | "auth"). @@ -382,6 +500,146 @@ pub struct DriverDeps { pub on_tick: Arc, } +/// Type-erased RAII permit supplied by the host's authority coordinator. +/// `qbz-app` needs only the drop boundary; it deliberately knows nothing about +/// the daemon's concrete authority stamps. +pub struct DriverActionPermit { + _inner: Box, +} + +/// Type-erased exact owner token supplied by the host authority coordinator. +/// The concrete token remains opaque to `qbz-app`; only the host callback can +/// downcast and validate it. +#[derive(Clone)] +pub struct DriverOwnerToken(Arc); + +impl DriverOwnerToken { + pub fn new(token: T) -> Self { + Self(Arc::new(token)) + } + + pub fn downcast_ref(&self) -> Option<&T> { + self.0.downcast_ref::() + } +} + +impl fmt::Debug for DriverOwnerToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DriverOwnerToken(..)") + } +} + +/// One exact owner observation plus the live permit covering the tick input. +pub struct DriverOwnerAdmission { + token: DriverOwnerToken, + _permit: DriverActionPermit, +} + +impl DriverOwnerAdmission { + pub fn new(token: DriverOwnerToken, permit: DriverActionPermit) -> Self { + Self { + token, + _permit: permit, + } + } + + fn token(&self) -> DriverOwnerToken { + self.token.clone() + } +} + +/// Host classification captured before a driver tick reads player or queue. +/// A lifecycle fence is distinct from delegated playback: fenced ticks retry +/// without reading or altering either authority's carried state. +pub enum DriverAuthorityObservation { + Owner(DriverOwnerAdmission), + Delegated, + Fenced, +} + +impl DriverActionPermit { + pub fn new(permit: T) -> Self { + Self { + _inner: Box::new(permit), + } + } +} + +/// Result of a gapless byte fetch that ran outside the 450 ms control loop. +/// Keeping the predecessor alongside the successor lets the loop reject a +/// late download after a manual track/queue change instead of appending it to +/// whichever engine happens to be current by then. +struct GaplessFetchResult { + predecessor_id: u64, + successor_id: u64, + bytes: Option>, + /// The successor was appended as an incremental CMAF stream by the + /// player itself (`queue_gapless_streaming`); nothing left to hand over. + streamed: bool, +} + +/// A completed gapless fetch is usable only while both playback and the queue +/// still describe the edge it was started for. `is_playing` is deliberately +/// not required: `PlayNext` can safely land just after natural end and revive +/// the now-empty engine, closing the final download-vs-end race. +fn gapless_fetch_is_current( + result: &GaplessFetchResult, + event: &PlaybackEvent, + queue: &QueueSnapshot, +) -> bool { + event.track_id == result.predecessor_id + && event.gapless_next_track_id == 0 + && queue.current == result.predecessor_id + && queue.stop_after != Some(result.predecessor_id) + && queue.upcoming.first().copied() == Some((result.successor_id, true)) +} + +async fn finish_gapless_fetch( + runtime: &Arc>, + result: GaplessFetchResult, +) { + if result.streamed { + log::info!( + "[qbzd] driver: successor {} queued as a gapless stream after {}", + result.successor_id, + result.predecessor_id + ); + return; + } + if result.bytes.is_none() { + log::warn!( + "[qbzd] driver: gapless fetch for track {} failed", + result.successor_id + ); + return; + } + + let core = runtime.core(); + let queue = queue_snapshot(core).await; + let player = core.player(); + let event = player.get_playback_event(); + if !player.state.has_loaded_audio() || !gapless_fetch_is_current(&result, &event, &queue) { + log::info!( + "[qbzd] driver: discarding stale gapless track {} (predecessor was {})", + result.successor_id, + result.predecessor_id + ); + return; + } + + let bytes = result.bytes.expect("gapless bytes checked above"); + match player.play_next(bytes, result.successor_id) { + Ok(()) => log::info!( + "[qbzd] driver: queued track {} for gapless playback", + result.successor_id + ), + Err(error) => log::warn!( + "[qbzd] driver: gapless play_next for track {} failed: {error}", + result.successor_id + ), + } +} + /// The 450 ms IO shell. Each tick: read the player event, drain the stream-error /// latch, project the queue, `plan_tick`, execute the actions, then /// `advance_state`. Breaks when `shutdown` flips to `true`; the loop is thin by @@ -393,20 +651,76 @@ pub async fn run_driver( mut shutdown: tokio::sync::watch::Receiver, ) { let mut state = DriverState::default(); + let mut delegated_state: Option = None; let mut ticker = tokio::time::interval(Duration::from_millis(TICK_MS)); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Gapless downloads may outlive the fixed ten-second arm window, but they + // must never block the loop that detects natural end and advances the + // queue. JoinSet keeps those tasks owned by the driver so shutdown can + // abort and drain them before AppRuntime is dropped (#521 ordering). + let mut gapless_fetches = tokio::task::JoinSet::new(); + // Successor warm-ups (cache prefetch) are best-effort downloads owned by + // the driver for the same shutdown reason; they never block a tick. + let mut warm_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); loop { tokio::select! { - _ = ticker.tick() => {} - _ = shutdown.changed() => { - if *shutdown.borrow() { + biased; + changed = shutdown.changed() => { + if changed.is_err() || *shutdown.borrow() { break; } continue; } + completed = gapless_fetches.join_next(), if !gapless_fetches.is_empty() => { + match completed { + Some(Ok((owner_token, result))) => { + if let Some(_permit) = (deps.readmit_owner_action)(&owner_token) { + finish_gapless_fetch(&runtime, result).await; + } else { + log::debug!("[qbzd] driver: discarded gapless result from a stale owner authority"); + } + } + Some(Err(error)) if error.is_cancelled() => { + log::debug!("[qbzd] driver: stale gapless fetch cancelled"); + } + Some(Err(error)) => { + log::warn!("[qbzd] driver: gapless fetch task failed: {error}"); + } + None => {} + } + continue; + } + reaped = warm_tasks.join_next(), if !warm_tasks.is_empty() => { + if let Some(Err(error)) = reaped { + if !error.is_cancelled() { + log::warn!("[qbzd] driver: successor warm-up task failed: {error}"); + } + } + continue; + } + _ = ticker.tick() => {} } + // Capture owner admission BEFORE the first player/queue read. If this + // tick begins under guest authority it remains guest-only even when an + // owner restore completes during `queue_snapshot().await`. + let owner_admission = match (deps.observe_authority)() { + DriverAuthorityObservation::Owner(admission) => Some(admission), + DriverAuthorityObservation::Delegated => None, + DriverAuthorityObservation::Fenced => { + // The transition fence is waiting for every exact permit held + // by these network jobs. Abort them on the first fenced tick, + // not only after delegated authority is already installed, or + // a slow prefetch could stretch handoff to the stream timeout. + gapless_fetches.abort_all(); + warm_tasks.abort_all(); + continue; + } + }; + let owner_token = owner_admission + .as_ref() + .map(DriverOwnerAdmission::token); let core = runtime.core(); let player = core.player(); let ev = player.get_playback_event(); @@ -414,22 +728,146 @@ pub async fn run_driver( let stream_error = player.state.take_stream_error_message(); let queue = queue_snapshot(core).await; - let actions = plan_tick(&state, &ev, &queue, stream_error.as_deref()); + let owner_actions_allowed = owner_admission.is_some(); + if owner_actions_allowed && delegated_state.is_some() { + // Rebase on the first fully owner-admitted observation after a + // delegated span. Durable player generations may have advanced + // while the guest was active; comparing those guest edges with the + // pre-handoff owner snapshot could synthesize an owner auto-advance. + state = DriverState::after(&ev); + delegated_state = None; + } + let delegated_baseline = DriverState::default(); + let planning_state = if owner_actions_allowed { + &state + } else { + delegated_state.as_ref().unwrap_or(&delegated_baseline) + }; + let actions = plan_tick(planning_state, &ev, &queue, stream_error.as_deref()); + // Authority can change while an owner prefetch is in flight. Cancel + // those best-effort jobs at the first observed delegated tick; their + // completion paths are fenced independently above. + if !owner_actions_allowed { + gapless_fetches.abort_all(); + warm_tasks.abort_all(); + } + + // Once the engine/queue edge has moved, an outstanding fetch belongs + // to the old predecessor. Drop its network future immediately so it + // cannot contend with the normal next-track start. + if actions.iter().any(|action| { + matches!( + action, + DriverAction::SyncCursorTo(_) + | DriverAction::AdvanceAndPlay + | DriverAction::PauseStopAfter + | DriverAction::QueueFinished + ) + }) { + gapless_fetches.abort_all(); + } + + let mut executed_actions = Vec::with_capacity(actions.len()); for action in &actions { + if !action_allowed(action, owner_actions_allowed) { + continue; + } + executed_actions.push(action.clone()); match action { DriverAction::SyncCursorTo(id) => { core.sync_current_to_id(*id).await; } DriverAction::ArmGapless(id) => { let quality = (deps.quality)(); - if let Some(bytes) = - core.fetch_for_gapless_resolved(*id, quality, None, None).await - { - if let Err(e) = player.play_next(bytes, *id) { - log::warn!("[qbzd] driver: gapless play_next failed: {e}"); + let predecessor_id = ev.track_id; + let successor_id = *id; + let task_runtime = Arc::clone(&runtime); + let task_owner_actions_allowed = Arc::clone(&deps.owner_actions_allowed); + let task_readmit_owner_action = Arc::clone(&deps.readmit_owner_action); + let task_owner_token = owner_token + .as_ref() + .expect("owner action admitted without an owner token") + .clone(); + log::info!( + "[qbzd] driver: fetching gapless track {successor_id} after {predecessor_id}" + ); + let cached = player.is_track_cached(successor_id); + gapless_fetches.spawn(async move { + let Some(_permit) = task_readmit_owner_action(&task_owner_token) else { + return ( + task_owner_token, + GaplessFetchResult { + predecessor_id, + successor_id, + bytes: None, + streamed: false, + }, + ); + }; + let core = task_runtime.core(); + // COLD successor: append its initial CMAF buffer as an + // incremental source (the desktop's streaming + // hand-off) instead of materializing the whole file — + // a Hi-Res track cannot reliably download inside the + // 10 s window on a Pi, and the whole-file `Vec` is + // exactly what a 1 GB box cannot afford. Cached + // successors keep the byte path (no network at all). + if !cached { + match core.queue_gapless_streaming(successor_id, quality).await { + Ok(()) => { + return ( + task_owner_token, + GaplessFetchResult { + predecessor_id, + successor_id, + bytes: None, + streamed: true, + }, + ) + } + Err(error) => log::warn!( + "[qbzd] driver: gapless stream setup for {successor_id} failed: {error}; trying byte fallback" + ), + } } - } + let bytes = if task_owner_actions_allowed() { + core.fetch_for_gapless_resolved(successor_id, quality, None, None) + .await + } else { + None + }; + ( + task_owner_token, + GaplessFetchResult { + predecessor_id, + successor_id, + bytes, + streamed: false, + }, + ) + }); + } + DriverAction::WarmSuccessor => { + let quality = (deps.quality)(); + let task_runtime = Arc::clone(&runtime); + let task_owner_actions_allowed = Arc::clone(&deps.owner_actions_allowed); + let task_readmit_owner_action = Arc::clone(&deps.readmit_owner_action); + let task_owner_token = owner_token + .as_ref() + .expect("owner action admitted without an owner token") + .clone(); + warm_tasks.spawn(async move { + let Some(_permit) = task_readmit_owner_action(&task_owner_token) else { + return; + }; + prefetch_successors( + task_runtime.as_ref(), + quality, + task_owner_actions_allowed.as_ref(), + ) + .await; + }); } DriverAction::PauseStopAfter => { // The ended track is the queue's current track (playback.rs:4708). @@ -476,9 +914,25 @@ pub async fn run_driver( } } - state = advance_state(&state, &ev, &actions); + advance_authority_state( + &mut state, + &mut delegated_state, + &ev, + &executed_actions, + owner_actions_allowed, + ); (deps.on_tick)(); } + warm_tasks.abort_all(); + while warm_tasks.join_next().await.is_some() {} + gapless_fetches.abort_all(); + while let Some(result) = gapless_fetches.join_next().await { + if let Err(error) = result { + if !error.is_cancelled() { + log::warn!("[qbzd] driver: gapless fetch failed during shutdown: {error}"); + } + } + } log::info!("[qbzd] driver: shutting down"); } @@ -547,8 +1001,9 @@ pub async fn advance_and_play( let track_id = track.id; core.play_track_resolved(track_id, quality, None, None, 0) .await?; - // Warm the successors so the next transition can be gapless (best-effort). - prefetch_successors(runtime, quality).await; + // The successor warm-up is the driver's `WarmSuccessor` action (fired on + // the first tick that sees the new track, for EVERY entry point) — never + // awaited here, where a full download used to stall the control loop. // Persist the session (queue + current + position) so a restart resumes. save_session_now(runtime).await; Ok(Some(track)) @@ -560,9 +1015,27 @@ pub async fn advance_and_play( async fn prefetch_successors( runtime: &AppRuntime, quality: Quality, + owner_actions_allowed: &(dyn Fn() -> bool + Send + Sync), ) { + if !owner_actions_allowed() { + return; + } + // Memory-pressure watchdog: critical pressure latched a halt — do not + // start a new download (issue #660). + if crate::memory_watchdog::prefetch_halted() { + log::debug!("[qbzd] driver: prefetch skipped — memory-pressure halt active"); + return; + } + let profile = qbz_core::system_capabilities::memory_profile(); let core = runtime.core(); - let upcoming = core.peek_upcoming(1).await; + // Depth honors the profile's prefetch_count cap. The daemon's + // historical depth is 1 successor per advance, which is within BOTH + // classes' caps (LowMemory 1, Normal 5), so this changes nothing today + // and clamps automatically if the depth ever grows. There is no + // concurrency loop here — the single prefetch below is awaited — so + // max_concurrent_prefetch (>= 1 in both classes) is trivially honored. + let depth = 1usize.min(profile.prefetch_count); + let upcoming = core.peek_upcoming(depth).await; let Some(next) = upcoming.into_iter().next() else { return; }; @@ -573,16 +1046,45 @@ async fn prefetch_successors( if player.is_track_cached(next.id) { return; } + if !owner_actions_allowed() { + return; + } let client_lock = core.client(); let guard = client_lock.read().await; let Some(client) = guard.as_ref() else { return; }; - if let Err(e) = player.prefetch_into_cache(client, next.id, quality).await { + // Quality cap: the LowMemory profile disallows HiRes prefetch entirely + // (~60 MB/track → ~15 MB at Lossless); the watchdog additionally pauses + // HiRes prefetch on low-pressure ticks. This caps only the warm-ahead + // copy — playback of the user's selected quality is unaffected. + let allow_hires = + profile.allow_hires_prefetch && !crate::memory_watchdog::hires_prefetch_paused(); + let prefetch_quality = cap_prefetch_quality(quality, allow_hires); + if !owner_actions_allowed() { + return; + } + if let Err(e) = player + .prefetch_into_cache(client, next.id, prefetch_quality) + .await + { log::debug!("[qbzd] driver: prefetch track {} failed: {e}", next.id); } } +/// Pure prefetch-quality cap: when HiRes prefetch is disallowed (LowMemory +/// profile or a low-pressure watchdog tick), HiRes/UltraHiRes warm-aheads +/// downgrade to Lossless; anything else passes through unchanged. +fn cap_prefetch_quality(requested: Quality, allow_hires: bool) -> Quality { + if allow_hires { + return requested; + } + match requested { + Quality::HiRes | Quality::UltraHiRes => Quality::Lossless, + q => q, + } +} + /// Project the live queue into the decision's [`QueueSnapshot`]. async fn queue_snapshot( core: &QbzCore, @@ -611,9 +1113,7 @@ async fn queue_snapshot( /// `None`). Mirrors `crates/qbz/src/session_persist.rs::capture_and_save`, minus /// the desktop-only `persist_session` gate (the daemon's store IS its queue /// persistence, so it always saves). -pub async fn save_session_now( - runtime: &AppRuntime, -) { +pub async fn save_session_now(runtime: &AppRuntime) { let core = runtime.core(); let (tracks, current_index) = core.get_all_queue_tracks().await; let full = core.get_queue_state_full().await; @@ -739,6 +1239,8 @@ fn from_persisted(t: PersistedQueueTrack) -> QueueTrack { source_item_id_hint: t.source_item_id_hint, context_kind: None, context_id: None, + isrc: None, + recording_mbid: None, } } @@ -755,6 +1257,7 @@ mod tests { duration: dur, track_id: track, volume: 1.0, + hardware_volume_active: false, sample_rate: None, bit_depth: None, shuffle: None, @@ -764,6 +1267,12 @@ mod tests { gapless_next_track_id: 0, bit_perfect_mode: None, buffer_progress: None, + buffer_state: PlaybackBufferState::Idle, + buffer_track_id: 0, + engine_empty_generation: 0, + engine_empty_track_id: 0, + source_failure_generation: 0, + source_failure_track_id: 0, } } @@ -784,22 +1293,210 @@ mod tests { } } + #[test] + fn delegated_authority_fences_every_owner_action() { + let owner_actions = [ + DriverAction::SyncCursorTo(2), + DriverAction::ArmGapless(2), + DriverAction::WarmSuccessor, + DriverAction::AdvanceAndPlay, + DriverAction::PauseStopAfter, + DriverAction::SavePosition(17), + DriverAction::QueueFinished, + ]; + + for action in &owner_actions { + assert!(action_allowed(action, true), "owner rejected {action:?}"); + assert!( + !action_allowed(action, false), + "delegated authority accepted {action:?}" + ); + } + + for action in [ + DriverAction::LatchError("stream failed".to_string()), + DriverAction::ReportEdge, + ] { + assert!(action_allowed(&action, true)); + assert!(action_allowed(&action, false)); + } + } + + #[test] + fn delegated_filter_keeps_diagnostics_in_planned_order() { + let state = DriverState::after(&ev(1, true, 580, 581)); + let planned = plan_tick( + &state, + &ev(1, false, 581, 581), + &q(1, &[(2, true)], "off", None), + Some("stream failed"), + ); + assert!(planned.contains(&DriverAction::AdvanceAndPlay)); + + let executable: Vec<_> = planned + .into_iter() + .filter(|action| action_allowed(action, false)) + .collect(); + assert_eq!( + executable, + vec![ + DriverAction::LatchError("stream failed".to_string()), + DriverAction::ReportEdge, + ] + ); + } + + #[test] + fn delegated_snapshots_never_replace_owner_driver_state() { + let owner_event = ev(11, true, 42, 300); + let mut owner_state = DriverState::after(&owner_event); + let original_owner_state = owner_state.clone(); + let mut delegated_state = None; + let guest_event = ev(99, true, 120, 240); + + advance_authority_state( + &mut owner_state, + &mut delegated_state, + &guest_event, + &[DriverAction::ReportEdge], + false, + ); + + assert_eq!(owner_state, original_owner_state); + assert_eq!( + delegated_state.as_ref().map(|state| state.last.track_id), + Some(99) + ); + + let restored_event = ev(11, true, 43, 300); + advance_authority_state( + &mut owner_state, + &mut delegated_state, + &restored_event, + &[], + true, + ); + assert_eq!(owner_state.last.track_id, 11); + assert_eq!(owner_state.last.position, 43); + assert!(delegated_state.is_none()); + } + #[test] fn end_edge_advances() { let s = DriverState::after(&ev(1, true, 580, 581)); - let a = plan_tick(&s, &ev(1, false, 581, 581), &q(1, &[(2, true)], "off", None), None); + let a = plan_tick( + &s, + &ev(1, false, 581, 581), + &q(1, &[(2, true)], "off", None), + None, + ); assert!(a.contains(&DriverAction::AdvanceAndPlay)); assert!(a.contains(&DriverAction::ReportEdge)); // play-state edge } + #[test] + fn engine_empty_generation_advances_when_playing_pulse_was_missed() { + // Both sampled ticks are stopped. This is the observed 2026-08-28 + // failure: a late gapless resume and engine-empty happened inside one + // polling interval, so `last.is_playing` never became true. + let s = DriverState::after(&ev(1, false, 580, 581)); + let mut current = ev(1, false, 581, 581); + current.engine_empty_generation = 1; + current.engine_empty_track_id = 1; + + let actions = plan_tick(&s, ¤t, &q(1, &[(2, true)], "off", None), None); + assert!(actions.contains(&DriverAction::AdvanceAndPlay)); + } + + #[test] + fn source_failure_advances_even_when_track_never_started() { + let s = DriverState::after(&ev(0, false, 0, 0)); + let mut current = ev(0, false, 0, 0); + current.source_failure_generation = 1; + current.source_failure_track_id = 1; + + let actions = plan_tick( + &s, + ¤t, + &q(1, &[(2, true)], "off", None), + Some("CMAF segment retries exhausted"), + ); + assert!(actions.contains(&DriverAction::LatchError( + "CMAF segment retries exhausted".to_string() + ))); + assert!(actions.contains(&DriverAction::AdvanceAndPlay)); + } + + #[test] + fn stale_source_failure_cannot_skip_the_new_queue_track() { + let s = DriverState::after(&ev(2, false, 0, 581)); + let mut current = ev(2, false, 0, 581); + current.source_failure_generation = 1; + current.source_failure_track_id = 1; + + let actions = plan_tick(&s, ¤t, &q(2, &[(3, true)], "off", None), None); + assert!(!actions.contains(&DriverAction::AdvanceAndPlay)); + } + + #[test] + fn consumed_engine_empty_generation_does_not_advance_twice() { + let s = DriverState::after(&ev(1, false, 580, 581)); + let mut current = ev(1, false, 581, 581); + current.engine_empty_generation = 1; + current.engine_empty_track_id = 1; + let queue = q(1, &[(2, true)], "off", None); + let first = plan_tick(&s, ¤t, &queue, None); + assert!(first.contains(&DriverAction::AdvanceAndPlay)); + + let advanced = advance_state(&s, ¤t, &first); + let second = plan_tick(&advanced, ¤t, &queue, None); + assert!(!second.contains(&DriverAction::AdvanceAndPlay)); + } + + #[test] + fn late_gapless_rescue_consumes_engine_empty_without_advancing() { + let s = DriverState::after(&ev(1, false, 580, 581)); + let mut current = ev(1, true, 581, 581); + current.engine_empty_generation = 1; + current.engine_empty_track_id = 1; + current.gapless_next_track_id = 2; + + let queue = q(1, &[(2, true)], "off", None); + let first = plan_tick(&s, ¤t, &queue, None); + assert!(!first.contains(&DriverAction::AdvanceAndPlay)); + + let advanced = advance_state(&s, ¤t, &first); + let second = plan_tick(&advanced, ¤t, &queue, None); + assert!(!second.contains(&DriverAction::AdvanceAndPlay)); + } + #[test] fn mid_track_pause_does_not_advance() { let s = DriverState::after(&ev(1, true, 100, 581)); - let a = plan_tick(&s, &ev(1, false, 100, 581), &q(1, &[(2, true)], "off", None), None); + let a = plan_tick( + &s, + &ev(1, false, 100, 581), + &q(1, &[(2, true)], "off", None), + None, + ); assert!(!a.contains(&DriverAction::AdvanceAndPlay)); assert!(a.contains(&DriverAction::ReportEdge)); } + #[test] + fn buffer_transition_emits_qconnect_report_edge() { + let mut ready = ev(41, true, 100, 581); + ready.buffer_state = PlaybackBufferState::Ready; + ready.buffer_track_id = 41; + let state = DriverState::after(&ready); + + let mut underrun = ready.clone(); + underrun.buffer_state = PlaybackBufferState::Underrun; + let actions = plan_tick(&state, &underrun, &q(41, &[(42, true)], "off", None), None); + + assert!(actions.contains(&DriverAction::ReportEdge)); + } + #[test] fn stop_after_one_shot() { let s = DriverState::after(&ev(42, true, 580, 581)); @@ -813,6 +1510,42 @@ mod tests { assert!(!a.contains(&DriverAction::AdvanceAndPlay)); } + #[test] + fn warm_successor_fires_once_per_track_for_any_entry() { + // A track appears playing (started by API/QConnect/CLI — the driver + // does not care which): warm once, then stay quiet for that id. + let e = ev(1, true, 3, 581); + let s = DriverState::after(&ev(1, true, 2, 581)); + let queue = q(1, &[(2, true)], "off", None); + let a1 = plan_tick(&s, &e, &queue, None); + assert!(a1.contains(&DriverAction::WarmSuccessor)); + let s2 = advance_state(&s, &e, &a1); + assert_eq!(s2.warmed_for, 1); + let a2 = plan_tick(&s2, &e, &queue, None); + assert!(!a2.contains(&DriverAction::WarmSuccessor)); + // Paused: no warm for a track that is not audible yet. + let paused = ev(5, false, 0, 581); + let a3 = plan_tick(&s2, &paused, &q(5, &[(6, true)], "off", None), None); + assert!(!a3.contains(&DriverAction::WarmSuccessor)); + } + + #[test] + fn seamless_edge_warms_the_new_successor() { + // 1 -> 2 hand-off without a stop: the cursor sync AND a warm for 2's + // successor fire on the same tick (the #699 follow-up: every edge + // after a seamless one used to be cold). + let prev = ev(1, true, 580, 581); + let mut s = DriverState::after(&prev); + s.warmed_for = 1; + let e = ev(2, true, 0, 300); + let a = plan_tick(&s, &e, &q(1, &[(2, true), (3, true)], "off", None), None); + assert!(a.contains(&DriverAction::SyncCursorTo(2))); + assert!(a.contains(&DriverAction::WarmSuccessor)); + let s2 = advance_state(&s, &e, &a); + assert_eq!(s2.warmed_for, 2); + assert_eq!(s2.gapless_requested_for, 0); + } + #[test] fn gapless_arms_exactly_once() { let mut e = ev(1, true, 300, 581); @@ -827,6 +1560,80 @@ mod tests { assert!(!a2.iter().any(|x| matches!(x, DriverAction::ArmGapless(_)))); } + #[test] + fn stopped_track_never_starts_a_gapless_fetch() { + let mut current = ev(1, false, 581, 581); + current.gapless_ready = true; + let previous = DriverState::after(&ev(1, true, 580, 581)); + let actions = plan_tick(&previous, ¤t, &q(1, &[(2, true)], "off", None), None); + assert!(!actions + .iter() + .any(|action| matches!(action, DriverAction::ArmGapless(_)))); + assert!(actions.contains(&DriverAction::AdvanceAndPlay)); + } + + #[test] + fn completed_gapless_fetch_requires_the_original_queue_edge() { + let result = GaplessFetchResult { + predecessor_id: 1, + successor_id: 2, + bytes: None, + streamed: false, + }; + let mut current = ev(1, true, 575, 581); + current.gapless_ready = true; + assert!(gapless_fetch_is_current( + &result, + ¤t, + &q(1, &[(2, true)], "off", None) + )); + + // Natural end still accepts a just-finished download: PlayNext's + // late-arrival path revives the empty engine without replaying track 1. + current.is_playing = false; + assert!(gapless_fetch_is_current( + &result, + ¤t, + &q(1, &[(2, true)], "off", None) + )); + + current.track_id = 2; + assert!(!gapless_fetch_is_current( + &result, + ¤t, + &q(2, &[(3, true)], "off", None) + )); + } + + #[test] + fn completed_gapless_fetch_rejects_changed_successor_and_stop_after() { + let result = GaplessFetchResult { + predecessor_id: 1, + successor_id: 2, + bytes: None, + streamed: false, + }; + let mut current = ev(1, true, 575, 581); + current.gapless_ready = true; + assert!(!gapless_fetch_is_current( + &result, + ¤t, + &q(1, &[(3, true)], "off", None) + )); + assert!(!gapless_fetch_is_current( + &result, + ¤t, + &q(1, &[(2, true)], "off", Some(1)) + )); + + current.gapless_next_track_id = 9; + assert!(!gapless_fetch_is_current( + &result, + ¤t, + &q(1, &[(2, true)], "off", None) + )); + } + #[test] fn repeat_one_advances_instead_of_finishing() { let s = DriverState::after(&ev(1, true, 580, 581)); @@ -838,13 +1645,21 @@ mod tests { #[test] fn queue_finished_when_nothing_playable() { let s = DriverState::after(&ev(1, true, 580, 581)); - let a = plan_tick(&s, &ev(1, false, 581, 581), &q(1, &[(2, false)], "off", None), None); + let a = plan_tick( + &s, + &ev(1, false, 581, 581), + &q(1, &[(2, false)], "off", None), + None, + ); assert!(a.contains(&DriverAction::QueueFinished)); } #[test] fn skip_walk_bounds() { - assert_eq!(next_playable(&[(2, false), (3, false), (4, true)], 50), Some((2, 4))); + assert_eq!( + next_playable(&[(2, false), (3, false), (4, true)], 50), + Some((2, 4)) + ); let all_bad: Vec<(u64, bool)> = (0..60).map(|i| (i, false)).collect(); assert_eq!(next_playable(&all_bad, 50), None); // bounded — never walks forever } @@ -867,7 +1682,12 @@ mod tests { #[test] fn seamless_gapless_transition_syncs_cursor() { let s = DriverState::after(&ev(1, true, 580, 581)); - let a = plan_tick(&s, &ev(2, true, 0, 547), &q(1, &[(2, true)], "off", None), None); + let a = plan_tick( + &s, + &ev(2, true, 0, 547), + &q(1, &[(2, true)], "off", None), + None, + ); assert!(a.contains(&DriverAction::SyncCursorTo(2))); } @@ -886,8 +1706,39 @@ mod tests { #[test] fn duration_zero_never_advances() { let s = DriverState::after(&ev(1, true, 580, 581)); - let a = plan_tick(&s, &ev(1, false, 580, 0), &q(1, &[(2, true)], "off", None), None); + let a = plan_tick( + &s, + &ev(1, false, 580, 0), + &q(1, &[(2, true)], "off", None), + None, + ); assert!(!a.contains(&DriverAction::AdvanceAndPlay)); assert!(a.contains(&DriverAction::ReportEdge)); // play-state edge } + + #[test] + fn prefetch_quality_uncapped_when_hires_allowed() { + // Normal profile, healthy pressure: every tier passes through. + for q in [ + Quality::Mp3, + Quality::Lossless, + Quality::HiRes, + Quality::UltraHiRes, + ] { + assert_eq!(cap_prefetch_quality(q, true), q); + } + } + + #[test] + fn prefetch_quality_caps_hires_to_lossless_when_disallowed() { + // LowMemory profile / low-pressure tick: HiRes warm-aheads shrink. + assert_eq!(cap_prefetch_quality(Quality::HiRes, false), Quality::Lossless); + assert_eq!( + cap_prefetch_quality(Quality::UltraHiRes, false), + Quality::Lossless + ); + // Lower tiers are untouched. + assert_eq!(cap_prefetch_quality(Quality::Lossless, false), Quality::Lossless); + assert_eq!(cap_prefetch_quality(Quality::Mp3, false), Quality::Mp3); + } } diff --git a/crates/qbz-app/src/session_persist.rs b/crates/qbz-app/src/session_persist.rs new file mode 100644 index 000000000..ab1900eb6 --- /dev/null +++ b/crates/qbz-app/src/session_persist.rs @@ -0,0 +1,467 @@ +//! Queue + active-track persistence — SHARED. +//! +//! Lifted out of `crates/qbz/src/session_persist.rs` (the Slint binary), where +//! the only thing tying it to Slint was one type alias: +//! `type Runtime = Arc>`. Everything it touches — +//! `session_store`, `settings::playback`, `shell::AppRuntime`, `qbz_models` — +//! already lives in shared crates, so the Qt port had no session persistence +//! for no structural reason at all. ADR-006. +//! +//! The crash-chain level is injected by the host rather than read from a +//! frontend-local static; this module only needs the numeric level. + +//! What it is: the glue for the `persist_session` / +//! `resume_playback_position` playback preferences. +//! +//! The on-disk store is the frontend-agnostic [`SessionStore`] in +//! `qbz-app` (a per-user `session.db`, already built + tested). This module is +//! the thin frontend glue: it owns a process-global store handle (bound at +//! session activation), captures the live queue + playback state into the +//! persisted snapshot at meaningful edges, and restores it at startup. +//! +//! Gating: `persist_session` (restore the queue/session) and +//! `resume_playback_position` (also restore the exact position) are per-user +//! playback preferences. Both are cached here so the hot save path never reopens +//! the prefs DB; [`set_gates`] refreshes the cache when the toggles change, and +//! [`init_for_user`] seeds them synchronously when the store opens (no race with +//! the async settings snapshot load). +//! +//! Restore is PAUSED: the queue + current track come back — it touches +//! NO protected-audio code beyond threading an existing `start_position_secs`. +//! The saved position rides along via [`take_resume_for`] and is consumed on the +//! first play of the restored track, reusing the player's session-resume offset. +//! Nothing here touches the protected audio path beyond threading an existing +//! `start_position_secs` argument. + + +/// Host-set crash-chain level (see the callers' probe). 1 = clean previous boot. +static CRASH_CHAIN: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(1); + +/// The frontend adapter bound to the runtime. Named locally so the signatures +/// below read as one bound instead of repeating the whole where-clause. +pub trait Adapter: qbz_models::traits::FrontendAdapter + Send + Sync + 'static {} +impl Adapter for T {} + +/// Publish this boot's crash-chain level. Call once, after arming the probe. +pub fn set_crash_chain_level(level: u8) { + CRASH_CHAIN.store(level, std::sync::atomic::Ordering::Relaxed); +} + +fn crash_chain_level() -> u8 { + CRASH_CHAIN.load(std::sync::atomic::Ordering::Relaxed) +} + + +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::session_store::{ + PersistedPlaybackSession, PersistedQueueExtras, PersistedQueueHistoryEntry, + PersistedQueueTrack, PersistedQueueTrackEdition, PersistedSessionSnapshot, + PersistedShellViewState, SessionStore, +}; +use crate::settings::playback::PlaybackPreferencesStore; +use crate::shell::AppRuntime; +use qbz_models::{QueueTrack, RepeatMode}; + +/// Per-user session store, bound at activation (None before login / on failure). +static STORE: Mutex> = Mutex::new(None); +/// Cached `persist_session` gate — captured/restored only when true. +static PERSIST_SESSION: AtomicBool = AtomicBool::new(false); +/// Cached `resume_playback_position` gate — primes [`PENDING_RESUME`] at restore. +static RESUME_POSITION: AtomicBool = AtomicBool::new(false); +/// Position (secs) to resume the restored current track at on the first play. +/// 0 = none. Written at restore (when resume is on); consumed on first play. +static PENDING_RESUME: AtomicU64 = AtomicU64::new(0); +/// Track id the pending resume position applies to, so ONLY the restored current +/// track resumes — playing any other track first starts from 0. 0 = none. +static PENDING_RESUME_TRACK: AtomicU64 = AtomicU64::new(0); +/// Runtime + tokio handle captured at shell entry, so the synchronous window +/// close handlers can flush a final full snapshot before the loop quits. +/// Type-ERASED on purpose. A `static` cannot name `Arc>` now that +/// `A` is a parameter, so what gets stored is the finished job — a closure that +/// already owns the runtime and the handle — instead of the ingredients. +type ExitSaver = Box; +static EXIT_CTX: OnceLock = OnceLock::new(); + +/// Bind the runtime + tokio handle for [`save_on_exit`]. Called once at shell +/// entry (idempotent — later calls are ignored by `OnceLock`). +pub fn bind_exit_ctx(runtime: Arc>, handle: tokio::runtime::Handle) { + let _ = EXIT_CTX.set(Box::new(move || { + handle.block_on(capture_and_save(&runtime)); + })); +} + +/// Flush a final full snapshot synchronously (the window close handlers run on +/// the UI thread, off the tokio runtime, so we `block_on`). No-op until the exit +/// context is bound or unless `persist_session` is on. +pub fn save_on_exit() { + if !persist_enabled() { + return; + } + if let Some(save) = EXIT_CTX.get() { + save(); + } +} + +/// Open the per-user session store and seed the gate flags synchronously from +/// the playback preferences. Called at session activation alongside the other +/// `init_for_user` stores. Failures degrade to "no persistence" (logged). +pub fn init_for_user(base_dir: &Path) { + let opened = match SessionStore::new_at(base_dir) { + Ok(store) => { + *STORE.lock().unwrap() = Some(store); + true + } + Err(e) => { + log::warn!("[session] persist: open failed: {e}"); + *STORE.lock().unwrap() = None; + false + } + }; + // Seed the gates from the per-user playback prefs so capture/restore work + // before the async settings snapshot has had a chance to call set_gates. + match PlaybackPreferencesStore::new_at(base_dir).and_then(|s| s.get_preferences()) { + Ok(prefs) => set_gates(prefs.persist_session, prefs.resume_playback_position), + Err(e) => { + log::warn!("[session] persist: prefs read failed, gates off: {e}"); + set_gates(false, false); + } + } + log::info!( + "[session] persist: init at {} (store_open={opened}, persist={}, resume={})", + base_dir.display(), + PERSIST_SESSION.load(Ordering::Relaxed), + RESUME_POSITION.load(Ordering::Relaxed) + ); +} + +/// Refresh the cached gate flags (called by the Settings toggle handlers and the +/// settings snapshot load so the cache tracks live preference changes). +pub fn set_gates(persist_session: bool, resume_position: bool) { + PERSIST_SESSION.store(persist_session, Ordering::Relaxed); + RESUME_POSITION.store(resume_position, Ordering::Relaxed); +} + +/// Set ONE gate. The two toggles live on different Settings rows and each +/// handler only knows its own new value; making it read the sibling pref back +/// off disk just to call `set_gates` is a round trip that can also disagree +/// with what is on screen. +pub fn set_persist_gate(on: bool) { + PERSIST_SESSION.store(on, Ordering::Relaxed); +} + +/// Sibling of [`set_persist_gate`] for the resume-position row. +pub fn set_resume_gate(on: bool) { + RESUME_POSITION.store(on, Ordering::Relaxed); +} + +/// Whether session persistence is currently enabled. +pub fn persist_enabled() -> bool { + PERSIST_SESSION.load(Ordering::Relaxed) +} + +/// Take + clear the pending resume position IF it applies to `track_id` (the +/// restored current track). Returns the saved position once, then 0 forever — +/// and 0 for any other track, so playing something else first never resumes. +pub fn take_resume_for(track_id: u64) -> u64 { + if track_id != 0 && PENDING_RESUME_TRACK.swap(0, Ordering::Relaxed) == track_id { + PENDING_RESUME.swap(0, Ordering::Relaxed) + } else { + 0 + } +} + +/// Peek the pending resume position WITHOUT consuming it (so the seek bar can be +/// seeded at restore while the actual resume still fires on first play). 0 = none. +pub fn pending_resume_position() -> u64 { + if PENDING_RESUME_TRACK.load(Ordering::Relaxed) != 0 { + PENDING_RESUME.load(Ordering::Relaxed) + } else { + 0 + } +} + +fn repeat_to_str(mode: RepeatMode) -> &'static str { + match mode { + RepeatMode::Off => "off", + RepeatMode::All => "all", + RepeatMode::One => "one", + } +} + +fn repeat_from_str(s: &str) -> RepeatMode { + match s { + "all" => RepeatMode::All, + "one" => RepeatMode::One, + _ => RepeatMode::Off, + } +} + +fn to_persisted(t: &QueueTrack) -> PersistedQueueTrack { + PersistedQueueTrack { + id: t.id, + title: t.title.clone(), + artist: t.artist.clone(), + album: t.album.clone(), + duration_secs: t.duration_secs, + artwork_url: t.artwork_url.clone(), + hires: t.hires, + bit_depth: t.bit_depth, + sample_rate: t.sample_rate, + is_local: t.is_local, + album_id: t.album_id.clone(), + artist_id: t.artist_id, + streamable: t.streamable, + source: t.source.clone(), + parental_warning: t.parental_warning, + source_item_id_hint: t.source_item_id_hint.clone(), + } +} + +fn from_persisted(t: PersistedQueueTrack) -> QueueTrack { + QueueTrack { + id: t.id, + title: t.title, + // The portable session shape predates edition subtitles. The additive + // queue-extras snapshot patches these two fields after conversion. + version: None, + artist: t.artist, + album: t.album, + album_version: None, + duration_secs: t.duration_secs, + artwork_url: t.artwork_url, + hires: t.hires, + bit_depth: t.bit_depth, + sample_rate: t.sample_rate, + is_local: t.is_local, + album_id: t.album_id, + artist_id: t.artist_id, + streamable: t.streamable, + source: t.source, + parental_warning: t.parental_warning, + source_item_id_hint: t.source_item_id_hint, + // Not persisted in the session schema — a restored track carries no + // container origin, so the "playing from" button falls back to the + // track's own album until the next container play re-stamps the queue. + context_kind: None, + context_id: None, + isrc: None, + recording_mbid: None, + } +} + +/// Capture the live queue + playback state and persist it. No-op unless +/// `persist_session` is on and the store is open. Async (reads the queue lock). +pub async fn capture_and_save(runtime: &Arc>) { + if !persist_enabled() { + return; + } + let (tracks, current_index, history_indices) = + runtime.core().get_persistable_queue_state().await; + // Crash-chain level >=3 bypassed the restore this boot, so the queue on + // disk is the GOOD copy the user wants back on a healthy start — don't + // clobber it with this session's empty queue at exit. A queue the user + // actually built during the recovered boot still saves normally. + if tracks.is_empty() && crash_chain_level() >= 3 { + log::info!( + "[session] persist: crash-chain recovery boot with empty queue — \ + keeping the preserved snapshot on disk" + ); + return; + } + let full = runtime.core().get_queue_state_full().await; + let pb = runtime.core().get_playback_state(); + // POSITION: the engine reports 0 until something actually loads, so a + // session that was restored and then closed WITHOUT playing would write + // that idle 0 over the resume point the restore had just armed — one + // open-and-close and the user silently loses their place, with the queue + // still there to make it look like persistence worked. + // + // An UNCONSUMED pending resume is the truthful value in exactly that case. + // `pending_resume_position` self-clears the moment the first play takes it + // (`take_resume_for` zeroes the track id), so this can never resurrect a + // stale position: once the track has played, the engine's own number wins. + let position = if pb.position == 0 && !pb.is_playing { + let pending = pending_resume_position(); + if pending > 0 { + log::info!( + "[session] persist: keeping the un-consumed resume position {pending}s (engine idle at 0 — nothing was played this session)" + ); + } + pending + } else { + pb.position + }; + let snapshot = PersistedSessionSnapshot { + playback: PersistedPlaybackSession { + queue_tracks: tracks.iter().map(to_persisted).collect(), + current_index, + current_position_secs: position, + volume: pb.volume, + shuffle_enabled: full.shuffle, + repeat_mode: repeat_to_str(full.repeat).to_string(), + was_playing: pb.is_playing, + saved_at: 0, // set inside save_session + }, + // Shell-view restoration is handled separately (ui_prefs startup_page); + // keep the Tauri view columns at their defaults so the schema round-trips. + shell_view: PersistedShellViewState::default(), + }; + let extras = PersistedQueueExtras { + editions: tracks + .iter() + .enumerate() + .map(|(position, track)| PersistedQueueTrackEdition { + position, + track_id: track.id, + version: track.version.clone(), + album_version: track.album_version.clone(), + }) + .collect(), + history: history_indices + .into_iter() + .filter_map(|position| { + tracks + .get(position) + .map(|track| PersistedQueueHistoryEntry { + position, + track_id: track.id, + }) + }) + .collect(), + }; + let track_count = snapshot.playback.queue_tracks.len(); + let history_count = extras.history.len(); + if let Some(store) = STORE.lock().unwrap().as_ref() { + match store.save_session(&snapshot) { + Ok(()) => match store.save_queue_extras(&extras) { + Ok(()) => log::info!( + "[session] persist: saved {track_count} queue tracks and {history_count} history entries (pos {}s, playing {})", + snapshot.playback.current_position_secs, + snapshot.playback.was_playing + ), + Err(e) => log::warn!("[session] persist: queue extras save failed: {e}"), + }, + Err(e) => log::warn!("[session] persist: save failed: {e}"), + } + } else { + log::warn!("[session] persist: capture skipped (store not open)"); + } +} + +/// Quick position-only save (a single cheap UPDATE) — for the poll loop's +/// throttled tick and the pause edge, so a crash keeps a near-current position. +pub fn save_position(position_secs: u64) { + if !persist_enabled() { + return; + } + if let Some(store) = STORE.lock().unwrap().as_ref() { + let _ = store.save_position(position_secs); + } +} + +/// Restore the persisted queue at startup. Returns true if a non-empty queue was +/// restored (so the caller refreshes the now-playing bar). Restores PAUSED; when +/// `resume_playback_position` is on, primes [`PENDING_RESUME`] for Phase B. +pub async fn restore(runtime: &Arc>) -> bool { + if !persist_enabled() { + log::info!("[session] persist: restore skipped (persist_session off)"); + return false; + } + let (snapshot, extras) = { + let guard = STORE.lock().unwrap(); + let Some(store) = guard.as_ref() else { + log::warn!("[session] persist: restore skipped (store not open)"); + return false; + }; + let snapshot = match store.load_session() { + Ok(s) => s, + Err(e) => { + log::warn!("[session] persist: load failed: {e}"); + return false; + } + }; + let extras = match store.load_queue_extras() { + Ok(extras) => extras, + Err(e) => { + log::warn!("[session] persist: queue extras load failed: {e}"); + PersistedQueueExtras::default() + } + }; + (snapshot, extras) + }; + let pb_sess = snapshot.playback; + if pb_sess.queue_tracks.is_empty() { + log::info!("[session] persist: nothing to restore (saved queue is empty)"); + return false; + } + let position = pb_sess.current_position_secs; + let count = pb_sess.queue_tracks.len(); + let index = pb_sess.current_index; + let mut tracks: Vec = pb_sess + .queue_tracks + .into_iter() + .map(from_persisted) + .collect(); + // Extras and the portable snapshot are committed independently for + // backward compatibility. Accept them only when every saved position/id + // still names this exact queue; this prevents a crash between commits from + // attaching stale editions or history to a newer session. + let extras_match = extras.editions.len() == tracks.len() + && extras.editions.iter().all(|edition| { + tracks + .get(edition.position) + .is_some_and(|track| track.id == edition.track_id) + }); + let restored_history = if extras_match { + for edition in &extras.editions { + if let Some(track) = tracks.get_mut(edition.position) { + track.version = edition.version.clone(); + track.album_version = edition.album_version.clone(); + } + } + extras + .history + .iter() + .filter_map(|entry| { + tracks + .get(entry.position) + .filter(|track| track.id == entry.track_id) + .map(|_| entry.position) + }) + .collect::>() + } else { + if !extras.editions.is_empty() || !extras.history.is_empty() { + log::warn!("[session] persist: stale queue extras discarded"); + } + Vec::new() + }; + // The current track's id, so the resume position is applied ONLY when this + // exact track is the first one played after the restore. + let current_track_id = index.and_then(|i| tracks.get(i)).map(|t| t.id).unwrap_or(0); + runtime + .core() + .set_queue_with_order(tracks, index, pb_sess.shuffle_enabled, None) + .await; + let history_count = restored_history.len(); + runtime.core().restore_queue_history(restored_history).await; + runtime + .core() + .set_repeat_mode(repeat_from_str(&pb_sess.repeat_mode)) + .await; + // The queue session carries the authoritative last volume; apply it to the + // player (the slider also seeds from ui_prefs, but this keeps them in step). + let _ = runtime.core().set_volume(pb_sess.volume); + if RESUME_POSITION.load(Ordering::Relaxed) && position > 0 && current_track_id != 0 { + PENDING_RESUME.store(position, Ordering::Relaxed); + PENDING_RESUME_TRACK.store(current_track_id, Ordering::Relaxed); + } + log::info!( + "[session] persist: restored {count} queue tracks and {} history entries (index {index:?}), paused; \ + resume position {position}s (consumed on first play when enabled)", + history_count + ); + true +} diff --git a/crates/qbz-app/src/session_store.rs b/crates/qbz-app/src/session_store.rs index 084f7fb10..31833c30a 100644 --- a/crates/qbz-app/src/session_store.rs +++ b/crates/qbz-app/src/session_store.rs @@ -30,6 +30,14 @@ pub struct PersistedQueueTrack { pub is_local: bool, pub album_id: Option, pub artist_id: Option, + /// Resolved availability — absence was already interpreted upstream by + /// `qbz_models::Track::is_streamable`, so this stays a plain `bool` while + /// the catalog model is an `Option`, and `default_streamable` (TRUE) + /// covers a JSON snapshot written before the column existed. Values written + /// before the model became tri-state are untrustworthy and are cleared once + /// by the `user_version = 1` migration in `open_at`; from that point on + /// `false` here means only "Qobuz said no", which is a state a DOWNLOADED + /// track legitimately persists in. #[serde(default = "default_streamable")] pub streamable: bool, #[serde(default)] @@ -52,6 +60,31 @@ pub struct PersistedPlaybackSession { pub saved_at: i64, } +/// Additive queue fields that the legacy portable session shape cannot carry +/// without breaking older frontend struct literals. Positions refer to the +/// simultaneously persisted `queue_tracks` vector. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedQueueTrackEdition { + pub position: usize, + pub track_id: u64, + pub version: Option, + pub album_version: Option, +} + +/// One oldest-first play-history entry. `track_id` validates that `position` +/// still refers to the same queue snapshot before the entry is restored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedQueueHistoryEntry { + pub position: usize, + pub track_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PersistedQueueExtras { + pub editions: Vec, + pub history: Vec, +} + impl Default for PersistedPlaybackSession { fn default() -> Self { Self { @@ -153,6 +186,19 @@ impl SessionStore { source TEXT ); + CREATE TABLE IF NOT EXISTS queue_track_extras ( + position INTEGER PRIMARY KEY, + track_id INTEGER NOT NULL, + version TEXT, + album_version TEXT + ); + + CREATE TABLE IF NOT EXISTS queue_history ( + sequence INTEGER PRIMARY KEY, + position INTEGER NOT NULL, + track_id INTEGER NOT NULL + ); + INSERT OR IGNORE INTO player_state (id, current_position_secs, volume, shuffle_enabled, repeat_mode, was_playing, saved_at) VALUES (1, 0, 0.75, 0, 'off', 0, 0); ", @@ -252,6 +298,51 @@ impl SessionStore { ); } + // ── One-shot: distrust every `streamable` written before the model + // became tri-state ──────────────────────────────────────────────── + // + // `queue_tracks.streamable` is persisted from `QueueTrack.streamable`, + // which was fed from `qbz_models::Track.streamable` — a plain `bool` + // whose `#[serde(default)]` turned an ABSENT key into `false`. So a row + // queued from any endpoint that omits the key was stored as `0` meaning + // "the payload was terse", not "Qobuz pulled this track". + // + // The unavailability work gives `0` a new, destructive meaning: an + // unavailable track never enters the queue. Left alone, those legacy + // zeroes would silently delete tracks from every restored session, for + // good — on a player whose entire point is not losing the user's music. + // + // The original value is unrecoverable (nothing on disk records WHY a + // row is 0), so the only honest repair is to clear the poison once and + // let the live API re-establish the truth on the next listing fetch, + // with the reactive auto-skip as the backstop for anything genuinely + // dead. Erring toward "available" here is the same asymmetry + // `Track::is_streamable` documents: a wasted round trip is cheap, a + // vanished track is not. + // + // It must be ONE-SHOT rather than a coercion on read, because after the + // model change a `0` is meaningful again: a track Qobuz pulled but the + // user already DOWNLOADED stays in the queue and plays from disk, and + // it stays there with `streamable = 0`. Coercing on read would + // resurrect that row as available forever and re-hide the very state + // the render is meant to distinguish. + // + // `PRAGMA user_version` is the guard — it is 0 on every database that + // predates this build and on a freshly created one, where the UPDATE + // simply matches no rows. The column-probe idiom used by the migrations + // above cannot express this: nothing is being added, only rewritten. + let schema_version: i64 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap_or(0); + if schema_version < 1 { + let _ = conn.execute_batch( + " + UPDATE queue_tracks SET streamable = 1 WHERE streamable = 0; + PRAGMA user_version = 1; + ", + ); + } + Ok(Self { conn }) } @@ -269,6 +360,14 @@ impl SessionStore { let _ = self.conn.execute("ROLLBACK", []); return Err(format!("Failed to clear queue: {}", e)); } + if let Err(e) = self.conn.execute("DELETE FROM queue_track_extras", []) { + let _ = self.conn.execute("ROLLBACK", []); + return Err(format!("Failed to clear queue track extras: {e}")); + } + if let Err(e) = self.conn.execute("DELETE FROM queue_history", []) { + let _ = self.conn.execute("ROLLBACK", []); + return Err(format!("Failed to clear queue history: {e}")); + } for (pos, track) in session.playback.queue_tracks.iter().enumerate() { if let Err(e) = self.conn.execute( @@ -431,6 +530,95 @@ impl SessionStore { }) } + /// Persist edition subtitles and oldest-first playback history alongside + /// the portable session snapshot. Kept additive so older frontends that + /// construct `PersistedPlaybackSession` remain source-compatible. + pub fn save_queue_extras(&self, extras: &PersistedQueueExtras) -> Result<(), String> { + self.conn + .execute("BEGIN TRANSACTION", []) + .map_err(|e| format!("Failed to begin queue-extras transaction: {e}"))?; + + let save = (|| -> Result<(), String> { + self.conn + .execute("DELETE FROM queue_track_extras", []) + .map_err(|e| format!("Failed to clear queue track extras: {e}"))?; + self.conn + .execute("DELETE FROM queue_history", []) + .map_err(|e| format!("Failed to clear queue history: {e}"))?; + + for edition in &extras.editions { + self.conn + .execute( + "INSERT INTO queue_track_extras (position, track_id, version, album_version) VALUES (?1, ?2, ?3, ?4)", + params![ + edition.position as i64, + edition.track_id as i64, + edition.version.as_deref(), + edition.album_version.as_deref(), + ], + ) + .map_err(|e| format!("Failed to save queue track extra: {e}"))?; + } + + for (sequence, entry) in extras.history.iter().enumerate() { + self.conn + .execute( + "INSERT INTO queue_history (sequence, position, track_id) VALUES (?1, ?2, ?3)", + params![sequence as i64, entry.position as i64, entry.track_id as i64], + ) + .map_err(|e| format!("Failed to save queue history: {e}"))?; + } + Ok(()) + })(); + + if let Err(error) = save { + let _ = self.conn.execute("ROLLBACK", []); + return Err(error); + } + self.conn + .execute("COMMIT", []) + .map_err(|e| format!("Failed to commit queue-extras transaction: {e}"))?; + Ok(()) + } + + pub fn load_queue_extras(&self) -> Result { + let mut editions_stmt = self + .conn + .prepare( + "SELECT position, track_id, version, album_version FROM queue_track_extras ORDER BY position", + ) + .map_err(|e| format!("Failed to prepare queue track extras query: {e}"))?; + let editions = editions_stmt + .query_map([], |row| { + Ok(PersistedQueueTrackEdition { + position: row.get::<_, i64>(0)? as usize, + track_id: row.get::<_, i64>(1)? as u64, + version: row.get(2)?, + album_version: row.get(3)?, + }) + }) + .map_err(|e| format!("Failed to query queue track extras: {e}"))? + .collect::, _>>() + .map_err(|e| format!("Failed to read queue track extras: {e}"))?; + + let mut history_stmt = self + .conn + .prepare("SELECT position, track_id FROM queue_history ORDER BY sequence") + .map_err(|e| format!("Failed to prepare queue history query: {e}"))?; + let history = history_stmt + .query_map([], |row| { + Ok(PersistedQueueHistoryEntry { + position: row.get::<_, i64>(0)? as usize, + track_id: row.get::<_, i64>(1)? as u64, + }) + }) + .map_err(|e| format!("Failed to query queue history: {e}"))? + .collect::, _>>() + .map_err(|e| format!("Failed to read queue history: {e}"))?; + + Ok(PersistedQueueExtras { editions, history }) + } + pub fn save_position(&self, position_secs: u64) -> Result<(), String> { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -473,6 +661,12 @@ impl SessionStore { self.conn .execute("DELETE FROM queue_tracks", []) .map_err(|e| format!("Failed to clear queue: {}", e))?; + self.conn + .execute("DELETE FROM queue_track_extras", []) + .map_err(|e| format!("Failed to clear queue track extras: {e}"))?; + self.conn + .execute("DELETE FROM queue_history", []) + .map_err(|e| format!("Failed to clear queue history: {e}"))?; self.conn.execute( "UPDATE player_state SET current_index = NULL, current_position_secs = 0, was_playing = 0, last_view = 'home', view_context_id = NULL, view_context_type = NULL WHERE id = 1", @@ -591,8 +785,124 @@ mod tests { assert!(loaded.playback.was_playing); assert!(loaded.playback.saved_at > 0); assert_eq!(loaded.shell_view.last_view, "album"); - assert_eq!(loaded.shell_view.view_context_id.as_deref(), Some("album-1")); - assert_eq!(loaded.shell_view.view_context_type.as_deref(), Some("album")); + assert_eq!( + loaded.shell_view.view_context_id.as_deref(), + Some("album-1") + ); + assert_eq!( + loaded.shell_view.view_context_type.as_deref(), + Some("album") + ); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn session_store_round_trips_queue_editions_and_history() { + let dir = unique_test_dir("session-queue-extras"); + let store = SessionStore::new_at(&dir).expect("open store"); + let session = PersistedSessionSnapshot { + playback: PersistedPlaybackSession { + queue_tracks: vec![sample_track()], + current_index: Some(0), + ..PersistedPlaybackSession::default() + }, + shell_view: PersistedShellViewState::default(), + }; + let extras = PersistedQueueExtras { + editions: vec![PersistedQueueTrackEdition { + position: 0, + track_id: 42, + version: Some("Backing Track / Bonus Track".to_string()), + album_version: Some("Remastered 2014".to_string()), + }], + history: vec![PersistedQueueHistoryEntry { + position: 0, + track_id: 42, + }], + }; + + store.save_session(&session).expect("save session"); + store.save_queue_extras(&extras).expect("save queue extras"); + drop(store); + + let reopened = SessionStore::new_at(&dir).expect("reopen store"); + assert_eq!( + reopened.load_queue_extras().expect("load queue extras"), + extras + ); + + reopened.clear_session().expect("clear session"); + assert_eq!( + reopened.load_queue_extras().expect("load cleared extras"), + PersistedQueueExtras::default() + ); + + let _ = std::fs::remove_dir_all(dir); + } + + /// The `user_version = 1` repair clears legacy `streamable = 0` rows ONCE, + /// and never fires again. + /// + /// Both halves matter and they pull in opposite directions. A `0` written + /// by an older build is untrustworthy — it may only mean the endpoint was + /// terse — and under the new queue filter it would silently delete the + /// track from every restored session. A `0` written AFTER the repair is + /// real: it is a track Qobuz pulled that the user has downloaded, which + /// stays in the queue and plays from disk. A coercion on read would satisfy + /// the first half and destroy the second, which is why this is a migration. + #[test] + fn legacy_streamable_zeroes_are_cleared_once_then_honoured() { + let dir = unique_test_dir("session-streamable-migration"); + + let session_with_sample = || { + let mut session = PersistedSessionSnapshot::default(); + session.playback.queue_tracks = vec![sample_track()]; + session.playback.current_index = Some(0); + session + }; + + // A database as an older build left it: a row at `streamable = 0` and + // no schema stamp. Rewinding the pragma is what makes it "older" — + // `sample_track()` already carries `streamable: false`. + { + let store = SessionStore::new_at(&dir).expect("open store"); + store + .save_session(&session_with_sample()) + .expect("save legacy session"); + store + .conn + .execute_batch("PRAGMA user_version = 0;") + .expect("rewind schema stamp"); + } + + // Reopening runs the repair: the untrustworthy 0 is cleared. + { + let store = SessionStore::new_at(&dir).expect("reopen store"); + let loaded = store.load_session().expect("load repaired session"); + assert!( + loaded.playback.queue_tracks[0].streamable, + "a pre-migration 0 is untrustworthy and must be cleared, not \ + read as 'Qobuz pulled this track'" + ); + + // Now write a 0 that IS trustworthy — the downloaded-but-pulled + // state the render distinguishes. + store + .save_session(&session_with_sample()) + .expect("save post-migration session"); + } + + // Reopening again must leave it alone: the repair is one-shot. + { + let store = SessionStore::new_at(&dir).expect("reopen store again"); + let loaded = store.load_session().expect("load session"); + assert!( + !loaded.playback.queue_tracks[0].streamable, + "the repair must not fire twice — a post-migration 0 is real \ + and resurrecting it would hide a downloaded-but-pulled track" + ); + } let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/qbz-app/src/settings/album_play_history.rs b/crates/qbz-app/src/settings/album_play_history.rs index 28ce4cf2a..d86b35dde 100644 --- a/crates/qbz-app/src/settings/album_play_history.rs +++ b/crates/qbz-app/src/settings/album_play_history.rs @@ -52,30 +52,46 @@ fn init_schema(conn: &Connection) -> rusqlite::Result<()> { updated_at INTEGER NOT NULL ); "#, - ) + )?; + // PLAYBACK CONTEXT of the event — "album" | "playlist" | "" (unknown). + // + // Additive, and it has to be: this .db is shared with existing installs, + // whose tree is frozen and whose INSERT names its columns explicitly + // (`INSERT INTO album_play_events (album_id, occurred_at)`), so an extra + // column with a DEFAULT is invisible to it and it simply writes `''`. + // Every legacy row is `''` too, and the ranking query treats `''` as + // "album" — so no history is lost or reinterpreted by this migration. + // + // SQLite has no `ADD COLUMN IF NOT EXISTS`; running it and swallowing the + // duplicate-column error is the idiom, and it is cheaper than a + // `PRAGMA table_info` round trip on every open. + let _ = conn.execute_batch( + "ALTER TABLE album_play_events ADD COLUMN context_kind TEXT NOT NULL DEFAULT '';", + ); + Ok(()) } fn open_db() -> Option { let path = db_path()?; if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { - log::warn!("[qbz-slint] album_play_history dir create failed: {e}"); + log::warn!("[qbz-app] album_play_history dir create failed: {e}"); return None; } } let conn = match Connection::open(&path) { Ok(c) => c, Err(e) => { - log::warn!("[qbz-slint] album_play_history open failed: {e}"); + log::warn!("[qbz-app] album_play_history open failed: {e}"); return None; } }; // ADR-002: WAL for any SQLite store touched off the UI thread. - if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") { - log::warn!("[qbz-slint] album_play_history pragma failed: {e}"); + if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") { + log::warn!("[qbz-app] album_play_history pragma failed: {e}"); } if let Err(e) = init_schema(&conn) { - log::warn!("[qbz-slint] album_play_history schema failed: {e}"); + log::warn!("[qbz-app] album_play_history schema failed: {e}"); return None; } Some(conn) @@ -103,6 +119,11 @@ pub struct AlbumPlayMeta<'a> { pub quality_label: &'a str, pub year: &'a str, pub source: &'a str, + /// What the user was PLAYING FROM when this event happened: `"album"`, + /// `"playlist"`, or `""` when nothing was stamped. NOT the same axis as + /// [`Self::source`], which is where the audio comes from (qobuz / local / + /// plex) — an album can be local AND played from a playlist. + pub context_kind: &'a str, } /// One ranked album for the "Most Played Albums" rail / View-all page. @@ -124,10 +145,11 @@ pub struct AlbumPlayRow { /// Internal so tests can drive it against an in-memory connection. fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) { if let Err(e) = conn.execute( - "INSERT INTO album_play_events (album_id, occurred_at) VALUES (?, ?)", - params![m.album_id, now], + "INSERT INTO album_play_events (album_id, occurred_at, context_kind) \ + VALUES (?, ?, ?)", + params![m.album_id, now, m.context_kind], ) { - log::warn!("[qbz-slint] album_play_history insert event failed: {e}"); + log::warn!("[qbz-app] album_play_history insert event failed: {e}"); } if let Err(e) = conn.execute( r#" @@ -159,7 +181,7 @@ fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) { now ], ) { - log::warn!("[qbz-slint] album_play_history upsert meta failed: {e}"); + log::warn!("[qbz-app] album_play_history upsert meta failed: {e}"); } } @@ -174,6 +196,13 @@ fn query_on(conn: &Connection, limit: Option) -> Vec { JOIN ( SELECT album_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at FROM album_play_events + -- Only plays that were ABOUT the album. A playlist of 40 tracks + -- from 40 albums used to add one play to each of them, which is + -- how "Most Played Albums" came to be a list of whatever playlist + -- the user had on. `''` is a legacy row or one written by the + -- other frontend, and is counted — dropping it would erase + -- history that is almost entirely genuine album listening. + WHERE context_kind IN ('', 'album') GROUP BY album_id ) p ON p.album_id = m.album_id ORDER BY p.plays DESC, p.last_at DESC @@ -207,7 +236,6 @@ fn query_on(conn: &Connection, limit: Option) -> Vec { /// Record a play. Called from `playback::record_recent` when a track starts /// audible playback. No-op when the album id is empty (some local/Plex /// sources carry none — same guard as the recently-played rail). -#[allow(dead_code)] // wired by playback::record_recent pub fn record_album_play(m: AlbumPlayMeta) { if m.album_id.is_empty() { return; @@ -223,17 +251,45 @@ pub fn record_album_play(m: AlbumPlayMeta) { } /// The top `limit` most-played albums (the carousel). -#[allow(dead_code)] // wired by home/foryou pub fn top_albums(limit: u32) -> Vec { with_db(|conn| Some(query_on(conn, Some(limit)))).unwrap_or_default() } /// Every played album, ranked (the "View all" page). -#[allow(dead_code)] // wired by the View-all loader pub fn all_albums() -> Vec { with_db(|conn| Some(query_on(conn, None))).unwrap_or_default() } +/// Drop every trace of the given albums — the twin of +/// `playlist_play_history::prune_playlists`, for albums whose library folder +/// was removed. Until this existed "Most Played Albums" kept cards that +/// opened onto deleted tracks while Recently Played had already pruned them. +/// Returns how many meta rows went. +pub fn prune_albums(album_ids: &[String]) -> usize { + if album_ids.is_empty() { + return 0; + } + with_db(|conn| Some(prune_on(conn, album_ids))) + .unwrap_or(0) +} + +fn prune_on(conn: &Connection, album_ids: &[String]) -> usize { + let mut gone = 0usize; + for id in album_ids { + let _ = conn.execute( + "DELETE FROM album_play_events WHERE album_id = ?", + rusqlite::params![id], + ); + gone += conn + .execute( + "DELETE FROM album_meta WHERE album_id = ?", + rusqlite::params![id], + ) + .unwrap_or(0); + } + gone +} + #[cfg(test)] mod tests { use super::*; @@ -249,6 +305,7 @@ mod tests { quality_label: "Hi-Res", year: "2024", source: "qobuz", + context_kind: "album", } } diff --git a/crates/qbz-app/src/settings/artist_blacklist.rs b/crates/qbz-app/src/settings/artist_blacklist.rs index cd54d824c..a717f8413 100644 --- a/crates/qbz-app/src/settings/artist_blacklist.rs +++ b/crates/qbz-app/src/settings/artist_blacklist.rs @@ -84,7 +84,7 @@ impl BlacklistService { .map_err(|e| format!("Failed to open blacklist database: {}", e))?; // Enable WAL mode for better concurrent access (ADR-002). - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to set WAL mode: {}", e))?; let service = Self { @@ -179,7 +179,7 @@ impl BlacklistService { .map_err(|e| format!("Failed to prepare blacklist query: {}", e))?; let ids: Vec = stmt - .query_map([], |row| row.get(0)) + .query_map([], |row| row.get::<_, i64>(0).map(|v| v as u64)) .map_err(|e| format!("Failed to query blacklist: {}", e))? .filter_map(|r| r.ok()) .collect(); diff --git a/crates/qbz-app/src/settings/blacklist_portable.rs b/crates/qbz-app/src/settings/blacklist_portable.rs new file mode 100644 index 000000000..341d11a41 --- /dev/null +++ b/crates/qbz-app/src/settings/blacklist_portable.rs @@ -0,0 +1,275 @@ +//! A portable blacklist: the blocked artists and albums as one JSON file a +//! user can hand to another QBZ user, or carry to another account. +//! +//! Frontend-agnostic on purpose (ADR 006): the Qt shell and the daemon both +//! read and write the same document. The import is ADDITIVE — it never +//! removes an entry, never rewrites one that is already blocked, and never +//! touches the enabled flag — so importing a friend's file twice, or your +//! own old file over a newer list, is always safe. + +use serde::{Deserialize, Serialize}; + +use super::artist_blacklist::BlacklistService; + +/// The `kind` discriminator; a file without it is not ours. +pub const KIND: &str = "qbz-blacklist"; +/// Bumped only when a reader would misread an older file. Readers accept +/// any version up to their own. +pub const SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortableArtist { + pub artist_id: u64, + pub artist_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortableAlbum { + pub album_id: String, + pub album_title: String, + #[serde(default)] + pub artist_name: String, + #[serde(default)] + pub cover_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlacklistBundle { + pub kind: String, + pub schema_version: u32, + /// RFC 3339, informational. + pub created_at: String, + #[serde(default)] + pub artists: Vec, + #[serde(default)] + pub albums: Vec, +} + +/// What an import did. `existing` entries were already blocked and were +/// left exactly as they were (their own notes and dates win). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportReport { + pub artists_added: usize, + pub artists_existing: usize, + pub albums_added: usize, + pub albums_existing: usize, +} + +impl ImportReport { + pub fn added(&self) -> usize { + self.artists_added + self.albums_added + } + pub fn existing(&self) -> usize { + self.artists_existing + self.albums_existing + } +} + +/// Snapshot the store into a bundle. Ids, names and notes only: the +/// `added_at` stamps stay local (the importer stamps its own). +pub fn export(service: &BlacklistService) -> Result { + let artists = service + .get_all()? + .into_iter() + .map(|a| PortableArtist { + artist_id: a.artist_id, + artist_name: a.artist_name, + notes: a.notes, + }) + .collect(); + let albums = service + .get_all_albums()? + .into_iter() + .map(|a| PortableAlbum { + album_id: a.album_id, + album_title: a.album_title, + artist_name: a.artist_name, + cover_url: a.cover_url, + notes: a.notes, + }) + .collect(); + Ok(BlacklistBundle { + kind: KIND.to_string(), + schema_version: SCHEMA_VERSION, + created_at: chrono::Utc::now().to_rfc3339(), + artists, + albums, + }) +} + +/// Parse and validate a bundle. A file of another kind or from a newer +/// schema is refused rather than half-read. +pub fn parse(text: &str) -> Result { + let bundle: BlacklistBundle = + serde_json::from_str(text).map_err(|e| format!("not a QBZ blacklist file: {e}"))?; + if bundle.kind != KIND { + return Err(format!("not a QBZ blacklist file (kind `{}`)", bundle.kind)); + } + if bundle.schema_version > SCHEMA_VERSION { + return Err(format!( + "blacklist file schema {} is newer than this QBZ understands ({})", + bundle.schema_version, SCHEMA_VERSION + )); + } + Ok(bundle) +} + +/// Additive merge into the store. Entries already blocked are skipped +/// (`INSERT OR REPLACE` would otherwise overwrite their notes and dates); +/// the enabled flag is not touched. +pub fn import( + service: &BlacklistService, + bundle: &BlacklistBundle, +) -> Result { + // Existence by STORED id, not `is_blacklisted`: that predicate also + // folds in the enabled flag, and a disabled blacklist must still keep + // its rows through an import. + let existing_artists: std::collections::HashSet = service + .get_all()? + .into_iter() + .map(|a| a.artist_id) + .collect(); + let existing_albums: std::collections::HashSet = service + .get_all_albums()? + .into_iter() + .map(|a| a.album_id) + .collect(); + let mut report = ImportReport::default(); + for artist in &bundle.artists { + if existing_artists.contains(&artist.artist_id) { + report.artists_existing += 1; + continue; + } + service.add( + artist.artist_id, + &artist.artist_name, + artist.notes.as_deref(), + )?; + report.artists_added += 1; + } + for album in &bundle.albums { + if album.album_id.trim().is_empty() { + continue; + } + if existing_albums.contains(&album.album_id) { + report.albums_existing += 1; + continue; + } + service.add_album( + &album.album_id, + &album.album_title, + &album.artist_name, + &album.cover_url, + album.notes.as_deref(), + )?; + report.albums_added += 1; + } + Ok(report) +} + +/// `qbz-blacklist-YYYYMMDD.json`, next to the settings bundle's naming. +pub fn default_filename() -> String { + format!("qbz-blacklist-{}.json", chrono::Utc::now().format("%Y%m%d")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> BlacklistService { + BlacklistService::new_in_memory().expect("in-memory blacklist") + } + + #[test] + fn export_then_import_into_an_empty_store_is_lossless() { + let src = store(); + src.add(1, "Anthrax", Some("wrong Anthrax")).unwrap(); + src.add(2, "Nickelback", None).unwrap(); + src.add_album( + "abc123", + "Bad Album", + "Someone", + "https://x/cover.jpg", + None, + ) + .unwrap(); + let bundle = export(&src).unwrap(); + assert_eq!(bundle.kind, KIND); + assert_eq!(bundle.artists.len(), 2); + assert_eq!(bundle.albums.len(), 1); + + let text = serde_json::to_string(&bundle).unwrap(); + let dst = store(); + let report = import(&dst, &parse(&text).unwrap()).unwrap(); + assert_eq!(report.artists_added, 2); + assert_eq!(report.albums_added, 1); + assert_eq!(report.existing(), 0); + assert!(dst.is_blacklisted(1)); + assert!(dst.is_album_blacklisted("abc123")); + let anthrax = dst + .get_all() + .unwrap() + .into_iter() + .find(|a| a.artist_id == 1) + .unwrap(); + assert_eq!(anthrax.notes.as_deref(), Some("wrong Anthrax")); + } + + #[test] + fn import_is_additive_and_leaves_existing_entries_alone() { + let dst = store(); + dst.add(1, "Anthrax (thrash)", Some("mine")).unwrap(); + dst.set_enabled(false).unwrap(); + let bundle = BlacklistBundle { + kind: KIND.into(), + schema_version: SCHEMA_VERSION, + created_at: String::new(), + artists: vec![ + PortableArtist { + artist_id: 1, + artist_name: "Anthrax".into(), + notes: Some("theirs".into()), + }, + PortableArtist { + artist_id: 3, + artist_name: "Creed".into(), + notes: None, + }, + ], + albums: vec![], + }; + let report = import(&dst, &bundle).unwrap(); + assert_eq!(report.artists_added, 1); + assert_eq!(report.artists_existing, 1); + // The existing row kept its own name and notes; the flag is untouched. + let mine = dst + .get_all() + .unwrap() + .into_iter() + .find(|a| a.artist_id == 1) + .unwrap(); + assert_eq!(mine.artist_name, "Anthrax (thrash)"); + assert_eq!(mine.notes.as_deref(), Some("mine")); + assert!(!dst.is_enabled()); + // Importing the same file again changes nothing. + let again = import(&dst, &bundle).unwrap(); + assert_eq!(again.added(), 0); + assert_eq!(again.existing(), 2); + } + + #[test] + fn parse_refuses_other_files_and_newer_schemas() { + assert!(parse("{}").is_err()); + assert!(parse(r#"{"kind":"qbz-settings","schema_version":1,"created_at":""}"#).is_err()); + assert!(parse(&format!( + r#"{{"kind":"qbz-blacklist","schema_version":{},"created_at":""}}"#, + SCHEMA_VERSION + 1 + )) + .is_err()); + let ok = parse(r#"{"kind":"qbz-blacklist","schema_version":1,"created_at":"x"}"#).unwrap(); + assert!(ok.artists.is_empty() && ok.albums.is_empty()); + } +} diff --git a/crates/qbz-app/src/settings/bundle.rs b/crates/qbz-app/src/settings/bundle.rs index d461e44d8..faf6c4f7d 100644 --- a/crates/qbz-app/src/settings/bundle.rs +++ b/crates/qbz-app/src/settings/bundle.rs @@ -736,6 +736,7 @@ fn plan_audio( | "output_device" | "alsa_plugin" | "alsa_hardware_volume" + | "alsa_hardware_volume_controls" | "dsd_mode" ) || k.eq_ignore_ascii_case("volume"); @@ -922,15 +923,22 @@ fn plan_audio_machine( } } - // alsa_plugin / alsa_hardware_volume — apply only with a validated ALSA device. + // ALSA plugin / hardware-volume flag / exact route map — apply only with + // a validated ALSA device. Imported identities are data only; the active + // route is probed again before any mixer write. let resolved_backend_alsa = resolved_backend_is_alsa(map, current, fallback, forced_device); - for key in ["alsa_plugin", "alsa_hardware_volume"] { + for key in [ + "alsa_plugin", + "alsa_hardware_volume", + "alsa_hardware_volume_controls", + ] { let Some(v) = map.get(key) else { continue }; let no_change = alsa_field_no_change(current, key, v); if no_change { applied_line(plan, &format!("audio.{key}"), v, ""); } else if device_survives && resolved_backend_alsa { applied_line(plan, &format!("audio.{key}"), v, "rides the ALSA device"); + plan.routing_critical_changed = true; } else { plan.skipped.push(skip_line( &format!("audio.{key}"), @@ -978,6 +986,7 @@ fn backend_name(b: AudioBackendType) -> &'static str { AudioBackendType::Alsa => "Alsa", AudioBackendType::Pulse => "Pulse", AudioBackendType::Jack => "Jack", + AudioBackendType::WasapiExclusive => "WasapiExclusive", AudioBackendType::SystemDefault => "SystemDefault", } } @@ -996,6 +1005,12 @@ fn intent_flag_current(current: &AudioSettings, flag: &str) -> bool { fn alsa_field_no_change(current: &AudioSettings, key: &str, v: &Value) -> bool { match key { "alsa_hardware_volume" => v.as_bool() == Some(current.alsa_hardware_volume), + "alsa_hardware_volume_controls" => { + serde_json::to_value(¤t.alsa_hardware_volume_controls) + .ok() + .as_ref() + == Some(v) + } "alsa_plugin" => { let cur = serde_json::to_value(current.alsa_plugin).unwrap_or(Value::Null); *v == cur @@ -1165,6 +1180,11 @@ fn apply_audio_writes(data_root: &Path, writes: &[(&str, &Value)]) -> Result<(), store.set_alsa_plugin(p)?; } "alsa_hardware_volume" => store.set_alsa_hardware_volume(as_bool(value))?, + "alsa_hardware_volume_controls" => { + let controls = serde_json::from_value((*value).clone()) + .map_err(|e| format!("alsa_hardware_volume_controls: {e}"))?; + store.set_alsa_hardware_volume_controls(controls)?; + } "exclusive_mode" => store.set_exclusive_mode(as_bool(value))?, "dac_passthrough" => store.set_dac_passthrough(as_bool(value))?, "pw_force_bitperfect" => store.set_pw_force_bitperfect(as_bool(value))?, @@ -1425,7 +1445,7 @@ fn qconnect_kv_read(db: &Path, key: &str) -> Option { fn qconnect_kv_write(db: &Path, key: &str, value: Option<&str>) -> Result<(), String> { let conn = rusqlite::Connection::open(db).map_err(|e| e.to_string())?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| e.to_string())?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)", @@ -1512,17 +1532,63 @@ fn desktop_paths() -> ProfilePaths { } } -fn hostname() -> String { - std::env::var("HOSTNAME") - .ok() - .filter(|h| !h.trim().is_empty()) - .or_else(|| { - std::fs::read_to_string("/etc/hostname") - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - }) - .unwrap_or_else(|| "unknown".to_string()) +/// The machine's name, for user-facing device labels. +/// +/// `HOSTNAME` is a SHELL variable rather than an environment one on most Linux +/// setups, hence the file fallback. Windows exports `COMPUTERNAME` and has no +/// `/etc/hostname`, so every Windows install used to land on the same +/// "unknown" and collide on Qobuz Connect. +/// +/// The single implementation: `qbz-qt` and `qbzd` both call it and keep only +/// their own last-resort word, which differ on purpose. +pub fn hostname() -> String { + for var in ["HOSTNAME", "COMPUTERNAME"] { + if let Ok(h) = std::env::var(var) { + let h = h.trim(); + if !h.is_empty() { + return h.to_string(); + } + } + } + if let Ok(h) = std::fs::read_to_string("/etc/hostname") { + let h = h.trim(); + if !h.is_empty() { + return h.to_string(); + } + } + "unknown".to_string() +} + +#[cfg(test)] +mod hostname_tests { + /// ONE test, not two: the environment is process-global and cargo runs + /// tests on parallel threads, so a sibling that sets HOSTNAME races a + /// sibling that clears it. Splitting these failed exactly that way. + #[test] + fn computername_is_the_windows_source_and_hostname_still_wins() { + let saved_host = std::env::var("HOSTNAME").ok(); + let saved_computer = std::env::var("COMPUTERNAME").ok(); + + std::env::remove_var("HOSTNAME"); + std::env::set_var("COMPUTERNAME", "WINBOX"); + assert_eq!(super::hostname(), "WINBOX", "Windows has only COMPUTERNAME"); + + std::env::set_var("HOSTNAME", "gentoo-box"); + assert_eq!( + super::hostname(), + "gentoo-box", + "HOSTNAME keeps precedence, so Linux behaviour does not move" + ); + + match saved_host { + Some(v) => std::env::set_var("HOSTNAME", v), + None => std::env::remove_var("HOSTNAME"), + } + match saved_computer { + Some(v) => std::env::set_var("COMPUTERNAME", v), + None => std::env::remove_var("COMPUTERNAME"), + } + } } fn now_rfc3339() -> String { diff --git a/crates/qbz-app/src/settings/bundle/tests.rs b/crates/qbz-app/src/settings/bundle/tests.rs index f5b76574d..f90471d90 100644 --- a/crates/qbz-app/src/settings/bundle/tests.rs +++ b/crates/qbz-app/src/settings/bundle/tests.rs @@ -483,6 +483,43 @@ fn apply_writes_are_idempotent_and_persist() { cleanup(&p); } +#[test] +fn exact_alsa_mixer_route_map_survives_bundle_plan_and_apply() { + let p = scratch("alsa-mixer-map"); + let route = "alsa-route-v1|card=by-id:usb-D50-00|pcm=hw|DEV=0"; + let bundle = bundle_with(json!({ + "audio": { + "output_device": "hw:1,0", + "backend_type": "Alsa", + "alsa_hardware_volume": false, + "alsa_hardware_volume_controls": { + (route): { "name": "D50 III", "index": 0 } + } + } + })); + + let plan = plan(&bundle, &p, &ImportOptions::default(), &live()).expect("plan"); + assert!( + find(&plan.applied, "audio.alsa_hardware_volume_controls").is_some(), + "exact route map must be classified" + ); + assert!(plan.routing_critical_changed); + apply(&plan, &p, None).expect("apply map"); + + let audio = AudioSettingsStore::new_at(&p.data_root).unwrap().get_settings().unwrap(); + let route_key = + serde_json::from_value::(json!(route)) + .unwrap(); + assert_eq!( + audio + .alsa_hardware_volume_controls + .get(&route_key) + .map(|control| (control.name.as_str(), control.index)), + Some(("D50 III", 0)) + ); + cleanup(&p); +} + #[test] fn bundle_json_roundtrips_flat() { // The on-disk shape is flat (§2.9): header + domains at the top level. diff --git a/crates/qbz-app/src/settings/daemon_prefs.rs b/crates/qbz-app/src/settings/daemon_prefs.rs index c044cbb54..c888e92df 100644 --- a/crates/qbz-app/src/settings/daemon_prefs.rs +++ b/crates/qbz-app/src/settings/daemon_prefs.rs @@ -16,6 +16,12 @@ pub struct DaemonPrefs { /// (CONSOLE ext). Default ON; the `QBZD_MPRIS` env var overrides it when /// set. Toggling this needs a daemon restart (the service spawns at boot). pub mpris_enabled: bool, + /// Absolute path of an executable the daemon runs on playback/session + /// events (CONSOLE ext), with the event in `QBZ_*` environment variables. + /// Empty = hooks off (the default). The `QBZD_HOOK` env var overrides it + /// when set. Changing it needs a daemon restart (the dispatcher spawns at + /// boot). Machine-local by design — NEVER part of the settings bundle. + pub hook_script: String, } impl Default for DaemonPrefs { fn default() -> Self { @@ -23,6 +29,7 @@ impl Default for DaemonPrefs { streaming_quality: "hires_plus".into(), volume: 0.5, mpris_enabled: true, + hook_script: String::new(), } } } @@ -35,7 +42,11 @@ pub fn load_at(data_root: &Path) -> DaemonPrefs { // forward-compatible: unknown fields ignored WITH a warning (01 §10.3) if let Some(obj) = v.as_object() { for k in obj.keys() { - if k != "streaming_quality" && k != "volume" && k != "mpris_enabled" { + if k != "streaming_quality" + && k != "volume" + && k != "mpris_enabled" + && k != "hook_script" + { log::warn!("[daemon_prefs] unknown field ignored: {k}"); } } @@ -92,6 +103,8 @@ mod tests { // Non-default (default is true) so the roundtrip actually exercises // the field, not just its presence. mpris_enabled: false, + // Non-default (default is empty) for the same reason. + hook_script: "/usr/local/bin/qbz-hook.sh".into(), }; save_at(&prefs, &dir).expect("save daemon prefs"); @@ -100,6 +113,7 @@ mod tests { assert_eq!(loaded.streaming_quality, "cd"); assert_eq!(loaded.volume, 0.72); assert!(!loaded.mpris_enabled); + assert_eq!(loaded.hook_script, "/usr/local/bin/qbz-hook.sh"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/qbz-app/src/settings/developer.rs b/crates/qbz-app/src/settings/developer.rs index f7f4bd9fb..3f0c2149d 100644 --- a/crates/qbz-app/src/settings/developer.rs +++ b/crates/qbz-app/src/settings/developer.rs @@ -63,7 +63,7 @@ impl DeveloperSettingsStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open developer settings database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| { format!( "Failed to enable WAL for developer settings database: {}", diff --git a/crates/qbz-app/src/settings/discover_prefs.rs b/crates/qbz-app/src/settings/discover_prefs.rs index a094834cb..496f56411 100644 --- a/crates/qbz-app/src/settings/discover_prefs.rs +++ b/crates/qbz-app/src/settings/discover_prefs.rs @@ -88,6 +88,16 @@ pub enum DiscoverySectionId { /// "Most Played Albums" — top albums by local play count /// (`qbz_app::settings::album_play_history`). Home + For You, default off. MostPlayedAlbums, + /// "Recently Played Playlists" — the playlists the user actually chose to + /// play, by last play (`qbz_app::settings::playlist_play_history`). + /// + /// Its sibling `RecentlyPlayedAlbums` used to absorb these: every track + /// start wrote its ALBUM into the recent list whatever the user had put on, + /// so playing one 40-track playlist filled a 24-entry album rail with that + /// playlist's contents. Plays are separated by context now, and this is + /// where the playlist half surfaces. Home + For You, default ON — it is the + /// answer to a question the album rail was silently answering wrong. + RecentlyPlayedPlaylists, } impl DiscoverySectionId { @@ -115,6 +125,7 @@ impl DiscoverySectionId { ArtistSpotlight => "artistSpotlight", Pinned => "pinned", MostPlayedAlbums => "mostPlayedAlbums", + RecentlyPlayedPlaylists => "recentlyPlayedPlaylists", } } @@ -142,6 +153,7 @@ impl DiscoverySectionId { "artistSpotlight" => ArtistSpotlight, "pinned" => Pinned, "mostPlayedAlbums" => MostPlayedAlbums, + "recentlyPlayedPlaylists" => RecentlyPlayedPlaylists, _ => return None, }) } @@ -192,6 +204,7 @@ pub fn default_prefs() -> DiscoverPrefs { pref(PressAwards, true), pref(QobuzPlaylists, true), pref(RecentlyPlayedAlbums, true), + pref(RecentlyPlayedPlaylists, true), pref(ContinueListening, true), pref(IdealDiscography, true), pref(MostStreamed, true), @@ -223,6 +236,7 @@ pub fn default_prefs() -> DiscoverPrefs { pref(RadioStations, true), pref(ContinueListening, true), pref(RecentlyPlayedAlbums, true), + pref(RecentlyPlayedPlaylists, true), pref(TopArtists, true), pref(FavoriteAlbums, true), pref(SimilarAlbums, true), @@ -418,6 +432,24 @@ pub fn reconcile_list(persisted: Option<&Vec>, fallback: &[SectionPref]) // SQLite store // --------------------------------------------------------------------------- +/// The rail sizes the UI offers, IN THE ORDER THE UI OFFERS THEM — the selector +/// sends back an index into this array. Reordering is safe ONLY because what is +/// STORED is the size and never the index (`save_rail_size` takes the count, +/// `rail_size_index_of` derives the index back), so a stored 15 keeps meaning +/// 15 whatever position it sits in. Keep that invariant or this comment becomes +/// a trap. +/// +/// There is no "uncapped" entry, and that is deliberate. It used to read "All", +/// which was a lie twice over: an "all" in Qobuz terms is what the View-all +/// page holds, and /discover/index hands each rail a DIFFERENT number anyway — +/// measured on a live response, new_releases 26, ideal_discography 26, +/// playlists 20, most_streamed 15, and qobuzissims / album_of_the_week / +/// press_awards 10. One label could not be honest about all of them. So the +/// default is now a real, uniform cap of ten and the entry says so. +pub const RAIL_SIZE_PRESETS: [i64; 4] = [10, 15, 20, 25]; + +pub const DEFAULT_RAIL_SIZE: i64 = 0; + pub struct DiscoverPrefsStore { conn: Connection, } @@ -431,7 +463,7 @@ impl DiscoverPrefsStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open discover prefs database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to enable WAL for discover prefs database: {}", e))?; conn.execute_batch( @@ -448,6 +480,33 @@ impl DiscoverPrefsStore { ) .map_err(|e| format!("Failed to initialize discover prefs: {}", e))?; + // Rail size lives in its OWN TABLE, not as a key inside `prefs_json`, + // and that is a data-safety decision rather than a stylistic one. + // + // `DiscoverPrefs::to_json` rebuilds the blob FROM SCRATCH out of the + // keys the running binary knows, and `save` overwrites the whole + // column with it. This .db is SHARED with the Slint build, whose tree + // is frozen and will not be recompiled — so the installed Slint binary + // has the old `to_json` permanently. A new key inside the blob would + // therefore survive exactly until the user touched any toggle over + // there, and then vanish, with nothing logged anywhere. + // + // A separate table cannot be hit that way: the old binary never names + // it in any statement, and `CREATE TABLE IF NOT EXISTS` is idempotent + // in whichever build opens the file first. WAL + busy_timeout are + // already set above, so two binaries on one file is the existing + // design, not something this adds. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS discover_rail_sizes ( + section_id TEXT PRIMARY KEY, + rail_size INTEGER NOT NULL + );", + ) + .map_err(|e| format!("Failed to create discover rail size table: {}", e))?; + // No seed row: ABSENT means the default, which is uncapped. Writing a + // row per section at open would put twenty rows in the file to say + // "unchanged", and would then have to be kept in step with the enum. + Ok(Self { conn }) } @@ -480,6 +539,55 @@ impl DiscoverPrefsStore { } } + /// How many items each Discover rail may show, by section id. `0` = no + /// cap, and a section ABSENT from the map is uncapped too. + /// + /// Never an error to the caller: a missing table (an older file) or a value + /// outside the presets yields an empty map / drops that row. + pub fn load_rail_sizes(&self) -> std::collections::HashMap { + let out = (|| -> Option> { + let mut stmt = self + .conn + .prepare("SELECT section_id, rail_size FROM discover_rail_sizes") + .ok()?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .ok()?; + Some( + rows.flatten() + .filter(|(_, n)| RAIL_SIZE_PRESETS.contains(n)) + .collect(), + ) + })(); + out.unwrap_or_default() + } + + /// Persist ONE rail's size. Values outside the presets are rejected rather + /// than stored, so what loads back and the UI's index can never disagree. + /// The DEFAULT deletes the row instead of storing it — the file then only + /// carries the rails the user actually changed. + pub fn save_rail_size(&self, section_id: &str, n: i64) -> Result<(), String> { + if !RAIL_SIZE_PRESETS.contains(&n) { + return Err(format!("rail size {n} is not one of {RAIL_SIZE_PRESETS:?}")); + } + let sql = if n == DEFAULT_RAIL_SIZE { + self.conn.execute( + "DELETE FROM discover_rail_sizes WHERE section_id = ?1", + params![section_id], + ) + } else { + self.conn.execute( + "INSERT INTO discover_rail_sizes (section_id, rail_size) VALUES (?1, ?2) + ON CONFLICT(section_id) DO UPDATE SET rail_size = excluded.rail_size", + params![section_id, n], + ) + }; + sql.map_err(|e| format!("Failed to save discover rail size: {}", e))?; + Ok(()) + } + /// Persist the whole prefs blob (upsert row 1). pub fn save(&self, prefs: &DiscoverPrefs) -> Result<(), String> { self.conn @@ -525,18 +633,20 @@ mod tests { #[test] fn defaults_match_spec_exactly() { let d = default_prefs(); - // home: 15 entries, first 8 ON (Tauri sectionPrefs.ts + Slint `pinned` - // + the local mostPlayedAlbums, default off). + // home: 16 entries, first 9 ON (Tauri sectionPrefs.ts + Slint `pinned` + // + the local mostPlayedAlbums, default off, + recentlyPlayedPlaylists + // sitting right after its album sibling, default on). assert_eq!( ids(&d.home), vec![ NewReleases, PressAwards, QobuzPlaylists, RecentlyPlayedAlbums, + RecentlyPlayedPlaylists, ContinueListening, IdealDiscography, MostStreamed, Pinned, QobuzMixes, ReleaseWatch, EditorPicks, Qobuzissimes, TopArtists, FavoriteAlbums, MostPlayedAlbums, ] ); - assert_eq!(d.enabled_count(DiscoveryTab::Home), 8); + assert_eq!(d.enabled_count(DiscoveryTab::Home), 9); assert!(d.is_enabled(DiscoveryTab::Home, MostStreamed)); assert!(!d.is_enabled(DiscoveryTab::Home, Qobuzissimes)); // editorPicks: 7 entries, all ON. @@ -545,13 +655,15 @@ mod tests { vec![NewReleases, EditorPicks, Qobuzissimes, PressAwards, MostStreamed, IdealDiscography, QobuzPlaylists] ); assert_eq!(d.enabled_count(DiscoveryTab::EditorPicks), 7); - // forYou: 14 entries, qobuzMixes first, pinned second; the 13 - // Tauri+Slint ones ON, mostPlayedAlbums (local addition) OFF. - assert_eq!(d.for_you.len(), 14); + // forYou: 15 entries, qobuzMixes first, pinned second; the 13 + // Tauri+Slint ones plus recentlyPlayedPlaylists ON, mostPlayedAlbums + // (local addition) OFF. + assert_eq!(d.for_you.len(), 15); assert_eq!(d.for_you[0].id, QobuzMixes); assert_eq!(d.for_you[1].id, Pinned); - assert_eq!(d.for_you[13].id, MostPlayedAlbums); - assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 13); + assert_eq!(d.for_you[6].id, RecentlyPlayedPlaylists); + assert_eq!(d.for_you[14].id, MostPlayedAlbums); + assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 14); } // --- Group 2: reconcile_list --- diff --git a/crates/qbz-app/src/settings/favorites.rs b/crates/qbz-app/src/settings/favorites.rs index 48b09757e..66bdb3a4e 100644 --- a/crates/qbz-app/src/settings/favorites.rs +++ b/crates/qbz-app/src/settings/favorites.rs @@ -42,7 +42,7 @@ impl FavoritesPreferencesStore { let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open favorites preferences database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to set WAL mode on favorites preferences: {}", e))?; conn.execute( diff --git a/crates/qbz-app/src/settings/favorites_cache.rs b/crates/qbz-app/src/settings/favorites_cache.rs index c4c127bb6..143457343 100644 --- a/crates/qbz-app/src/settings/favorites_cache.rs +++ b/crates/qbz-app/src/settings/favorites_cache.rs @@ -25,7 +25,7 @@ impl FavoritesCacheStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open favorites cache database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to enable WAL for favorites cache database: {}", e))?; // Create tables diff --git a/crates/qbz-app/src/settings/graphics.rs b/crates/qbz-app/src/settings/graphics.rs index 5df9261cc..95aae3ef6 100644 --- a/crates/qbz-app/src/settings/graphics.rs +++ b/crates/qbz-app/src/settings/graphics.rs @@ -86,7 +86,7 @@ impl GraphicsSettingsStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open graphics settings database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to enable WAL for graphics settings database: {}", e))?; conn.execute_batch( diff --git a/crates/qbz-app/src/settings/local_favorites.rs b/crates/qbz-app/src/settings/local_favorites.rs index e26e40bdb..94d6b772a 100644 --- a/crates/qbz-app/src/settings/local_favorites.rs +++ b/crates/qbz-app/src/settings/local_favorites.rs @@ -1,13 +1,15 @@ //! Headless local-favorites service. //! -//! Frontend-agnostic store for favoriting LOCAL library items (genuine local -//! files + Plex — never the Qobuz offline cache). Mirrors `pinned_items.rs` +//! Frontend-agnostic store for favoriting LOCAL LIBRARY items (genuine local +//! files plus configured media servers — never the Qobuz offline cache). +//! Mirrors `pinned_items.rs` //! (same pragmas, error style, in-memory `(kind, id)` set) per ADR-006; the //! per-user lifecycle lives in the `qbz` crate wrapper (`crate::local_favorites`). //! //! Rows carry a display snapshot (title/subtitle/artwork) taken at favorite //! time plus a denormalized `artist` (for per-artist counts) and `source` -//! (`local` | `plex`). The `CHECK` on `source` refuses `qobuz_download` at +//! (`local` | `plex` | `jellyfin` | `subsonic`). The `CHECK` on `source` +//! refuses `qobuz_download` at //! write time, so the mixed-library feed built from this store is inherently //! free of Qobuz-offline duplicates. @@ -22,9 +24,9 @@ pub const DB_FILE_NAME: &str = "local_favorites.db"; /// A favorited local item with its display snapshot. /// -/// Ids are Strings: album = the local group key (`plex:…` / contains `|`/`/`), -/// artist = the artist NAME (local artists have no numeric id), track = -/// `file_path` (local) or `plex:` (Plex) — the stable key. +/// Ids are Strings: album = the source-aware group key (`plex:…`, +/// `jellyfin:…`, `subsonic:…` or a local key containing `|`/`/`), artist = +/// the artist NAME, track = a source-aware stable key. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalFavItem { /// "album" | "artist" | "track". @@ -35,7 +37,8 @@ pub struct LocalFavItem { pub artwork_url: String, /// Denormalized artist name (for per-artist counts); empty for kind="artist". pub artist: String, - /// "local" | "plex" — never "qobuz_download". + /// "local" | "plex" | "jellyfin" | "subsonic" — never a Qobuz + /// download spelling. pub source: String, /// Unix seconds; the ordering key (newest first). pub favorited_at: i64, @@ -56,8 +59,10 @@ impl LocalFavoritesService { let conn = Connection::open(db_path) .map_err(|e| format!("Failed to open local favorites database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") - .map_err(|e| format!("Failed to set WAL mode: {}", e))?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;", + ) + .map_err(|e| format!("Failed to set WAL mode: {}", e))?; let service = Self { conn, @@ -97,17 +102,68 @@ impl LocalFavoritesService { subtitle TEXT, artwork_url TEXT, artist TEXT, - source TEXT NOT NULL CHECK (source IN ('local','plex')), + source TEXT NOT NULL CHECK ( + source IN ('local','plex','jellyfin','subsonic') + ), favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), PRIMARY KEY (kind, id) ); + "#, + ) + .map_err(|e| format!("Failed to initialize local favorites schema: {}", e))?; + + // v1 accepted only local/Plex. SQLite cannot widen a CHECK in place, + // so rebuild the tiny snapshot table atomically while preserving every + // existing favorite. Index creation deliberately follows the rebuild: + // ALTER TABLE carries the old index names to the legacy table. + let table_sql = self + .conn + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='local_favorites'", + [], + |row| row.get::<_, String>(0), + ) + .map_err(|e| format!("Failed to inspect local favorites schema: {e}"))?; + if !table_sql.contains("'jellyfin'") || !table_sql.contains("'subsonic'") { + self.conn + .execute_batch( + r#" + BEGIN IMMEDIATE; + ALTER TABLE local_favorites RENAME TO local_favorites_v1; + CREATE TABLE local_favorites ( + kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')), + id TEXT NOT NULL, + title TEXT NOT NULL, + subtitle TEXT, + artwork_url TEXT, + artist TEXT, + source TEXT NOT NULL CHECK ( + source IN ('local','plex','jellyfin','subsonic') + ), + favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (kind, id) + ); + INSERT INTO local_favorites( + kind,id,title,subtitle,artwork_url,artist,source,favorited_at + ) + SELECT kind,id,title,subtitle,artwork_url,artist,source,favorited_at + FROM local_favorites_v1; + DROP TABLE local_favorites_v1; + COMMIT; + "#, + ) + .map_err(|e| format!("Failed to migrate local favorites schema: {e}"))?; + } + self.conn + .execute_batch( + r#" CREATE INDEX IF NOT EXISTS idx_local_favorites_at ON local_favorites(favorited_at); CREATE INDEX IF NOT EXISTS idx_local_favorites_artist ON local_favorites(kind, artist); "#, ) - .map_err(|e| format!("Failed to initialize local favorites schema: {}", e))?; + .map_err(|e| format!("Failed to index local favorites schema: {e}"))?; Ok(()) } @@ -298,10 +354,48 @@ mod tests { #[test] fn source_check_rejects_offline() { let s = LocalFavoritesService::new_in_memory().expect("svc"); + s.favorite(&item("album", "jf", "J", "A", "jellyfin")) + .expect("Jellyfin belongs to the Local Library favorite domain"); + s.favorite(&item("album", "sub", "S", "A", "subsonic")) + .expect("Subsonic belongs to the Local Library favorite domain"); assert!( s.favorite(&item("album", "x", "X", "A", "qobuz_download")) .is_err(), "the source CHECK refuses qobuz-offline rows" ); } + + #[test] + fn v1_schema_migrates_without_losing_existing_favorites() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + r#" + CREATE TABLE local_favorites ( + kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')), + id TEXT NOT NULL, + title TEXT NOT NULL, + subtitle TEXT, + artwork_url TEXT, + artist TEXT, + source TEXT NOT NULL CHECK (source IN ('local','plex')), + favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (kind, id) + ); + INSERT INTO local_favorites( + kind,id,title,subtitle,artwork_url,artist,source,favorited_at + ) VALUES ('album','plex:old','Old','','','Artist','plex',42); + "#, + ) + .unwrap(); + let service = LocalFavoritesService { + conn, + keys: RwLock::new(HashSet::new()), + }; + service.init_schema().unwrap(); + service.load_from_db().unwrap(); + assert!(service.is_favorite("album", "plex:old")); + service + .favorite(&item("album", "jellyfin:new", "New", "Artist", "jellyfin")) + .unwrap(); + } } diff --git a/crates/qbz-app/src/settings/media_servers.rs b/crates/qbz-app/src/settings/media_servers.rs new file mode 100644 index 000000000..53c6bfe5e --- /dev/null +++ b/crates/qbz-app/src/settings/media_servers.rs @@ -0,0 +1,546 @@ +//! Per-user connection settings for the media servers QBZ reads. +//! +//! ONE store for Jellyfin and Subsonic, keyed by a `server` column — the same +//! decision `qbz-media-cache` makes about their tracks, for the same reason: +//! the two carry the same fields, and a store per server means every future one +//! copies a file. Plex keeps its own ([`super::plex`]) until it folds into the +//! shared pair; see that crate's header for why that is a separate change. +//! +//! Mirrors [`super::plex`]'s shape: a small SQLite table, opened globally via +//! [`MediaServerStore::new`] and re-pointed at the active user's directory by +//! [`MediaServerState::init_at`] at login, so credentials are scoped per Qobuz +//! user. +//! +//! # What is stored, and what deliberately is not +//! +//! **Jellyfin** stores its `access_token` — the server issues it and the +//! password is never needed again. **Subsonic has no session**: every request +//! carries `md5(password + salt)`, so the password itself must be kept. +//! +//! That asymmetry is the protocol's, not a shortcut. It is also why `salt` is +//! persisted rather than generated per request: a rolling salt would make every +//! cover URL unique per request, and the artwork cache keys on the URL, so each +//! pass would re-download every cover. The salt is not a secret — it travels in +//! clear beside the token — its job is to keep one password from hashing to the +//! same value on two installs. +//! +//! `device_id` is Jellyfin's, and it is load-bearing: the server keys its +//! SESSION on it, and re-authenticating under the same one revokes the previous +//! token (measured against 10.11.11). Generated once and persisted, so two QBZ +//! installs never log each other out and a relaunch does not drop the token. + +use log::info; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +/// Which server a settings row describes. The wire values are the same words +/// `qbz_source::SourceId` and `qbz_media_cache::RemoteSource` use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaServerKind { + Jellyfin, + Subsonic, +} + +impl MediaServerKind { + pub const fn as_str(self) -> &'static str { + match self { + MediaServerKind::Jellyfin => "jellyfin", + MediaServerKind::Subsonic => "subsonic", + } + } + + pub fn from_word(w: &str) -> Option { + match w { + "jellyfin" => Some(MediaServerKind::Jellyfin), + "subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => { + Some(MediaServerKind::Subsonic) + } + _ => None, + } + } + + pub const ALL: [MediaServerKind; 2] = [MediaServerKind::Jellyfin, MediaServerKind::Subsonic]; +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MediaServerSettings { + /// Master toggle. Default OFF — integrations are opt-in. + pub enabled: bool, + /// Collapse chevron state for the settings panel. + pub ui_collapsed: bool, + /// `proto://host:port`, resolved. Empty = not configured. + pub base_url: String, + /// Display name reported by the server, for the panel's "connected to …". + pub server_name: String, + /// The server's own id, threaded into the cache as `server_id`. + pub server_id: String, + /// Jellyfin: the account name. Subsonic: the account name, sent on every + /// request. + pub username: String, + /// Jellyfin: the issued `AccessToken`. Empty for Subsonic. + pub token: String, + /// Subsonic ONLY: the password, because the protocol re-derives its token + /// on every request. Empty for Jellyfin, which never needs it again. + pub password: String, + /// Subsonic ONLY: the FIXED per-install salt. See the module header. + pub salt: String, + /// Jellyfin ONLY: the stable per-install `DeviceId`. See the module header. + pub device_id: String, + /// Picked library / music-folder ids. Empty = none chosen yet, which the + /// sync reads as "all of them" on its first run. + pub selected_libraries: Vec, + /// Unix seconds of the last COMPLETED sweep. 0 = never swept. + /// + /// Only a completed sweep updates it, because it is what a delta sync asks + /// the server about — stamping it after a partial one would silently skip + /// whatever the interrupted run never saw. + pub last_sync_at: i64, + /// Tracks in the cache at the end of that sweep, for the panel's summary. + pub last_sync_tracks: i64, +} + +impl MediaServerSettings { + /// Is this server usable right now — enabled, addressed, and carrying + /// whatever credential its protocol needs? + /// + /// The two protocols disagree about what "credentialed" means, and this is + /// the ONE place that knows: Jellyfin needs its issued token, Subsonic + /// needs a username and password. + pub fn is_configured(&self, kind: MediaServerKind) -> bool { + if !self.enabled || self.base_url.trim().is_empty() { + return false; + } + match kind { + MediaServerKind::Jellyfin => !self.token.trim().is_empty(), + MediaServerKind::Subsonic => { + !self.username.trim().is_empty() && !self.password.is_empty() + } + } + } +} + +pub struct MediaServerStore { + conn: Connection, +} + +impl MediaServerStore { + fn open_at(dir: &Path) -> Result { + std::fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create data directory: {e}"))?; + let conn = Connection::open(dir.join("media_servers.db")) + .map_err(|e| format!("Failed to open media server settings: {e}"))?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| format!("Failed to set pragmas: {e}"))?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS media_server_settings ( + server TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + ui_collapsed INTEGER NOT NULL DEFAULT 0, + base_url TEXT NOT NULL DEFAULT '', + server_name TEXT NOT NULL DEFAULT '', + server_id TEXT NOT NULL DEFAULT '', + username TEXT NOT NULL DEFAULT '', + token TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + salt TEXT NOT NULL DEFAULT '', + device_id TEXT NOT NULL DEFAULT '', + selected_libraries TEXT NOT NULL DEFAULT '', + last_sync_at INTEGER NOT NULL DEFAULT 0, + last_sync_tracks INTEGER NOT NULL DEFAULT 0 + );", + ) + .map_err(|e| format!("Failed to init media server schema: {e}"))?; + info!("[MediaServers] Database initialized"); + Ok(Self { conn }) + } + + pub fn new() -> Result { + let dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&dir) + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir) + } + + /// Read one server's settings. A row that has never been written comes back + /// as defaults (disabled, unconfigured) rather than an error — "the user + /// has not set this up" is not a failure. + pub fn get(&self, kind: MediaServerKind) -> Result { + let row = self.conn.query_row( + "SELECT enabled, ui_collapsed, base_url, server_name, server_id, username, + token, password, salt, device_id, selected_libraries, + last_sync_at, last_sync_tracks + FROM media_server_settings WHERE server = ?1", + params![kind.as_str()], + |r| { + Ok(MediaServerSettings { + enabled: r.get::<_, i64>(0)? != 0, + ui_collapsed: r.get::<_, i64>(1)? != 0, + base_url: r.get(2)?, + server_name: r.get(3)?, + server_id: r.get(4)?, + username: r.get(5)?, + token: r.get(6)?, + password: r.get(7)?, + salt: r.get(8)?, + device_id: r.get(9)?, + selected_libraries: split_ids(&r.get::<_, String>(10)?), + last_sync_at: r.get(11)?, + last_sync_tracks: r.get(12)?, + }) + }, + ); + match row { + Ok(s) => Ok(s), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(MediaServerSettings::default()), + Err(e) => Err(format!("Failed to read media server settings: {e}")), + } + } + + /// Write one server's settings wholesale. + /// + /// One UPSERT rather than a setter per field: the settings panel edits a + /// form and saves it, and a dozen single-column setters is how `plex.rs` + /// grew to 723 lines. + pub fn put(&self, kind: MediaServerKind, s: &MediaServerSettings) -> Result<(), String> { + self.conn + .execute( + "INSERT INTO media_server_settings + (server, enabled, ui_collapsed, base_url, server_name, server_id, + username, token, password, salt, device_id, selected_libraries, + last_sync_at, last_sync_tracks) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14) + ON CONFLICT(server) DO UPDATE SET + enabled=excluded.enabled, ui_collapsed=excluded.ui_collapsed, + base_url=excluded.base_url, server_name=excluded.server_name, + server_id=excluded.server_id, username=excluded.username, + token=excluded.token, password=excluded.password, + salt=excluded.salt, device_id=excluded.device_id, + selected_libraries=excluded.selected_libraries, + last_sync_at=excluded.last_sync_at, + last_sync_tracks=excluded.last_sync_tracks", + params![ + kind.as_str(), + i64::from(s.enabled), + i64::from(s.ui_collapsed), + s.base_url.trim(), + s.server_name, + s.server_id, + s.username.trim(), + s.token.trim(), + s.password, + s.salt, + s.device_id, + s.selected_libraries.join(","), + s.last_sync_at, + s.last_sync_tracks, + ], + ) + .map(|_| ()) + .map_err(|e| format!("Failed to write media server settings: {e}")) + } + + /// Forget one server's credentials, keeping the row so its UI state and its + /// picked libraries survive a reconnect. + pub fn disconnect(&self, kind: MediaServerKind) -> Result<(), String> { + self.conn + .execute( + "UPDATE media_server_settings + SET enabled = 0, token = '', password = '', server_id = '', + server_name = '', last_sync_at = 0, last_sync_tracks = 0 + WHERE server = ?1", + params![kind.as_str()], + ) + .map(|_| ()) + .map_err(|e| format!("Failed to disconnect media server: {e}")) + } +} + +fn split_ids(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() +} + +/// The process-global store, re-pointed at the active user at login. +#[derive(Clone)] +pub struct MediaServerState { + store: Arc>>, +} + +impl Default for MediaServerState { + fn default() -> Self { + Self::new() + } +} + +impl MediaServerState { + pub fn new() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + /// Bind to the active user's directory. Called from the auth path. + pub fn init_at(&self, dir: &Path) { + match MediaServerStore::new_at(dir) { + Ok(s) => { + if let Ok(mut slot) = self.store.lock() { + *slot = Some(s); + } + } + Err(e) => log::error!("[MediaServers] init failed: {e}"), + } + } + + /// Drop the handle (logout). + pub fn reset(&self) { + if let Ok(mut slot) = self.store.lock() { + *slot = None; + } + } + + /// Read. Unbound or unreadable answers with DEFAULTS — i.e. "disabled" — + /// which is the safe direction: a settings read that fails must not make an + /// unconfigured server look configured. + pub fn get(&self, kind: MediaServerKind) -> MediaServerSettings { + self.store + .lock() + .ok() + .and_then(|g| g.as_ref().and_then(|s| s.get(kind).ok())) + .unwrap_or_default() + } + + pub fn put(&self, kind: MediaServerKind, s: &MediaServerSettings) { + if let Ok(g) = self.store.lock() { + if let Some(store) = g.as_ref() { + if let Err(e) = store.put(kind, s) { + log::error!("[MediaServers] write failed: {e}"); + } + } + } + } + + pub fn disconnect(&self, kind: MediaServerKind) { + if let Ok(g) = self.store.lock() { + if let Some(store) = g.as_ref() { + let _ = store.disconnect(kind); + } + } + } + + /// Every server the user has switched on AND finished configuring. + /// + /// This is what the Local Library union filters on, so "enabled" alone is + /// not enough: a server that is toggled on but has no credentials yet would + /// contribute a `source IN (...)` arm over rows that were never swept. + pub fn configured_words(&self) -> Vec<&'static str> { + MediaServerKind::ALL + .iter() + .filter(|k| self.get(**k).is_configured(**k)) + .map(|k| k.as_str()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn store() -> (TempDir, MediaServerStore) { + let t = TempDir::new().unwrap(); + let s = MediaServerStore::new_at(t.path()).unwrap(); + (t, s) + } + + /// A server the user has never touched reads as defaults, not as an error. + #[test] + fn an_unwritten_server_reads_as_disabled() { + let (_t, s) = store(); + let jf = s.get(MediaServerKind::Jellyfin).unwrap(); + assert!(!jf.enabled); + assert!(jf.base_url.is_empty()); + assert!(!jf.is_configured(MediaServerKind::Jellyfin)); + } + + #[test] + fn settings_round_trip_per_server_without_bleeding() { + let (_t, s) = store(); + let jf = MediaServerSettings { + enabled: true, + base_url: "http://jf:8096".into(), + username: "admin".into(), + token: "tok".into(), + device_id: "qbz-abc".into(), + selected_libraries: vec!["lib1".into(), "lib2".into()], + ..Default::default() + }; + s.put(MediaServerKind::Jellyfin, &jf).unwrap(); + + let got = s.get(MediaServerKind::Jellyfin).unwrap(); + assert_eq!(got.base_url, "http://jf:8096"); + assert_eq!(got.token, "tok"); + assert_eq!(got.selected_libraries, vec!["lib1", "lib2"]); + // The other server is untouched. + assert!(!s.get(MediaServerKind::Subsonic).unwrap().enabled); + } + + /// The two protocols disagree about what "credentialed" means, and this is + /// the one place that knows. Getting it backwards would either refuse a + /// working server or hand the sync a connection it cannot authenticate. + #[test] + fn configured_means_a_different_thing_for_each_protocol() { + // Jellyfin: an issued token, and the password is never needed again. + let mut jf = MediaServerSettings { + enabled: true, + base_url: "http://jf:8096".into(), + username: "admin".into(), + ..Default::default() + }; + assert!(!jf.is_configured(MediaServerKind::Jellyfin), "no token yet"); + jf.token = "tok".into(); + assert!(jf.is_configured(MediaServerKind::Jellyfin)); + + // Subsonic: no session at all, so the PASSWORD is what it needs. + let mut sub = MediaServerSettings { + enabled: true, + base_url: "http://nd:4533".into(), + username: "admin".into(), + ..Default::default() + }; + assert!(!sub.is_configured(MediaServerKind::Subsonic), "no password yet"); + sub.password = "pw".into(); + assert!(sub.is_configured(MediaServerKind::Subsonic)); + // ...and a Jellyfin-style token does NOT make it configured. + let token_only = MediaServerSettings { + token: "tok".into(), + ..sub.clone() + }; + let token_only = MediaServerSettings { + password: String::new(), + ..token_only + }; + assert!(!token_only.is_configured(MediaServerKind::Subsonic)); + } + + /// A disabled or unaddressed server is never configured, whatever else it + /// holds — that is what makes the master toggle actually remove its rows + /// from the Local Library union. + #[test] + fn the_master_toggle_and_the_address_both_gate_it() { + let full = MediaServerSettings { + enabled: true, + base_url: "http://jf:8096".into(), + token: "tok".into(), + ..Default::default() + }; + assert!(full.is_configured(MediaServerKind::Jellyfin)); + assert!(!MediaServerSettings { + enabled: false, + ..full.clone() + } + .is_configured(MediaServerKind::Jellyfin)); + assert!(!MediaServerSettings { + base_url: " ".into(), + ..full + } + .is_configured(MediaServerKind::Jellyfin)); + } + + /// Disconnect clears the CREDENTIALS and keeps the picked libraries, so a + /// reconnect does not make the user choose them again. + #[test] + fn disconnect_forgets_the_credentials_but_not_the_choices() { + let (_t, s) = store(); + s.put( + MediaServerKind::Subsonic, + &MediaServerSettings { + enabled: true, + base_url: "http://nd:4533".into(), + username: "admin".into(), + password: "pw".into(), + salt: "fixed".into(), + selected_libraries: vec!["1".into()], + last_sync_tracks: 6678, + ..Default::default() + }, + ) + .unwrap(); + s.disconnect(MediaServerKind::Subsonic).unwrap(); + let got = s.get(MediaServerKind::Subsonic).unwrap(); + assert!(!got.enabled); + assert!(got.password.is_empty(), "the password survived a disconnect"); + assert_eq!(got.last_sync_tracks, 0); + // Kept on purpose. + assert_eq!(got.base_url, "http://nd:4533"); + assert_eq!(got.username, "admin"); + assert_eq!(got.salt, "fixed"); + assert_eq!(got.selected_libraries, vec!["1"]); + } + + #[test] + fn brand_spellings_fold_to_subsonic() { + for w in ["subsonic", "navidrome", "gonic", "airsonic", "astiga"] { + assert_eq!( + MediaServerKind::from_word(w), + Some(MediaServerKind::Subsonic), + "{w}" + ); + } + assert_eq!( + MediaServerKind::from_word("jellyfin"), + Some(MediaServerKind::Jellyfin) + ); + assert_eq!(MediaServerKind::from_word("plex"), None); + assert_eq!(MediaServerKind::from_word("emby"), None); + } + + /// Only a server that is enabled AND finished gets into the union filter. + /// A half-configured one would otherwise widen the SQL over rows that were + /// never swept. + #[test] + fn only_fully_configured_servers_reach_the_union_filter() { + let t = TempDir::new().unwrap(); + let state = MediaServerState::new(); + state.init_at(t.path()); + assert!(state.configured_words().is_empty()); + + state.put( + MediaServerKind::Jellyfin, + &MediaServerSettings { + enabled: true, + base_url: "http://jf:8096".into(), + token: "tok".into(), + ..Default::default() + }, + ); + // Enabled but with no password — not configured. + state.put( + MediaServerKind::Subsonic, + &MediaServerSettings { + enabled: true, + base_url: "http://nd:4533".into(), + username: "admin".into(), + ..Default::default() + }, + ); + assert_eq!(state.configured_words(), vec!["jellyfin"]); + } + + /// An UNBOUND state reads as disabled rather than panicking or looking + /// configured — the same safe direction the rest of the app takes. + #[test] + fn an_unbound_state_reports_nothing_configured() { + let state = MediaServerState::new(); + assert!(!state.get(MediaServerKind::Jellyfin).enabled); + assert!(state.configured_words().is_empty()); + } +} diff --git a/crates/qbz-app/src/settings/mod.rs b/crates/qbz-app/src/settings/mod.rs index 48a0f1577..440dc7a79 100644 --- a/crates/qbz-app/src/settings/mod.rs +++ b/crates/qbz-app/src/settings/mod.rs @@ -1,4 +1,5 @@ pub mod artist_blacklist; +pub mod blacklist_portable; pub mod bundle; pub mod daemon_prefs; pub mod developer; @@ -7,8 +8,11 @@ pub mod favorites; pub mod favorites_cache; pub mod graphics; pub mod local_favorites; +pub mod payload_cache; pub mod pinned_items; pub mod playback; +pub mod playlist_play_history; +pub mod media_servers; pub mod plex; pub mod album_play_history; pub mod reco_store; diff --git a/crates/qbz-app/src/settings/payload_cache.rs b/crates/qbz-app/src/settings/payload_cache.rs new file mode 100644 index 000000000..9b1206dac --- /dev/null +++ b/crates/qbz-app/src/settings/payload_cache.rs @@ -0,0 +1,153 @@ +//! A tiny in-memory, insertion-order LRU for FRONTEND-SHAPED payloads. +//! +//! ## Why this exists, when `search_cache` already does +//! +//! `search_cache` (CAPA A) caches `SearchAllResults` — the raw API shape. It +//! has never been read by anything: `SearchService::cached` has zero non-test +//! callers in either frontend, and `git log -S` shows the read side was never +//! wired in any commit. Reviving it would still leave the expensive half on +//! the table, because a cortinilla payload is not the API response: it is the +//! response after ranking, per-category truncation, i18n section titles, the +//! top-result pick and — the part no API cache can shortcut — the local +//! library query and its derived album/artist grouping. +//! +//! So this caches the FINISHED payload instead, and it is deliberately +//! generic: the store never inspects `T`. That matters, because the two +//! frontends' payload types have diverged (the Slint carries one raw +//! `artwork_url`, the Qt port an `art_url` / `art_path` pair) and unifying +//! them is a refactor neither needs in order to share this. +//! +//! ## Deliberately NOT persisted +//! +//! Process lifetime IS the TTL. A payload embeds local-library rows and +//! resolved artwork paths, so a persisted entry would resurrect albums the +//! user has since deleted and covers that have since moved. There is no +//! timestamp and no staleness rule, and that is the point: nothing here +//! outlives the process, so nothing can go stale across one. +//! +//! The caller is responsible for clearing on the events that invalidate +//! content within a session — a user switch, and any mutation of the local +//! library. + +use std::collections::HashMap; +use std::collections::VecDeque; + +use crate::settings::search_cache::normalize_query; + +/// Default bound. Small on purpose: the value is "the user is refining a +/// query", and a refinement session is a handful of prefixes, not a history. +pub const DEFAULT_CAPACITY: usize = 20; + +/// An insertion-order LRU keyed by the normalized query. +/// +/// The key goes through [`normalize_query`] — the SAME function +/// `search_cache` and `search_ranking` use — so `" Pink Floyd "` and +/// `"pink floyd"` are one entry, here and in the ranking buckets alike. +pub struct PayloadCache { + capacity: usize, + entries: HashMap, + order: VecDeque, +} + +impl PayloadCache { + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + entries: HashMap::new(), + order: VecDeque::new(), + } + } + + /// The cached payload for `query`, if any. Does NOT reorder — this is an + /// insertion-order bound, not a true recency LRU, matching `search_cache`. + pub fn get(&self, query: &str) -> Option<&T> { + self.entries.get(&normalize_query(query)) + } + + /// Insert or replace. Re-inserting an existing key keeps its original + /// position, so a repeatedly refreshed query cannot pin itself forever. + pub fn put(&mut self, query: &str, value: T) { + let key = normalize_query(query); + if self.entries.insert(key.clone(), value).is_none() { + self.order.push_back(key); + } + while self.order.len() > self.capacity { + if let Some(oldest) = self.order.pop_front() { + self.entries.remove(&oldest); + } + } + } + + /// Drop everything. Called on a user switch, on teardown, and on any + /// local-library mutation. + pub fn clear(&mut self) { + self.entries.clear(); + self.order.clear(); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl Default for PayloadCache { + fn default() -> Self { + Self::new(DEFAULT_CAPACITY) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A value type with NO `PartialEq`, NO `Clone` and NO `Serialize`, to + /// prove the store never inspects `T` — which is what lets both frontends + /// share it despite their payload types having diverged. + struct Opaque(#[allow(dead_code)] u32); + + #[test] + fn payload_cache_bounds_normalizes_and_evicts() { + let mut c: PayloadCache = PayloadCache::new(3); + assert!(c.is_empty()); + + c.put("alpha", Opaque(1)); + c.put("beta", Opaque(2)); + assert_eq!(c.len(), 2); + assert!(c.get("alpha").is_some()); + + // The key is normalized: whitespace collapsed, trimmed, lowercased. + c.put(" Pink Floyd ", Opaque(3)); + assert!( + c.get("pink floyd").is_some(), + "the normalized key must match" + ); + assert_eq!(c.len(), 3); + + // Past the bound the OLDEST goes, not the newest. + c.put("gamma", Opaque(4)); + assert_eq!(c.len(), 3, "never grows past the capacity"); + assert!(c.get("alpha").is_none(), "the oldest key was evicted"); + assert!(c.get("gamma").is_some()); + + // Re-inserting an existing key must not push a second order entry, or + // a refreshed query would evict the cache one slot at a time. + c.put("gamma", Opaque(5)); + assert_eq!(c.len(), 3); + assert_eq!(c.order.len(), 3, "no duplicate order entry"); + + c.clear(); + assert!(c.is_empty()); + assert!(c.get("gamma").is_none()); + } + + #[test] + fn payload_cache_capacity_is_never_zero() { + let mut c: PayloadCache = PayloadCache::new(0); + c.put("only", Opaque(1)); + assert_eq!(c.len(), 1, "a zero capacity would make put() a no-op"); + } +} diff --git a/crates/qbz-app/src/settings/pinned_items.rs b/crates/qbz-app/src/settings/pinned_items.rs index 77ecf7d99..ee84a9d83 100644 --- a/crates/qbz-app/src/settings/pinned_items.rs +++ b/crates/qbz-app/src/settings/pinned_items.rs @@ -53,7 +53,7 @@ impl PinnedItemsService { .map_err(|e| format!("Failed to open pinned items database: {}", e))?; // Enable WAL mode for better concurrent access (ADR-002). - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to set WAL mode: {}", e))?; let service = Self { diff --git a/crates/qbz-app/src/settings/playback.rs b/crates/qbz-app/src/settings/playback.rs index 4c2de8915..413a206e3 100644 --- a/crates/qbz-app/src/settings/playback.rs +++ b/crates/qbz-app/src/settings/playback.rs @@ -85,7 +85,7 @@ impl PlaybackPreferencesStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open playback preferences database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| { format!( "Failed to enable WAL for playback preferences database: {}", diff --git a/crates/qbz-app/src/settings/playlist_play_history.rs b/crates/qbz-app/src/settings/playlist_play_history.rs new file mode 100644 index 000000000..59e7eca85 --- /dev/null +++ b/crates/qbz-app/src/settings/playlist_play_history.rs @@ -0,0 +1,469 @@ +//! Local PLAYLIST play history — source of truth for the "Recently Played +//! Playlists" rail. +//! +//! The structural twin of [`crate::settings::album_play_history`], and it +//! exists because that module answered the wrong question. Playing a playlist +//! of 40 tracks drawn from 40 albums used to write 40 ALBUM plays, so +//! "Recently Played" became a list of whatever playlist was on and the +//! playlist itself was recorded nowhere. Contexts are now separated at the +//! write edge (`recently_qt::record_queue_track` reads `QueueTrack +//! ::context_kind`), and this is where the playlist half lands. +//! +//! # Why a NEW file and not a column somewhere +//! +//! `recently_played.json` is a DESTRUCTIVE round trip between the two +//! frontends: the Slint build deserializes it into its own structs, serde +//! drops the fields it does not know, and its next write puts the truncated +//! object back. Anything Qt added there would disappear the first time the +//! other build played a track, intermittently and with nothing logged. A .db +//! the frozen tree has never heard of cannot be destroyed that way — it never +//! opens it. +//! +//! One event per TRACK-START, exactly like the album store (see its header): +//! the de-dup for "recently played" happens in the `GROUP BY`, so listening to +//! forty tracks of one playlist yields ONE card with `plays = 40`. +//! +//! SQLite is opened lazily and every read/write swallows its errors into a +//! `log::warn!`; a fresh user with no .db yet gets an empty list, so the rail +//! self-hides. + +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rusqlite::{params, Connection}; + +static DB: OnceLock>> = OnceLock::new(); + +fn db_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("playlist_play_history.db")) +} + +/// Create the tables + index on a fresh connection (shared by the lazy opener +/// and the in-memory test connections). +fn init_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS playlist_play_events ( + playlist_id TEXT NOT NULL, + occurred_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS playlist_play_events_pl + ON playlist_play_events(playlist_id); + + CREATE TABLE IF NOT EXISTS playlist_meta ( + playlist_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + owner TEXT NOT NULL DEFAULT '', + owner_id TEXT NOT NULL DEFAULT '', + artwork_url TEXT NOT NULL DEFAULT '', + track_count INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL + ); + "#, + )?; + // COVERS + OWN_IMAGE ARE A MIGRATION, not part of the CREATE above, and + // the difference is the whole bug this fixes. + // + // They were originally inlined into the CREATE. `CREATE TABLE IF NOT + // EXISTS` does NOTHING when the table is already there, so anyone who had + // run the previous build kept the old six-column table, every upsert then + // failed on "no such column: covers" — into a swallowed `log::warn!` — and + // `playlist_meta` stayed empty while `playlist_play_events` filled up + // normally. The rail JOINs the two, so it rendered its empty state through + // eight tracks, a restart and a manual refresh, with the plays sitting + // right there in the other table. + // + // A brand-new table is only new ONCE: the moment a build runs anywhere, its + // schema is legacy and every later column is a migration. Same idempotent + // ADD COLUMN the album history already uses. + // + // Up to four MEMBER covers as a JSON array, for the card's mosaic arm — + // most playlists have no graphic of their own, which is exactly why the + // detail page falls back to a collage. + let _ = conn.execute_batch( + "ALTER TABLE playlist_meta ADD COLUMN covers TEXT NOT NULL DEFAULT '[]';", + ); + // The playlist HAS a single graphic of its own (or a custom cover), which + // the card contain-fits instead of building a mosaic. + let _ = conn.execute_batch( + "ALTER TABLE playlist_meta ADD COLUMN own_image INTEGER NOT NULL DEFAULT 0;", + ); + Ok(()) +} + +fn open_db() -> Option { + let path = db_path()?; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = match Connection::open(&path) { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz] playlist_play_history open failed: {e}"); + return None; + } + }; + // ADR-002: WAL, so a read from the render path never blocks on a write. + let _ = conn.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;", + ); + if let Err(e) = init_schema(&conn) { + log::warn!("[qbz] playlist_play_history schema failed: {e}"); + return None; + } + Some(conn) +} + +fn with_db(f: F) -> Option +where + F: FnOnce(&Connection) -> Option, +{ + let cell = DB.get_or_init(|| Mutex::new(open_db())); + let guard = cell.lock().ok()?; + let conn = guard.as_ref()?; + f(conn) +} + +/// Playlist metadata captured at play time (refreshed on every play, so +/// renames and cover changes converge). +pub struct PlaylistPlayMeta<'a> { + /// The Qobuz numeric id as text, or `"local:"` for a first-class + /// local playlist. It is whatever `PlayContext::playlist(..)` stamped, so + /// the event and the card can never key differently. + pub playlist_id: &'a str, + pub title: &'a str, + pub owner: &'a str, + pub owner_id: &'a str, + pub artwork_url: &'a str, + pub track_count: u32, + /// `"qobuz"` | `"local"` — where the playlist itself lives, NOT where its + /// audio comes from (a Qobuz playlist can hold local rows). + pub source: &'a str, + /// Member covers for the mosaic (up to four). Remote urls for a Qobuz + /// playlist, local file paths for a local one — the artwork cache resolves + /// both, so the card does not care which it got. + pub covers: &'a [String], + /// True when [`Self::artwork_url`] is the playlist's OWN single graphic + /// (or a custom cover) rather than a stand-in member cover. + pub own_image: bool, +} + +/// One playlist row for the rail / a View-all page. +#[derive(Clone, Default, Debug, PartialEq)] +pub struct PlaylistPlayRow { + pub playlist_id: String, + pub title: String, + pub owner: String, + pub owner_id: String, + pub artwork_url: String, + pub track_count: u32, + pub source: String, + pub covers: Vec, + pub own_image: bool, + pub plays: u32, +} + +/// Upsert the meta WITHOUT recording a play. +/// +/// The two halves are separate on purpose. The meta is known by the view that +/// starts playback (it has the header in hand); the event is written at the +/// track-start edge, which only ever sees a `QueueTrack` and its +/// `context_id`. Splitting them keeps the edge from having to resolve a +/// playlist id back into a title on every single track change. +fn upsert_meta_on(conn: &Connection, m: &PlaylistPlayMeta, now: i64) { + if let Err(e) = conn.execute( + r#" + INSERT INTO playlist_meta + (playlist_id, title, owner, owner_id, artwork_url, + track_count, source, covers, own_image, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(playlist_id) DO UPDATE SET + title = excluded.title, + owner = excluded.owner, + owner_id = excluded.owner_id, + artwork_url = excluded.artwork_url, + track_count = excluded.track_count, + source = excluded.source, + covers = excluded.covers, + own_image = excluded.own_image, + updated_at = excluded.updated_at + "#, + params![ + m.playlist_id, + m.title, + m.owner, + m.owner_id, + m.artwork_url, + m.track_count, + m.source, + serde_json::to_string(m.covers).unwrap_or_else(|_| "[]".to_string()), + m.own_image as i32, + now + ], + ) { + // ERROR, not warn: this is the half of the pair whose absence makes the + // rail render its empty state while the events pile up next to it — a + // failure that is invisible in the UI by construction. + log::error!("[qbz] playlist_play_history upsert meta failed: {e}"); + } +} + +fn record_event_on(conn: &Connection, playlist_id: &str, now: i64) { + if let Err(e) = conn.execute( + "INSERT INTO playlist_play_events (playlist_id, occurred_at) VALUES (?, ?)", + params![playlist_id, now], + ) { + log::warn!("[qbz] playlist_play_history insert event failed: {e}"); + } +} + +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Remember what a playlist IS, so a later play event can render a card. +/// Called by the views that start playback from a playlist. No-op on an empty +/// id. +pub fn record_playlist_meta(m: PlaylistPlayMeta) { + if m.playlist_id.is_empty() { + return; + } + let now = now_secs(); + with_db(|conn| { + upsert_meta_on(conn, &m, now); + Some(()) + }); +} + +/// Record one play event. Called at the track-start edge when the track's +/// playback context is a playlist. No-op on an empty id. +pub fn record_playlist_play(playlist_id: &str) { + if playlist_id.is_empty() { + return; + } + let now = now_secs(); + with_db(|conn| { + record_event_on(conn, playlist_id, now); + Some(()) + }); +} + +/// `order_desc` is the ORDER BY body — the only difference between "recently +/// played" and "most played", which is why both come out of one query. +fn query_on(conn: &Connection, order: &str, limit: Option) -> Vec { + let sql = format!( + r#" + SELECT m.playlist_id, m.title, m.owner, m.owner_id, m.artwork_url, + m.track_count, m.source, m.covers, m.own_image, p.plays + FROM playlist_meta m + JOIN ( + SELECT playlist_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at + FROM playlist_play_events + GROUP BY playlist_id + ) p ON p.playlist_id = m.playlist_id + ORDER BY {order} + {} + "#, + limit.map(|n| format!("LIMIT {n}")).unwrap_or_default() + ); + let out = (|| -> Option> { + let mut stmt = conn.prepare(&sql).ok()?; + let rows = stmt + .query_map([], |row| { + Ok(PlaylistPlayRow { + playlist_id: row.get(0)?, + title: row.get(1)?, + owner: row.get(2)?, + owner_id: row.get(3)?, + artwork_url: row.get(4)?, + track_count: row.get::<_, i64>(5)? as u32, + source: row.get(6)?, + covers: serde_json::from_str(&row.get::<_, String>(7)?) + .unwrap_or_default(), + own_image: row.get::<_, i64>(8)? != 0, + plays: row.get::<_, i64>(9)? as u32, + }) + }) + .ok()?; + Some(rows.flatten().collect()) + })(); + out.unwrap_or_default() +} + +/// The `limit` most recently played playlists (the rail). +pub fn recent_playlists(limit: u32) -> Vec { + with_db(|conn| Some(query_on(conn, "p.last_at DESC", Some(limit)))).unwrap_or_default() +} + +/// Every played playlist, most recent first (a "View all" page). +pub fn all_recent_playlists() -> Vec { + with_db(|conn| Some(query_on(conn, "p.last_at DESC", None))).unwrap_or_default() +} + +/// Ranked by play count. Not wired to a rail — the owner asked for one rail, +/// and this falls out of the same table for free when a second one is wanted. +#[allow(dead_code)] +pub fn top_playlists(limit: u32) -> Vec { + with_db(|conn| Some(query_on(conn, "p.plays DESC, p.last_at DESC", Some(limit)))) + .unwrap_or_default() +} + +/// Drop every trace of the given playlists — the parity of +/// `recently_qt::prune_albums`, for a playlist the user deleted or unfollowed. +/// Returns how many meta rows went. +pub fn prune_playlists(ids: &[String]) -> usize { + if ids.is_empty() { + return 0; + } + with_db(|conn| { + let mut gone = 0usize; + for id in ids { + let _ = conn.execute( + "DELETE FROM playlist_play_events WHERE playlist_id = ?", + params![id], + ); + gone += conn + .execute("DELETE FROM playlist_meta WHERE playlist_id = ?", params![id]) + .unwrap_or(0); + } + Some(gone) + }) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta<'a>(id: &'a str, title: &'a str) -> PlaylistPlayMeta<'a> { + PlaylistPlayMeta { + playlist_id: id, + title, + owner: "Someone", + owner_id: "42", + artwork_url: "http://art", + track_count: 12, + source: "qobuz", + covers: &[], + own_image: false, + } + } + + fn mem() -> Connection { + let c = Connection::open_in_memory().unwrap(); + init_schema(&c).unwrap(); + c + } + + #[test] + fn recent_orders_by_last_play_not_by_count() { + let c = mem(); + // A is played far more, B far more RECENTLY. "Recently played" must + // put B first — that is the whole difference from "most played". + upsert_meta_on(&c, &meta("A", "Playlist A"), 100); + upsert_meta_on(&c, &meta("B", "Playlist B"), 100); + for i in 0..30 { + record_event_on(&c, "A", 100 + i); + } + record_event_on(&c, "B", 9_000); + + let recent = query_on(&c, "p.last_at DESC", Some(10)); + assert_eq!(recent.len(), 2); + assert_eq!(recent[0].playlist_id, "B"); + assert_eq!(recent[1].playlist_id, "A"); + assert_eq!(recent[1].plays, 30); + + let top = query_on(&c, "p.plays DESC, p.last_at DESC", Some(10)); + assert_eq!(top[0].playlist_id, "A"); + } + + #[test] + fn one_card_per_playlist_however_many_tracks() { + let c = mem(); + upsert_meta_on(&c, &meta("A", "Playlist A"), 100); + for i in 0..40 { + record_event_on(&c, "A", 100 + i); + } + let recent = query_on(&c, "p.last_at DESC", None); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].plays, 40); + } + + #[test] + fn meta_without_a_play_does_not_show() { + // The JOIN is what enforces it: opening a playlist upserts nothing on + // its own, and even if it did, a card with no play is not "recently + // played". + let c = mem(); + upsert_meta_on(&c, &meta("A", "Playlist A"), 100); + assert!(query_on(&c, "p.last_at DESC", None).is_empty()); + } + + /// THE REGRESSION TEST for the bug the migration above exists to fix: a + /// database created by the FIRST build, whose `playlist_meta` has neither + /// `covers` nor `own_image`. `CREATE TABLE IF NOT EXISTS` leaves it alone, + /// so without the ALTERs the upsert fails on a missing column, the meta + /// table stays empty, and the rail renders its empty state forever while + /// the events pile up beside it. + #[test] + fn init_schema_migrates_a_pre_covers_database() { + let c = Connection::open_in_memory().unwrap(); + // The exact shape the first build shipped. + c.execute_batch( + r#" + CREATE TABLE playlist_play_events ( + playlist_id TEXT NOT NULL, + occurred_at INTEGER NOT NULL + ); + CREATE TABLE playlist_meta ( + playlist_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + owner TEXT NOT NULL DEFAULT '', + owner_id TEXT NOT NULL DEFAULT '', + artwork_url TEXT NOT NULL DEFAULT '', + track_count INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL + ); + "#, + ) + .unwrap(); + // A play recorded by that build, which must survive the migration. + record_event_on(&c, "A", 100); + + init_schema(&c).unwrap(); + + // The upsert now works, and the pre-existing event still counts. + upsert_meta_on(&c, &meta("A", "Playlist A"), 200); + let rows = query_on(&c, "p.last_at DESC", None); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].playlist_id, "A"); + assert_eq!(rows[0].plays, 1); + assert!(rows[0].covers.is_empty()); + assert!(!rows[0].own_image); + + // And running it AGAIN is a no-op rather than an error — every launch + // calls it. + init_schema(&c).unwrap(); + assert_eq!(query_on(&c, "p.last_at DESC", None).len(), 1); + } + + #[test] + fn prune_removes_events_and_meta() { + let c = mem(); + upsert_meta_on(&c, &meta("A", "Playlist A"), 100); + upsert_meta_on(&c, &meta("B", "Playlist B"), 100); + record_event_on(&c, "A", 100); + record_event_on(&c, "B", 100); + let _ = c.execute("DELETE FROM playlist_play_events WHERE playlist_id = 'A'", []); + let _ = c.execute("DELETE FROM playlist_meta WHERE playlist_id = 'A'", []); + let left = query_on(&c, "p.last_at DESC", None); + assert_eq!(left.len(), 1); + assert_eq!(left[0].playlist_id, "B"); + } +} diff --git a/crates/qbz-app/src/settings/plex.rs b/crates/qbz-app/src/settings/plex.rs index 1070c1af2..58db60411 100644 --- a/crates/qbz-app/src/settings/plex.rs +++ b/crates/qbz-app/src/settings/plex.rs @@ -79,7 +79,7 @@ impl PlexSettingsStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open Plex settings database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to enable WAL for Plex settings database: {}", e))?; // Base (slice 2a) schema. New columns are added by idempotent ALTERs diff --git a/crates/qbz-app/src/settings/reco_store.rs b/crates/qbz-app/src/settings/reco_store.rs index 8cbd4880a..28045de5a 100644 --- a/crates/qbz-app/src/settings/reco_store.rs +++ b/crates/qbz-app/src/settings/reco_store.rs @@ -196,7 +196,7 @@ impl RecoStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open reco database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to enable WAL for reco database: {}", e))?; let store = Self { conn }; @@ -335,11 +335,11 @@ impl RecoStore { params![ event.event_type.as_str(), event.item_type.as_str(), - event.track_id, + event.track_id.map(|v| v as i64), event.album_id.as_deref(), - event.artist_id, - event.playlist_id, - event.genre_id, + event.artist_id.map(|v| v as i64), + event.playlist_id.map(|v| v as i64), + event.genre_id.map(|v| v as i64), now_ts(), ], ) @@ -405,7 +405,7 @@ impl RecoStore { .map_err(|e| format!("Failed to prepare recent tracks query: {}", e))?; let rows = stmt - .query_map(params![limit], |row| row.get::<_, u64>(0)) + .query_map(params![limit], |row| row.get::<_, i64>(0).map(|v| v as u64)) .map_err(|e| format!("Failed to query recent tracks: {}", e))?; let mut tracks = Vec::new(); @@ -439,7 +439,7 @@ impl RecoStore { .map_err(|e| format!("Failed to prepare windowed recent tracks query: {}", e))?; let rows = stmt - .query_map(params![since_ts, limit], |row| row.get::<_, u64>(0)) + .query_map(params![since_ts, limit], |row| row.get::<_, i64>(0).map(|v| v as u64)) .map_err(|e| format!("Failed to query windowed recent tracks: {}", e))?; let mut tracks = Vec::new(); @@ -467,7 +467,7 @@ impl RecoStore { .map_err(|e| format!("Failed to prepare favorite tracks query: {}", e))?; let rows = stmt - .query_map(params![limit], |row| row.get::<_, u64>(0)) + .query_map(params![limit], |row| row.get::<_, i64>(0).map(|v| v as u64)) .map_err(|e| format!("Failed to query favorite tracks: {}", e))?; let mut tracks = Vec::new(); @@ -542,7 +542,7 @@ impl RecoStore { let rows = stmt .query_map(params![limit], |row| { Ok(TopArtistSeed { - artist_id: row.get::<_, u64>(0)?, + artist_id: row.get::<_, i64>(0).map(|v| v as u64)?, play_count: row.get::<_, u32>(1)?, }) }) @@ -576,7 +576,7 @@ impl RecoStore { ) .map_err(|e| format!("Failed to prepare known artists query: {}", e))?; let rows = stmt - .query_map(params![play_threshold], |row| row.get::<_, u64>(0)) + .query_map(params![play_threshold], |row| row.get::<_, i64>(0).map(|v| v as u64)) .map_err(|e| format!("Failed to query known artists: {}", e))?; let mut ids = std::collections::HashSet::new(); for row in rows { @@ -604,7 +604,7 @@ impl RecoStore { .map_err(|e| format!("Failed to prepare top genres query: {}", e))?; let rows = stmt .query_map(params![limit], |row| { - Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)) + Ok((row.get::<_, i64>(0).map(|v| v as u64)?, row.get::<_, String>(1)?)) }) .map_err(|e| format!("Failed to query top genres: {}", e))?; let mut genres = Vec::new(); @@ -703,7 +703,7 @@ impl RecoStore { ) .map_err(|e| format!("Failed to prepare scored tracks query: {}", e))?; let rows = stmt - .query_map(params![score_type, limit], |row| row.get::<_, u64>(0)) + .query_map(params![score_type, limit], |row| row.get::<_, i64>(0).map(|v| v as u64)) .map_err(|e| format!("Failed to query scored tracks: {}", e))?; let mut out = Vec::new(); for row in rows { @@ -729,7 +729,7 @@ impl RecoStore { .map_err(|e| format!("Failed to prepare scored artists query: {}", e))?; let rows = stmt .query_map(params![score_type, limit], |row| { - Ok((row.get::<_, u64>(0)?, row.get::<_, f64>(1)?)) + Ok((row.get::<_, i64>(0).map(|v| v as u64)?, row.get::<_, f64>(1)?)) }) .map_err(|e| format!("Failed to query scored artists: {}", e))?; let mut out = Vec::new(); @@ -761,10 +761,10 @@ impl RecoStore { Ok(RecoEventRecord { event_type: row.get(0)?, item_type: row.get(1)?, - track_id: row.get(2)?, + track_id: row.get::<_, Option>(2)?.map(|v| v as u64), album_id: row.get(3)?, - artist_id: row.get(4)?, - genre_id: row.get(5)?, + artist_id: row.get::<_, Option>(4)?.map(|v| v as u64), + genre_id: row.get::<_, Option>(5)?.map(|v| v as u64), created_at: row.get(6)?, }) }) @@ -808,9 +808,9 @@ impl RecoStore { stmt.execute(params![ score_type, item_type, - entry.track_id, + entry.track_id.map(|v| v as i64), entry.album_id.as_deref(), - entry.artist_id, + entry.artist_id.map(|v| v as i64), entry.score, updated_at, ]) @@ -1071,7 +1071,7 @@ impl RecoStore { .conn .execute( "UPDATE reco_events SET genre_id = ? WHERE album_id = ? AND genre_id IS NULL", - params![genre_id, album_id], + params![genre_id as i64, album_id], ) .map_err(|e| format!("Failed to update genre for album: {}", e))?; Ok(affected as u64) @@ -1147,7 +1147,7 @@ mod tests { r#"INSERT INTO reco_events (event_type, item_type, track_id, album_id, artist_id, playlist_id, genre_id, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)"#, - params![event_type, item_type, track_id, album_id, artist_id, genre_id, created_at], + params![event_type, item_type, track_id.map(|v| v as i64), album_id, artist_id.map(|v| v as i64), genre_id.map(|v| v as i64), created_at], ) .expect("insert event at ts"); } diff --git a/crates/qbz-app/src/settings/remote_control.rs b/crates/qbz-app/src/settings/remote_control.rs index 86eaee898..c4a4c1ba4 100644 --- a/crates/qbz-app/src/settings/remote_control.rs +++ b/crates/qbz-app/src/settings/remote_control.rs @@ -49,7 +49,7 @@ impl RemoteControlSettingsStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open remote control settings database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| { format!( "Failed to enable WAL for remote control settings database: {}", @@ -305,7 +305,7 @@ impl AllowedOriginsStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open allowed origins database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to enable WAL for allowed origins database: {}", e))?; conn.execute_batch( diff --git a/crates/qbz-app/src/settings/scrobblers.rs b/crates/qbz-app/src/settings/scrobblers.rs index 0e0afec14..3375488d2 100644 --- a/crates/qbz-app/src/settings/scrobblers.rs +++ b/crates/qbz-app/src/settings/scrobblers.rs @@ -33,7 +33,7 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::sync::{Arc, Mutex}; -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScrobblerSettings { /// Master toggle. When false, the whole scrobblers section body is hidden. /// Default OFF — integrations are opt-in. @@ -56,6 +56,26 @@ pub struct ScrobblerSettings { pub listenbrainz_token: String, /// ListenBrainz username (`UserInfo.user_name`). pub listenbrainz_username: String, + + /// Keep the independent scrobblers active without a Qobuz session. + /// Default ON: Qobuz authentication is unrelated to Last.fm/LB auth. + pub allow_logged_out_scrobbling: bool, +} + +impl Default for ScrobblerSettings { + fn default() -> Self { + Self { + enabled: false, + ui_collapsed: false, + lastfm_enabled: false, + lastfm_session_key: String::new(), + lastfm_username: String::new(), + listenbrainz_enabled: false, + listenbrainz_token: String::new(), + listenbrainz_username: String::new(), + allow_logged_out_scrobbling: true, + } + } } impl ScrobblerSettings { @@ -78,6 +98,25 @@ impl ScrobblerSettings { pub fn listenbrainz_active(&self) -> bool { self.enabled && self.listenbrainz_enabled && self.listenbrainz_is_authed() } + + /// Whether QBZ may READ the user's listening history from an external + /// service (Recommendations). ONE rule, the same as sending: connected + /// AND scrobbling on. A connected-but-disabled account used to be read + /// silently through `*_is_authed()`; the scrobbling toggle now governs + /// both directions, and its Settings text says so. + pub fn history_read_allowed(&self, service: HistoryService) -> bool { + match service { + HistoryService::LastFm => self.lastfm_active(), + HistoryService::ListenBrainz => self.listenbrainz_active(), + } + } +} + +/// The external services whose history Recommendations may read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryService { + LastFm, + ListenBrainz, } pub struct ScrobblerSettingsStore { @@ -93,7 +132,7 @@ impl ScrobblerSettingsStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open scrobbler settings database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to enable WAL for scrobbler settings database: {}", e))?; conn.execute_batch( @@ -106,11 +145,20 @@ impl ScrobblerSettingsStore { lastfm_username TEXT NOT NULL DEFAULT '', listenbrainz_enabled INTEGER NOT NULL DEFAULT 0, listenbrainz_token TEXT NOT NULL DEFAULT '', - listenbrainz_username TEXT NOT NULL DEFAULT '' + listenbrainz_username TEXT NOT NULL DEFAULT '', + allow_logged_out_scrobbling INTEGER NOT NULL DEFAULT 1 );", ) .map_err(|e| format!("Failed to create scrobbler settings table: {}", e))?; + // Existing Slint/Qt profiles predate logged-out mode. Additive and + // default-on, so their established scrobbler setup keeps working. + let _ = conn.execute( + "ALTER TABLE scrobbler_settings + ADD COLUMN allow_logged_out_scrobbling INTEGER NOT NULL DEFAULT 1", + [], + ); + conn.execute( "INSERT OR IGNORE INTO scrobbler_settings (id, enabled, ui_collapsed, lastfm_enabled, lastfm_session_key, @@ -135,7 +183,7 @@ impl ScrobblerSettingsStore { .query_row( "SELECT enabled, ui_collapsed, lastfm_enabled, lastfm_session_key, lastfm_username, listenbrainz_enabled, listenbrainz_token, - listenbrainz_username + listenbrainz_username, allow_logged_out_scrobbling FROM scrobbler_settings WHERE id = 1", [], |row| { @@ -147,6 +195,7 @@ impl ScrobblerSettingsStore { let listenbrainz_enabled: i32 = row.get(5)?; let listenbrainz_token: String = row.get(6)?; let listenbrainz_username: String = row.get(7)?; + let allow_logged_out_scrobbling: i32 = row.get(8)?; Ok(ScrobblerSettings { enabled: enabled != 0, ui_collapsed: ui_collapsed != 0, @@ -156,6 +205,7 @@ impl ScrobblerSettingsStore { listenbrainz_enabled: listenbrainz_enabled != 0, listenbrainz_token, listenbrainz_username, + allow_logged_out_scrobbling: allow_logged_out_scrobbling != 0, }) }, ) @@ -182,6 +232,17 @@ impl ScrobblerSettingsStore { Ok(()) } + pub fn set_allow_logged_out_scrobbling(&self, value: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE scrobbler_settings + SET allow_logged_out_scrobbling = ?1 WHERE id = 1", + params![if value { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to set logged-out scrobbling: {}", e))?; + Ok(()) + } + // --- Last.fm --- pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> { @@ -315,6 +376,10 @@ impl ScrobblerSettingsState { self.with_store(|s| s.set_ui_collapsed(value)) } + pub fn set_allow_logged_out_scrobbling(&self, value: bool) -> Result<(), String> { + self.with_store(|s| s.set_allow_logged_out_scrobbling(value)) + } + pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> { self.with_store(|s| s.set_lastfm_enabled(value)) } @@ -363,6 +428,7 @@ mod tests { assert!(!s.listenbrainz_enabled); assert!(s.listenbrainz_token.is_empty()); assert!(s.listenbrainz_username.is_empty()); + assert!(s.allow_logged_out_scrobbling); assert!(!s.lastfm_is_authed()); assert!(!s.listenbrainz_is_authed()); assert!(!s.lastfm_active()); @@ -375,6 +441,7 @@ mod tests { let store = ScrobblerSettingsStore::new_at(&dir).expect("open store"); let s = store.get_settings().expect("get settings"); assert!(!s.enabled); + assert!(s.allow_logged_out_scrobbling); assert!(!s.lastfm_is_authed()); assert!(!s.listenbrainz_is_authed()); let _ = std::fs::remove_dir_all(dir); @@ -387,6 +454,9 @@ mod tests { let store = ScrobblerSettingsStore::new_at(&dir).expect("open store"); store.set_enabled(true).expect("enabled"); store.set_ui_collapsed(true).expect("collapsed"); + store + .set_allow_logged_out_scrobbling(false) + .expect("logged-out scrobbling"); store.set_lastfm_enabled(true).expect("lfm enabled"); store .set_lastfm_session(" sk-123 ", " alice ") @@ -400,6 +470,7 @@ mod tests { let s = reopened.get_settings().expect("get settings"); assert!(s.enabled); assert!(s.ui_collapsed); + assert!(!s.allow_logged_out_scrobbling); assert!(s.lastfm_enabled); // set_lastfm_session trims whitespace. assert_eq!(s.lastfm_session_key, "sk-123"); @@ -412,6 +483,37 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn existing_database_migrates_logged_out_scrobbling_default_on() { + let dir = unique_test_dir("scrobbler-migrate-logged-out"); + std::fs::create_dir_all(&dir).unwrap(); + { + let conn = Connection::open(dir.join("scrobbler_settings.db")).unwrap(); + conn.execute_batch( + "CREATE TABLE scrobbler_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0, + ui_collapsed INTEGER NOT NULL DEFAULT 0, + lastfm_enabled INTEGER NOT NULL DEFAULT 0, + lastfm_session_key TEXT NOT NULL DEFAULT '', + lastfm_username TEXT NOT NULL DEFAULT '', + listenbrainz_enabled INTEGER NOT NULL DEFAULT 0, + listenbrainz_token TEXT NOT NULL DEFAULT '', + listenbrainz_username TEXT NOT NULL DEFAULT '' + ); + INSERT INTO scrobbler_settings VALUES (1, 1, 0, 0, '', '', 0, '', '');", + ) + .unwrap(); + } + + let store = ScrobblerSettingsStore::new_at(&dir).expect("migrate store"); + let settings = store.get_settings().expect("get migrated settings"); + assert!(settings.enabled); + assert!(settings.allow_logged_out_scrobbling); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn scrobbler_disconnect_keeps_enable_flags() { let dir = unique_test_dir("scrobbler-disconnect"); diff --git a/crates/qbz-app/src/settings/subscription.rs b/crates/qbz-app/src/settings/subscription.rs index 722a61eab..505bc8bf7 100644 --- a/crates/qbz-app/src/settings/subscription.rs +++ b/crates/qbz-app/src/settings/subscription.rs @@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex}; /// /// The primary protection for offline files is the CMAF-at-rest cache format. /// This grace period is an additional compliance guard, not the main defense. -const GRACE_PERIOD_SECS: i64 = 30 * 24 * 60 * 60; +pub const GRACE_PERIOD_SECS: i64 = 30 * 24 * 60 * 60; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -55,7 +55,7 @@ impl SubscriptionStateStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open subscription state database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| { format!( "Failed to enable WAL for subscription state database: {}", diff --git a/crates/qbz-app/src/settings/tray.rs b/crates/qbz-app/src/settings/tray.rs index 64e491b16..e4efa79d2 100644 --- a/crates/qbz-app/src/settings/tray.rs +++ b/crates/qbz-app/src/settings/tray.rs @@ -78,7 +78,7 @@ impl TraySettingsStore { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open tray settings database: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") .map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?; conn.execute_batch( diff --git a/crates/qbz-app/src/shell.rs b/crates/qbz-app/src/shell.rs index 31ca79198..6ed3fcc07 100644 --- a/crates/qbz-app/src/shell.rs +++ b/crates/qbz-app/src/shell.rs @@ -1,36 +1,35 @@ //! Framework-agnostic application runtime facade. //! -//! [`AppRuntime`] is the composition root that a non-Tauri UI shell (Slint, -//! TUI, headless) builds on. It owns an `Arc>`, the framework- +//! [`AppRuntime`] is the composition root that a native UI shell (Qt, TUI, +//! headless) builds on. It owns an `Arc>`, the framework- //! agnostic runtime state machine, and the per-user session, all without any //! Tauri dependency. //! -//! Scope (Slint POC readiness audit, sessions 21-22): -//! -//! - Task 1 — composition and accessors: [`AppRuntime::new`], [`AppRuntime::core`]. -//! - Task 2 — minimal session activation: [`AppRuntime::activate`] and -//! friends. This is deliberately minimal. It opens only the session store -//! and performs the portable session scaffolding (user paths, directories, -//! last-user marker, runtime state). It does NOT touch Tauri's -//! `session_lifecycle`, does not initialize the `src-tauri`-side per-user -//! stores (`library`, `reco`, `lyrics`, ...), and does not run the -//! flat-to-user migration. A shell opens further stores per view, as the -//! views that need them come online. -//! -//! The Tauri app does not consume this module; `CoreBridge` and -//! `session_lifecycle` keep their own paths. `AppRuntime` is purely additive. +//! Session activation is deliberately minimal: it opens the session store and +//! performs the portable scaffolding (user paths, directories, last-user +//! marker, runtime state). The shell binds its additional per-user stores as +//! each session comes online. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, VisualizerTap}; use qbz_core::{FrontendAdapter, QbzCore}; +use qbz_offline_cache::OfflineCacheState; use qbz_player::Player; +use tokio::task::JoinHandle; use crate::runtime::RuntimeManager; use crate::session_store::SessionStore; +use crate::subscription_lifecycle; use crate::user_data::UserDataPaths; +/// How often an activated session re-checks the subscription grace clock, +/// so a long-running session without a re-login still crosses the 30-day +/// line on time. +const SUBSCRIPTION_TICK: Duration = Duration::from_secs(24 * 60 * 60); + /// The per-user stores opened for the currently active session. /// /// Minimal by design: it holds only the session store. A shell opens further @@ -38,9 +37,13 @@ use crate::user_data::UserDataPaths; /// loading the full WebKit-era store set up front. struct ActiveSession { user_id: u64, + data_dir: PathBuf, session_store: SessionStore, } +/// The offline cache a host registered, shared with the subscription ticker. +type OfflineCacheSlot = Arc>>>; + /// Composition root for a non-Tauri UI shell. /// /// Generic over the [`FrontendAdapter`] the shell supplies, so the same @@ -54,6 +57,12 @@ pub struct AppRuntime { /// the FFT producer and toggle capture. `None` for shells that do not drive /// audio visualization (the default `new`/`with_audio_settings` path). visualizer_tap: Option, + /// The host's offline cache, if it has one (Qt does, qbzd does not). + /// The subscription lifecycle purges through it once the grace window + /// has passed; without one the verdict is still recorded. + offline_cache: OfflineCacheSlot, + /// The daily subscription re-check for the active session. + subscription_ticker: Mutex>>, } impl AppRuntime { @@ -76,6 +85,8 @@ impl AppRuntime { user_paths: UserDataPaths::new(), session: Mutex::new(None), visualizer_tap: None, + offline_cache: Arc::new(Mutex::new(None)), + subscription_ticker: Mutex::new(None), } } @@ -103,9 +114,9 @@ impl AppRuntime { /// Build like [`AppRuntime::new`], but also wire a [`VisualizerTap`] into the /// player and retain it so the shell can start the frontend-agnostic FFT /// producer ([`qbz_audio::visualizer::spawn_visualizer_thread`]) and toggle - /// capture via the tap's `set_enabled`. Used by the Slint shell for the - /// ImmersiveView audio visualizers. The tap starts disabled, so it adds no - /// runtime cost until the immersive view enables it. + /// capture via the tap's `set_enabled`. Used by the Qt shell for the + /// immersive-view audio visualizers. The tap starts disabled, so it adds + /// no runtime cost until the immersive view enables it. pub fn with_visualizer(adapter: A) -> Self { let (device_name, audio_settings) = AudioSettingsStore::new() .ok() @@ -174,19 +185,106 @@ impl AppRuntime { self.runtime.set_session_activated(true, user_id).await; - let mut guard = self - .session - .lock() - .map_err(|e| format!("session lock poisoned: {}", e))?; - *guard = Some(ActiveSession { - user_id, - session_store, - }); + { + let mut guard = self + .session + .lock() + .map_err(|e| format!("session lock poisoned: {}", e))?; + *guard = Some(ActiveSession { + user_id, + data_dir: data_dir.to_path_buf(), + session_store, + }); + } log::info!("[AppRuntime] Session activated for user"); + + // Subscription lifecycle (D4): record the login verdict from the + // core's session — a member without the offline entitlement starts + // the grace clock, a subscriber clears it — and purge the offline + // cache when the window has passed. Shared by every host through + // this one activation path; best-effort, never blocks entry. + self.run_subscription_lifecycle().await; + self.start_subscription_ticker(data_dir.to_path_buf()); Ok(()) } + /// Register (or clear) the host's offline cache. When a session is + /// already active and a cache arrives, the grace clock is enforced + /// against it right away — Qt activates its cache after the session. + pub fn set_offline_cache(self: &Arc, cache: Option>) { + let registered = cache.is_some(); + if let Ok(mut slot) = self.offline_cache.lock() { + *slot = cache; + } + if registered && self.is_session_active() { + let runtime = Arc::clone(self); + tokio::spawn(async move { runtime.run_subscription_lifecycle().await }); + } + } + + async fn run_subscription_lifecycle(&self) { + let Some(data_dir) = self.active_data_dir() else { + return; + }; + let session = { + let client_lock = self.core.client(); + let guard = client_lock.read().await; + match guard.as_ref() { + Some(client) => client.session().await, + None => None, + } + }; + let offline = self.offline_cache.lock().ok().and_then(|slot| slot.clone()); + let now = subscription_lifecycle::now_unix_secs(); + match subscription_lifecycle::run(&data_dir, session.as_ref(), offline, now).await { + Ok(v) => log::info!( + "[AppRuntime] subscription verdict: offline_entitled={} member_only={} invalid_since={:?} purged={}", + v.offline_entitled, + v.member_only, + v.invalid_since, + v.purged + ), + Err(e) => log::error!("[AppRuntime] subscription lifecycle failed: {e}"), + } + } + + fn start_subscription_ticker(&self, data_dir: PathBuf) { + let offline_cache = Arc::clone(&self.offline_cache); + let handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(SUBSCRIPTION_TICK); + interval.tick().await; // the activation pass already ran + loop { + interval.tick().await; + let offline = offline_cache.lock().ok().and_then(|slot| slot.clone()); + let now = subscription_lifecycle::now_unix_secs(); + if let Err(e) = subscription_lifecycle::run(&data_dir, None, offline, now).await { + log::error!("[AppRuntime] subscription tick failed: {e}"); + } + } + }); + if let Ok(mut ticker) = self.subscription_ticker.lock() { + if let Some(previous) = ticker.replace(handle) { + previous.abort(); + } + } + } + + fn stop_subscription_ticker(&self) { + if let Ok(mut ticker) = self.subscription_ticker.lock() { + if let Some(handle) = ticker.take() { + handle.abort(); + } + } + } + + fn active_data_dir(&self) -> Option { + self.session + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(|s| s.data_dir.clone())) + } + /// Activate the per-user session for `user_id`. /// /// Resolves the real per-user directories through [`UserDataPaths`], @@ -260,6 +358,7 @@ impl AppRuntime { /// `last_user_id` marker is intentionally kept on disk so a later /// offline session can still find the user's data. pub async fn deactivate(&self) -> Result<(), String> { + self.stop_subscription_ticker(); { let mut guard = self .session @@ -318,6 +417,11 @@ mod tests { } fn test_runtime() -> AppRuntime { + // QbzCore builds a reqwest client (MusicBrainz); since reqwest no + // longer pulls a rustls provider (#663), tests must install the + // process-level one explicitly, exactly like the real binaries do at + // startup — otherwise Client construction panics with "No provider set". + crate::ensure_crypto_provider(); AppRuntime::with_audio_settings(NoOpAdapter, None, AudioSettings::default(), None) } @@ -365,6 +469,27 @@ mod tests { let _ = std::fs::remove_dir_all(&cache_dir); } + /// User zero is the first-run, never-authenticated profile selected by + /// `activate_offline`. It is a real local session, not an auth sentinel. + #[tokio::test] + async fn guest_profile_zero_is_a_valid_local_session() { + let rt = test_runtime(); + let data_dir = unique_test_dir("guest-data"); + let cache_dir = unique_test_dir("guest-cache"); + + rt.activate_at(0, &data_dir, &cache_dir) + .await + .expect("guest activation succeeds without Qobuz auth"); + + assert!(rt.is_session_active()); + assert_eq!(rt.active_user_id(), Some(0)); + assert!(data_dir.join("session.db").exists()); + + rt.deactivate().await.expect("guest deactivation succeeds"); + let _ = std::fs::remove_dir_all(&data_dir); + let _ = std::fs::remove_dir_all(&cache_dir); + } + #[tokio::test] async fn deactivate_clears_session_and_runtime() { let rt = test_runtime(); diff --git a/crates/qbz-app/src/subscription_lifecycle.rs b/crates/qbz-app/src/subscription_lifecycle.rs new file mode 100644 index 000000000..67a3d6ff8 --- /dev/null +++ b/crates/qbz-app/src/subscription_lifecycle.rs @@ -0,0 +1,212 @@ +//! Subscription lifecycle: the D4 grace clock and the offline-cache purge. +//! +//! Runs from the SHARED session activation (`AppRuntime::activate_at`) and +//! from a daily ticker the runtime owns, so every host — the Qt shell, the +//! daemon — gets the same verdict and the same enforcement without code of +//! its own. Before this module the verdict was produced by the Qt shell +//! alone, only when the login was REJECTED, and the purge had no caller at +//! all: past the 30-day grace the offline cache was merely blocked from +//! playing (`offline_playback_allowed`), never removed. The official Qobuz +//! app removes it; so does this. +//! +//! Semantics (member mode, 2026-09-02): +//! +//! - A login whose entitlements carry `offline_streaming` is a VALID +//! observation: the clock is cleared. +//! - A login without it — a Qobuz member with no subscription — is an +//! INVALID observation: the clock starts, or keeps running from its first +//! observation. The member still gets in; only offline is on the clock. +//! - Once the clock passes [`GRACE_PERIOD_SECS`], the cached audio is +//! purged — when a cache is registered. A host without one (qbzd) keeps +//! the verdict and the read gate, and a cache registered later is purged +//! on registration. +//! - Nothing here decides playback. `offline_playback_allowed` stays the +//! read gate the views consult. + +use std::path::Path; +use std::sync::Arc; + +use qbz_models::UserSession; +use qbz_offline_cache::OfflineCacheState; + +use crate::settings::subscription::SubscriptionStateStore; +pub use crate::settings::subscription::GRACE_PERIOD_SECS; + +/// What the lifecycle concluded for the active session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptionVerdict { + /// The session carries the `offline_streaming` entitlement. + pub offline_entitled: bool, + /// No streaming entitlement at all: a Qobuz member without subscription. + pub member_only: bool, + /// First time the account was seen without the offline entitlement, + /// unix seconds; `None` while the clock is clear. + pub invalid_since: Option, + /// When the grace window ends (`invalid_since + GRACE_PERIOD_SECS`). + pub grace_deadline: Option, + /// The cache was purged by THIS run. + pub purged: bool, +} + +/// D4 producer: record the login verdict for this session. +pub fn observe( + store: &SubscriptionStateStore, + session: &UserSession, + now: i64, +) -> Result<(), String> { + if session.entitlements.offline_streaming { + store.mark_valid(now) + } else { + store.mark_invalid(now) + } +} + +/// D4 enforcer: purge the offline cache once the grace window has passed. +/// +/// Returns `Ok(true)` when a purge happened. With no cache registered the +/// decision is logged and left standing (not marked purged), so the next +/// registration or tick purges. Takes the directory rather than an open +/// store because the store's SQLite connection cannot be held across the +/// purge's await point on a multi-threaded runtime. +pub async fn enforce( + data_dir: &Path, + now: i64, + offline: Option<&OfflineCacheState>, +) -> Result { + let due = SubscriptionStateStore::new_at(data_dir)?.should_purge_offline_cache(now)?; + if !due { + return Ok(false); + } + let Some(cache) = offline else { + log::warn!( + "[subscription] offline cache purge is due (no offline entitlement for {} days) but no cache is registered in this host", + GRACE_PERIOD_SECS / 86_400 + ); + return Ok(false); + }; + log::warn!( + "[subscription] no offline entitlement for {} days: purging the offline cache", + GRACE_PERIOD_SECS / 86_400 + ); + qbz_offline_cache::purge_all_cached_files(cache, &cache.library_db).await?; + SubscriptionStateStore::new_at(data_dir)?.mark_offline_cache_purged(now)?; + Ok(true) +} + +/// The verdict as the host may want to show it (diagnostics, `/api/status`). +pub fn verdict( + store: &SubscriptionStateStore, + session: Option<&UserSession>, + purged: bool, +) -> Result { + let state = store.get_state()?; + let entitlements = session.map(|s| &s.entitlements); + Ok(SubscriptionVerdict { + offline_entitled: entitlements.map(|e| e.offline_streaming).unwrap_or(false), + member_only: entitlements.map(|e| e.is_member_only()).unwrap_or(false), + invalid_since: state.invalid_since, + grace_deadline: state.invalid_since.map(|t| t + GRACE_PERIOD_SECS), + purged, + }) +} + +/// One full pass for the per-user directory `data_dir`: observe the session +/// (when there is one), enforce, report. Best-effort by contract — the +/// caller logs the error and activation proceeds; a broken clock must never +/// keep a user out. +pub async fn run( + data_dir: &Path, + session: Option<&UserSession>, + offline: Option>, + now: i64, +) -> Result { + if let Some(session) = session { + observe(&SubscriptionStateStore::new_at(data_dir)?, session, now)?; + } + let purged = enforce(data_dir, now, offline.as_deref()).await?; + verdict(&SubscriptionStateStore::new_at(data_dir)?, session, purged) +} + +pub fn now_unix_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use qbz_models::Entitlements; + + fn session(offline: bool) -> UserSession { + UserSession { + user_id: 7, + entitlements: Entitlements { + lossless_streaming: offline, + offline_streaming: offline, + ..Entitlements::default() + }, + ..UserSession::default() + } + } + + #[test] + fn subscriber_login_clears_the_clock() { + let dir = tempfile::tempdir().unwrap(); + let store = SubscriptionStateStore::new_at(dir.path()).unwrap(); + store.mark_invalid(100).unwrap(); + observe(&store, &session(true), 200).unwrap(); + let v = verdict(&store, Some(&session(true)), false).unwrap(); + assert_eq!(v.invalid_since, None); + assert_eq!(v.grace_deadline, None); + assert!(v.offline_entitled); + assert!(!v.member_only); + } + + #[test] + fn member_login_starts_the_clock_once_and_keeps_its_origin() { + let dir = tempfile::tempdir().unwrap(); + let store = SubscriptionStateStore::new_at(dir.path()).unwrap(); + observe(&store, &session(false), 100).unwrap(); + observe(&store, &session(false), 500).unwrap(); + let v = verdict(&store, Some(&session(false)), false).unwrap(); + assert_eq!(v.invalid_since, Some(100)); + assert_eq!(v.grace_deadline, Some(100 + GRACE_PERIOD_SECS)); + assert!(v.member_only); + assert!(!v.offline_entitled); + } + + #[tokio::test] + async fn purge_waits_for_the_grace_window_and_needs_a_cache() { + let dir = tempfile::tempdir().unwrap(); + let store = SubscriptionStateStore::new_at(dir.path()).unwrap(); + observe(&store, &session(false), 100).unwrap(); + // Inside the window: nothing. + assert!(!enforce(dir.path(), 100 + GRACE_PERIOD_SECS - 1, None) + .await + .unwrap()); + // Past the window but no cache registered: still nothing, and the + // decision is NOT marked as purged, so a later host can act. + assert!(!enforce(dir.path(), 100 + GRACE_PERIOD_SECS, None) + .await + .unwrap()); + assert!(store + .should_purge_offline_cache(100 + GRACE_PERIOD_SECS) + .unwrap()); + } + + #[tokio::test] + async fn run_without_a_session_only_enforces() { + let dir = tempfile::tempdir().unwrap(); + let store = SubscriptionStateStore::new_at(dir.path()).unwrap(); + store.mark_invalid(100).unwrap(); + drop(store); + // An offline entry (no Qobuz session) must not clear or restart the + // clock; it only reports it. + let v = run(dir.path(), None, None, 200).await.unwrap(); + assert_eq!(v.invalid_since, Some(100)); + assert!(!v.purged); + assert!(!v.offline_entitled); + } +} diff --git a/crates/qbz-app/src/user_data.rs b/crates/qbz-app/src/user_data.rs index 10d9c7b2f..7b72105a9 100644 --- a/crates/qbz-app/src/user_data.rs +++ b/crates/qbz-app/src/user_data.rs @@ -135,14 +135,6 @@ impl UserDataPaths { contents.trim().parse::().ok() } - /// Clear the last user_id file, called on explicit logout. - pub fn clear_last_user_id() { - if let Ok(path) = Self::last_user_id_path() { - let _ = std::fs::remove_file(&path); - log::info!("Cleared last_user_id file"); - } - } - fn last_user_id_path() -> Result { let dir = Self::global_data_dir()?; Ok(dir.join("last_user_id")) diff --git a/crates/qbz-audio/Cargo.toml b/crates/qbz-audio/Cargo.toml index 93cbbd34a..393d83355 100644 --- a/crates/qbz-audio/Cargo.toml +++ b/crates/qbz-audio/Cargo.toml @@ -34,7 +34,7 @@ rustfft = "6.4" spectrum-analyzer = "1.5" # Database for loudness cache -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } # System directories dirs = "6" @@ -50,6 +50,7 @@ alsa = "0.11" # Kept in lockstep with alsa (0.11 -> alsa-sys 0.4); a single alsa-sys is # required because of the `links = "alsa"` constraint shared with cpal. alsa-sys = "0.4" +libc = "0.2" # zbus for org.freedesktop.ReserveDevice1 D-Bus client (device reservation). # Pinned to v4 to share the transitive copy already pulled in by # secret-service/souvlaki and avoid duplicate zbus compiles. @@ -64,3 +65,15 @@ jack = "0.13" # CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode coreaudio-rs = "0.14.1" objc2-core-audio = "0.3.2" + +[target.'cfg(windows)'.dependencies] +# WASAPI exclusive mode (phase B). HEnquist's crate, the one CamillaDSP +# uses. `windows` is pinned to the 0.62 line cpal already resolves, so +# this adds no second windows tree to the graph. +wasapi = "0.24" +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Media_Audio", + "Win32_System_Com", + "Win32_System_Threading", +] } diff --git a/crates/qbz-audio/examples/wasapi_play.rs b/crates/qbz-audio/examples/wasapi_play.rs new file mode 100644 index 000000000..cf974d65e --- /dev/null +++ b/crates/qbz-audio/examples/wasapi_play.rs @@ -0,0 +1,221 @@ +//! Throwaway proof for the WASAPI exclusive path (phase B, task 2 step 8). +//! +//! Opens a render endpoint in EXCLUSIVE mode and plays a 1 kHz sine, then +//! reports what the stream negotiated and how many underruns it took. +//! +//! Deliberately quiet: -20 dBFS. A stability proof does not need full scale, +//! and this runs on somebody's actual monitors. +//! +//! cargo run -p qbz-audio --example wasapi_play list endpoints +//! cargo run -p qbz-audio --example wasapi_play [rate] [s] play +//! +//! What it proves that a unit test cannot: the DAC's own display shows the +//! rate we asked for. If the display says 48 kHz while this says 96, the +//! stream is being resampled somewhere and it is NOT bit-perfect. + +fn main() { + #[cfg(not(windows))] + { + eprintln!("wasapi_play is Windows-only"); + } + + #[cfg(windows)] + { + use qbz_audio::wasapi_direct::{WasapiDirectStream, WasapiTiming}; + + let args: Vec = std::env::args().skip(1).collect(); + + if args.is_empty() { + list_endpoints(); + eprintln!(); + eprintln!("pass an endpoint id to play, e.g."); + eprintln!(" cargo run -p qbz-audio --example wasapi_play \"{{0.0.0...}}\" 96000 10"); + return; + } + + let endpoint = args[0].clone(); + let rate: u32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(96_000); + let secs: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(10); + + let stream = match WasapiDirectStream::new(&endpoint, rate, 2, WasapiTiming::Events) { + Ok(s) => s, + Err(e) => { + eprintln!("open failed: {e}"); + return; + } + }; + let info = stream.open_info(); + println!("endpoint : {}", info.endpoint_name); + println!("rate : {} Hz", info.rate); + println!( + "format : {:?} ({}/{} bits)", + info.rung, + info.rung.valid_bits(), + info.rung.container_bits() + ); + println!( + "period : {} hns buffer: {} frames", + info.period_hns, info.buffer_frames + ); + println!("realigned: {} timing: {:?}", info.realigned, info.timing); + println!("bit-perfect mode: {:?}", stream.bit_perfect_mode()); + println!(); + println!("BEEP 1 of 3 - {secs}s of 1 kHz at -20 dBFS"); + println!("CHECK THE DAC: it must read {rate}. Then expect TWO more one-second"); + println!("beeps - one after the drain, one after the stop. Three beeps = PASS."); + + // Feed in period-sized chunks so the queue's back-pressure paces us + // instead of a sleep. 1 kHz divides every rate in the ladder evenly + // enough that the phase never accumulates audible drift over 10 s. + let frames_per_chunk = info.buffer_frames.max(1) as usize; + let total_frames = rate as u64 * secs; + let amp = 0.1f32; // -20 dBFS + let step = 2.0 * std::f32::consts::PI * 1000.0 / rate as f32; + + let mut phase = 0.0f32; + let mut written: u64 = 0; + while written < total_frames { + let n = frames_per_chunk.min((total_frames - written) as usize); + let mut buf = Vec::with_capacity(n * 2); + for _ in 0..n { + let v = phase.sin() * amp; + phase += step; + if phase > std::f32::consts::TAU { + phase -= std::f32::consts::TAU; + } + buf.push(v); // L + buf.push(v); // R + } + if let Err(e) = stream.write_f32(&buf) { + eprintln!("write failed after {written} frames: {e}"); + break; + } + written += n as u64; + } + + // The verdict must be judged on CONTINUOUS playback only. Everything + // after this point deliberately leaves the queue empty between beeps, + // and a genuine gap with the stream running IS an underrun -- counting + // those would make this instrument report a failure it created itself. + let underruns_playing = stream.underruns(); + let timeouts_playing = stream.event_timeouts(); + + // Drain, then WRITE AGAIN. This is the discriminating test for the + // gapless contract: drain must block until the queue has played out + // AND must leave the stream usable for the next source. An early + // return, or a render thread that exits on drain, both show up here + // and nowhere else. + let t0 = std::time::Instant::now(); + let drained = stream.drain(); + let drain_ms = t0.elapsed().as_millis(); + println!(); + println!("drain : {drained:?} in {drain_ms} ms"); + + // A FULL SECOND, not one buffer. The point of these two checks is that + // a person can HEAR the second and third beeps; a 144-frame burst at + // 44.1 kHz is 3 ms, which is inaudible, so the test could pass or fail + // and sound identical either way. + let mut beep = |stream: &WasapiDirectStream, phase: &mut f32| -> Result<(), String> { + let mut left = rate as usize; + while left > 0 { + let n = frames_per_chunk.min(left); + let mut buf = Vec::with_capacity(n * 2); + for _ in 0..n { + let v = phase.sin() * amp; + *phase += step; + if *phase > std::f32::consts::TAU { + *phase -= std::f32::consts::TAU; + } + buf.push(v); + buf.push(v); + } + stream.write_f32(&buf)?; + left -= n; + } + Ok(()) + }; + + println!("BEEP 2 of 3 - one second, after the drain"); + match beep(&stream, &mut phase) { + Ok(()) => println!("write after drain: OK - the stream survived the drain"), + Err(e) => println!("write after drain: FAILED ({e}) - the stream died on drain"), + } + let _ = stream.drain(); + + // STOP, then WRITE AGAIN. This is the OTHER half of the gapless + // contract and the one that was missing: the player calls + // engine.stop() on EVERY track change and then reuses the same stream + // object for the next track, exactly as ALSA's stop() does + // ("prepare for next playback"). A render thread that exits on Stop + // passes every drain test and still cannot play a second track -- + // which is what shipped, and what the app log caught as + // "Write failed: render thread is gone". + let t1 = std::time::Instant::now(); + let stopped = stream.stop(); + println!( + "stop : {stopped:?} in {} ms", + t1.elapsed().as_millis() + ); + + println!("BEEP 3 of 3 - one second, after the stop"); + let survived_stop = match beep(&stream, &mut phase) { + Ok(()) => { + println!("write after stop : OK - the stream survived the stop"); + true + } + Err(e) => { + println!("write after stop : FAILED ({e}) - the stream died on stop"); + false + } + }; + let _ = stream.drain(); + + println!(); + println!("frames written : {written}"); + println!( + "underruns : {} during playback ({} total, gaps between beeps included)", + underruns_playing, + stream.underruns() + ); + println!( + "event timeouts : {} during playback ({} total)", + timeouts_playing, + stream.event_timeouts() + ); + println!( + "VERDICT : {}", + if !survived_stop { + "FAIL - the stream did not survive stop(); a track change kills it" + } else if underruns_playing == 0 && timeouts_playing == 0 { + "PASS - survived drain and stop, no underruns, no timeouts" + } else { + "underruns occurred; try WasapiTiming::Polling" + } + ); + } +} + +#[cfg(windows)] +fn list_endpoints() { + use wasapi::{initialize_mta, DeviceEnumerator, Direction}; + let _ = initialize_mta().ok(); + let Ok(en) = DeviceEnumerator::new() else { + eprintln!("DeviceEnumerator failed"); + return; + }; + let Ok(coll) = en.get_device_collection(&Direction::Render) else { + eprintln!("device collection failed"); + return; + }; + let n = coll.get_nbr_devices().unwrap_or(0); + println!("render endpoints:"); + for i in 0..n { + if let Ok(d) = coll.get_device_at_index(i) { + println!( + " [{i}] {}", + d.get_friendlyname().unwrap_or_else(|_| "".into()) + ); + println!(" {}", d.get_id().unwrap_or_else(|_| "".into())); + } + } +} diff --git a/crates/qbz-audio/src/alsa_backend.rs b/crates/qbz-audio/src/alsa_backend.rs index 3e5be1172..1dd5930f2 100644 --- a/crates/qbz-audio/src/alsa_backend.rs +++ b/crates/qbz-audio/src/alsa_backend.rs @@ -69,17 +69,77 @@ fn suspend_default_sink_for_exclusive() { /// fix). No-op if QBZ did not suspend one — so it is safe to call on every /// stop/teardown. Call it once the exclusive device has actually been released /// so the rest of the system can use the sink again. -pub fn resume_suspended_sink() { +pub fn resume_suspended_sink() -> Result<(), String> { let target = match SUSPENDED_SINK.lock() { - Ok(mut guard) => guard.take(), + Ok(guard) => guard.clone(), Err(_) => None, }; - if let Some(sink) = target { - let _ = std::process::Command::new("pactl") - .args(["suspend-sink", &sink, "0"]) - .output(); - log::info!("[ALSA Backend] Resumed PipeWire sink '{}'", sink); + let Some(sink) = target else { + return Ok(()); + }; + + // The PCM and reservation have already been dropped by every caller, but + // PipeWire may need a short moment to recreate the parked sink. Retry the + // exact recorded name, then the default alias as a compatibility fallback. + // Keep SUSPENDED_SINK populated until one command actually succeeds: the + // old take-before-command implementation permanently lost the recovery + // target on one transient `pactl` failure while logging success anyway. + let mut last_error = String::new(); + for delay_ms in [0u64, 25, 75, 150] { + if delay_ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + } + let mut targets = vec![sink.as_str()]; + if sink != "@DEFAULT_SINK@" { + targets.push("@DEFAULT_SINK@"); + } + for target in targets { + match std::process::Command::new("pactl") + .args(["suspend-sink", target, "0"]) + .output() + { + Ok(output) if output.status.success() => { + if let Ok(mut guard) = SUSPENDED_SINK.lock() { + if guard.as_deref() == Some(sink.as_str()) { + *guard = None; + } + } + log::info!( + "[ALSA Backend] Resumed PipeWire sink '{}' via '{}'", + sink, + target + ); + return Ok(()); + } + Ok(output) => { + last_error = String::from_utf8_lossy(&output.stderr).trim().to_string(); + } + Err(error) => { + last_error = error.to_string(); + if error.kind() == std::io::ErrorKind::NotFound { + let message = format!( + "cannot resume PipeWire sink '{}': pactl is unavailable ({error})", + sink + ); + log::warn!("[ALSA Backend] {message}"); + return Err(message); + } + } + } + } } + + let message = format!( + "cannot resume PipeWire sink '{}' after releasing ALSA: {}", + sink, + if last_error.is_empty() { + "pactl failed without a diagnostic" + } else { + last_error.as_str() + } + ); + log::warn!("[ALSA Backend] {message}"); + Err(message) } /// Common audio sample rates to check for device support diff --git a/crates/qbz-audio/src/alsa_direct.rs b/crates/qbz-audio/src/alsa_direct.rs index 4355d4861..b2b937115 100644 --- a/crates/qbz-audio/src/alsa_direct.rs +++ b/crates/qbz-audio/src/alsa_direct.rs @@ -4,7 +4,7 @@ //! This module bypasses rodio/CPAL completely for direct hardware access. #[cfg(target_os = "linux")] -use alsa::pcm::{Access, Format, HwParams, PCM}; +use alsa::pcm::{Access, Format, Frames, HwParams, PCM}; #[cfg(target_os = "linux")] use alsa::{Direction, ValueOr}; #[cfg(target_os = "linux")] @@ -113,6 +113,18 @@ pub struct AlsaDirectStream { device_id: String, } +/// A writable ALSA mixer element and its current normalized level. +/// +/// The probe is deliberately read-only. Callers use the sampled level to seed +/// QBZ's volume state *before* enabling hardware volume, so changing the +/// setting cannot copy the direct path's fixed 100% UI value into the +/// physical mixer. +#[derive(Clone, Debug, PartialEq)] +pub struct HardwareVolumeInfo { + pub control_name: String, + pub volume: f32, +} + /// Defensive settle delay between reservation acquisition and PCM open. /// /// Only applied when the reservation actually transitioned ownership (i.e. @@ -144,13 +156,8 @@ impl AlsaDirectStream { // immediately before a real `PCM::new()` and held for as long as that // PCM is open. // TODO(Task 5): replace second arg with user-facing DAC name from settings. - let reservation = - crate::DeviceReservation::acquire(device_id, device_id).map_err(|e| { - format!( - "Cannot acquire exclusive device '{}': {}", - device_id, e - ) - })?; + let reservation = crate::DeviceReservation::acquire(device_id, device_id) + .map_err(|e| format!("Cannot acquire exclusive device '{}': {}", device_id, e))?; // Defensive margin only matters when the reservation actually displaced // a holder (or could have). On the degraded D-Bus path the bus name is @@ -213,13 +220,13 @@ impl AlsaDirectStream { // Set buffer size (larger buffer for high-res audio) let buffer_size = if sample_rate >= 192000 { // 500ms buffer for 192kHz+ (like MPD config) - (sample_rate / 2) as i64 + (sample_rate / 2) as Frames } else if sample_rate >= 96000 { // 250ms buffer for 96kHz - (sample_rate / 4) as i64 + (sample_rate / 4) as Frames } else { // 125ms buffer for lower rates - (sample_rate / 8) as i64 + (sample_rate / 8) as Frames }; hwp.set_buffer_size_near(buffer_size) @@ -300,11 +307,11 @@ impl AlsaDirectStream { hwp.set_rate(carrier_rate, ValueOr::Nearest) .map_err(|e| format!("Failed to set DoP carrier rate {}: {}", carrier_rate, e))?; let buffer_size = if carrier_rate >= 192000 { - (carrier_rate / 2) as i64 + (carrier_rate / 2) as Frames } else if carrier_rate >= 96000 { - (carrier_rate / 4) as i64 + (carrier_rate / 4) as Frames } else { - (carrier_rate / 8) as i64 + (carrier_rate / 8) as Frames }; hwp.set_buffer_size_near(buffer_size) .map_err(|e| format!("Failed to set buffer size: {}", e))?; @@ -380,15 +387,13 @@ impl AlsaDirectStream { } } let Some((format, le)) = selected else { - return Err( - "Device has no native DSD format (kernel quirk missing?)".to_string() - ); + return Err("Device has no native DSD format (kernel quirk missing?)".to_string()); }; hwp.set_channels(channels as u32) .map_err(|e| format!("Failed to set channels: {}", e))?; hwp.set_rate(rate, ValueOr::Nearest) .map_err(|e| format!("Failed to set native DSD rate {}: {}", rate, e))?; - let buffer_size = (rate / 4) as i64; // 250 ms + let buffer_size = (rate / 4) as Frames; // 250 ms hwp.set_buffer_size_near(buffer_size) .map_err(|e| format!("Failed to set buffer size: {}", e))?; hwp.set_period_size_near(buffer_size / 10, ValueOr::Nearest) @@ -441,7 +446,11 @@ impl AlsaDirectStream { match io.writei(samples) { Ok(written) => { if written != frames { - log::warn!("[ALSA Direct] Partial DoP write: {} / {} frames", written, frames); + log::warn!( + "[ALSA Direct] Partial DoP write: {} / {} frames", + written, + frames + ); } Ok(()) } @@ -852,7 +861,10 @@ impl AlsaDirectStream { // libc::EBADFD — the PCM was not in a running-ish state. const EBADFD: i32 = 77; if e.errno() as i32 != EBADFD { - log::warn!("[ALSA Direct] drop on stop failed (continuing to prepare): {}", e); + log::warn!( + "[ALSA Direct] drop on stop failed (continuing to prepare): {}", + e + ); } } pcm.prepare() @@ -869,61 +881,37 @@ impl AlsaDirectStream { self.channels } + /// Frames accepted by ALSA but not yet presented by the device. + /// + /// This is observational only: it neither changes PCM parameters nor + /// waits for the device. The direct writer uses it to point the passive + /// visualizer tap at the audible playhead instead of the queue head. + pub fn playback_delay_frames(&self) -> Result { + let pcm = self.pcm.lock().unwrap(); + pcm.delay() + .map(|frames| frames.max(0) as u64) + .map_err(|e| format!("Failed to query ALSA playback delay: {}", e)) + } + /// Get device ID pub fn device_id(&self) -> &str { &self.device_id } - /// Try to set hardware volume via ALSA mixer + /// Try to set hardware volume via ALSA mixer. /// /// Returns error if: /// - DAC doesn't have mixer controls (common for USB DACs) /// - Mixer API fails /// /// NOTE: Failure doesn't break playback, just means volume can't be controlled. - pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> { - use alsa::mixer::SelemChannelId::*; - use alsa::mixer::{Mixer, SelemId}; - - // Open mixer for device - let mixer = Mixer::new(&self.device_id, false) - .map_err(|e| format!("Failed to open mixer for {}: {}", self.device_id, e))?; - - // Try to find a volume control element - // Common names: "Master", "PCM", "Speaker", "Headphone" - let control_names = ["Master", "PCM", "Speaker", "Headphone", "Digital"]; - - for name in &control_names { - let selem_id = SelemId::new(name, 0); - - if let Some(selem) = mixer.find_selem(&selem_id) { - // Check if this element has playback volume control - if selem.has_playback_volume() { - let (min, max) = selem.get_playback_volume_range(); - let target = min + ((max - min) as f32 * volume) as i64; - - log::info!( - "[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})", - name, - volume * 100.0, - target, - max - ); - - // Set volume on all channels - for channel in &[FrontLeft, FrontRight, FrontCenter, RearLeft, RearRight] { - let _ = selem.set_playback_volume(*channel, target); - } - - return Ok(()); - } - } - } - - Err(format!( - "No volume control found for {}. DAC may not support hardware mixer.", - self.device_id - )) + pub fn set_hardware_volume( + &self, + control: &crate::alsa_hardware_volume::AlsaMixerControlId, + volume: f32, + ) -> Result<(), String> { + crate::alsa_hardware_volume::set_hardware_volume(&self.device_id, control, volume) + .map(|_| ()) } /// Check if device is a bit-perfect hardware device @@ -935,6 +923,47 @@ impl AlsaDirectStream { } } +/// Read-only capability probe used before the UI enables hardware volume. +pub fn probe_hardware_volume(device_id: &str) -> Result { + use crate::alsa_hardware_volume::{ + resolve_hardware_volume, HardwareVolumeDecision, HardwareVolumeUnsupportedReason, + }; + let decision = resolve_hardware_volume(device_id, &Default::default()); + match decision { + HardwareVolumeDecision::Selected { selection } => Ok(HardwareVolumeInfo { + control_name: selection.control.name, + volume: selection.candidate.current_volume.unwrap_or(1.0), + }), + HardwareVolumeDecision::NeedsChoice { candidates, .. } => Err(format!( + "Ambiguous playback-volume controls for {device_id}: {}", + candidates + .iter() + .map(|candidate| candidate.label.as_str()) + .collect::>() + .join(", ") + )), + HardwareVolumeDecision::Unsupported { reason, .. } => match reason { + HardwareVolumeUnsupportedReason::Probe(error) => Err(error.message), + other => Err(format!( + "No compatible ALSA hardware volume for {device_id}: {other:?}" + )), + }, + } +} + +/// Mirrors the exact route predicate used by the player: ALSA plus a direct +/// device id and either the `hw` or `plughw` engine. ALSA's default/sysdefault +/// and `Pcm` routes use CPAL/Rodio and retain software volume. +pub fn uses_alsa_direct_route(audio: &crate::settings::AudioSettings) -> bool { + audio.backend_type == Some(crate::backend::AudioBackendType::Alsa) + && audio.alsa_plugin.unwrap_or(crate::backend::AlsaPlugin::Hw) + != crate::backend::AlsaPlugin::Pcm + && audio + .output_device + .as_deref() + .is_some_and(AlsaDirectStream::is_hw_device) +} + #[cfg(not(target_os = "linux"))] impl AlsaDirectStream { pub fn new(_device_id: &str, _sample_rate: u32, _channels: u16) -> Result { @@ -965,8 +994,118 @@ impl AlsaDirectStream { 2 } + pub fn playback_delay_frames(&self) -> Result { + Ok(0) + } + /// Check if device is a bit-perfect hardware device (always false on non-Linux) pub fn is_hw_device(_device_id: &str) -> bool { false } } + +#[cfg(test)] +mod tests { + use super::uses_alsa_direct_route; + use crate::alsa_hardware_volume::mixer_ctl_name; + use crate::backend::{AlsaPlugin, AudioBackendType}; + use crate::settings::AudioSettings; + + #[test] + fn mixer_ctl_name_maps_card_forms() { + // HiFiBerry Digi S/PDIF (#331/#659) and other plugin aliases. + assert_eq!( + mixer_ctl_name("iec958:CARD=sndrpihifiberry,DEV=0"), + "hw:CARD=sndrpihifiberry" + ); + assert_eq!(mixer_ctl_name("hw:CARD=C20,DEV=0"), "hw:CARD=C20"); + assert_eq!(mixer_ctl_name("front:CARD=PCH,DEV=0"), "hw:CARD=PCH"); + assert_eq!(mixer_ctl_name("hdmi:CARD=NVidia,DEV=1"), "hw:CARD=NVidia"); + assert_eq!( + mixer_ctl_name("sysdefault:CARD=DacMagic,DEV=0"), + "hw:CARD=DacMagic" + ); + // CARD= in a non-first position. + assert_eq!(mixer_ctl_name("hw:DEV=0,CARD=DacMagic"), "hw:CARD=DacMagic"); + } + + #[test] + fn mixer_ctl_name_numeric_and_fallback() { + assert_eq!(mixer_ctl_name("hw:1,0"), "hw:1"); + assert_eq!(mixer_ctl_name("plughw:2"), "hw:2"); + // No CARD= and no numeric arg → unchanged. + assert_eq!(mixer_ctl_name("default"), "default"); + assert_eq!(mixer_ctl_name("pulse"), "pulse"); + } + + #[test] + fn direct_route_requires_alsa_direct_device_and_non_pcm_plugin() { + let mut audio = AudioSettings::default(); + assert!(!uses_alsa_direct_route(&audio)); + + audio.backend_type = Some(AudioBackendType::Alsa); + audio.output_device = Some("front:CARD=USB,DEV=0".to_string()); + audio.alsa_plugin = Some(AlsaPlugin::Hw); + assert_eq!( + uses_alsa_direct_route(&audio), + cfg!(target_os = "linux") + ); + + audio.alsa_plugin = Some(AlsaPlugin::PlugHw); + assert_eq!( + uses_alsa_direct_route(&audio), + cfg!(target_os = "linux") + ); + + audio.alsa_plugin = Some(AlsaPlugin::Pcm); + assert!(!uses_alsa_direct_route(&audio)); + + audio.alsa_plugin = Some(AlsaPlugin::Hw); + audio.output_device = Some("sysdefault:CARD=USB".to_string()); + assert!(!uses_alsa_direct_route(&audio)); + + audio.output_device = None; + assert!(!uses_alsa_direct_route(&audio)); + } +} + +/// Forwards to the inherent methods, which is all this is: no behaviour is +/// added, moved or wrapped. See `backend::DirectSink`. +impl crate::backend::DirectSink for AlsaDirectStream { + fn write_f32(&self, samples: &[f32]) -> Result<(), String> { + AlsaDirectStream::write_f32(self, samples) + } + fn drain(&self) -> Result<(), String> { + AlsaDirectStream::drain(self) + } + fn stop(&self) -> Result<(), String> { + AlsaDirectStream::stop(self) + } + fn sample_rate(&self) -> u32 { + AlsaDirectStream::sample_rate(self) + } + fn channels(&self) -> u16 { + AlsaDirectStream::channels(self) + } + fn playback_delay_frames(&self) -> Result { + AlsaDirectStream::playback_delay_frames(self) + } + fn log_label(&self) -> &'static str { + "ALSA Direct Engine" + } + #[cfg(target_os = "linux")] + fn alsa_device_id(&self) -> Option<&str> { + Some(self.device_id()) + } + /// Linux only, because the inherent method is: the non-Linux stub of this + /// type has no mixer at all, and there the trait default refusal is the + /// honest answer. + #[cfg(target_os = "linux")] + fn set_hardware_volume( + &self, + control: &crate::alsa_hardware_volume::AlsaMixerControlId, + volume: f32, + ) -> Result<(), String> { + AlsaDirectStream::set_hardware_volume(self, control, volume) + } +} diff --git a/crates/qbz-audio/src/alsa_hardware_volume.rs b/crates/qbz-audio/src/alsa_hardware_volume.rs new file mode 100644 index 000000000..2de0cf3f0 --- /dev/null +++ b/crates/qbz-audio/src/alsa_hardware_volume.rs @@ -0,0 +1,2347 @@ +//! ALSA hardware-volume discovery, identity, selection and control. +//! +//! This module is intentionally separate from `alsa_direct`: mixer probing is +//! control-plane work and must not change PCM open/configure/write/drain or any +//! sample-rate decision in the protected direct-output path. + +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::sync::Arc; + +/// The complete ALSA simple-mixer identity. Names alone are not unique. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AlsaMixerControlId { + pub name: String, + pub index: u32, +} + +impl fmt::Display for AlsaMixerControlId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{},{}", self.name, self.index) + } +} + +/// Opaque, serializable key for one physical card and one PCM route. +/// +/// The string form is deliberate: JSON maps only support string keys. Values +/// are versioned and contain a stable card identity, the PCM kind and `DEV=`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StableAlsaRouteKey(String); + +impl StableAlsaRouteKey { + pub fn as_str(&self) -> &str { + &self.0 + } + + fn from_parts(identity: &StableAlsaCardIdentity, pcm: &str, device: u32) -> Self { + let (kind, value) = match identity { + StableAlsaCardIdentity::ById(value) => ("by-id", value.as_str()), + StableAlsaCardIdentity::ByPath(value) => ("by-path", value.as_str()), + StableAlsaCardIdentity::Card(value) => ("card", value.as_str()), + }; + Self(format!( + "alsa-route-v1|card={kind}:{}|pcm={}|DEV={device}", + escape_key_component(value), + escape_key_component(pcm) + )) + } +} + +impl fmt::Display for StableAlsaRouteKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum StableAlsaCardIdentity { + ById(String), + ByPath(String), + Card(String), +} + +fn select_stable_card_identity( + has_real_serial: bool, + by_id: Option, + by_path: Option, + short_id: String, +) -> StableAlsaCardIdentity { + if has_real_serial { + if let Some(by_id) = by_id { + return StableAlsaCardIdentity::ById(by_id); + } + } + by_path + .map(StableAlsaCardIdentity::ByPath) + .unwrap_or(StableAlsaCardIdentity::Card(short_id)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct HardwareVolumeRange { + pub min: i64, + pub max: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HardwareVolumeChannelValue { + pub channel: String, + pub raw: i64, + pub db_millibels: Option, + pub playback_switch: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HardwareVolumeWritability { + Verified, + NeedsEnableCheck, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "detail", rename_all = "snake_case")] +pub enum HardwareVolumeRejectionReason { + Inactive, + CaptureOnly, + UnsafeInputPath, + CommonPlaybackCaptureVolume, + NoPlaybackVolume, + InvalidRange { min: i64, max: i64 }, + NoPlaybackChannels, + ReadFailed(String), + ReadOnly, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HardwareVolumeCandidate { + pub id: AlsaMixerControlId, + pub label: String, + pub channels: Vec, + pub raw_range: Option, + /// ALSA millibels (1/100 dB). `None` means raw-linear fallback. + pub db_range: Option, + pub current_values: Vec, + pub current_volume: Option, + pub has_playback_switch: bool, + pub recommended: bool, + pub writability: HardwareVolumeWritability, + pub rejection_reason: Option, +} + +impl HardwareVolumeCandidate { + pub fn is_valid(&self) -> bool { + self.rejection_reason.is_none() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HardwareVolumeProbeErrorKind { + InvalidDevice, + PermissionDenied, + DeviceBusy, + DeviceUnavailable, + NoMixer, + UnsupportedPlatform, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HardwareVolumeProbeError { + pub kind: HardwareVolumeProbeErrorKind, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HardwareVolumeProbe { + pub device_id: String, + pub ctl_name: String, + pub route_key: Option, + /// Every simple element, including rejected controls for diagnostics. + pub candidates: Vec, + /// Read-only UCM hints that resolve to simple-mixer identities. + pub ucm_controls: Vec, + pub error: Option, +} + +impl HardwareVolumeProbe { + pub fn valid_candidates(&self) -> impl Iterator { + self.candidates + .iter() + .filter(|candidate| candidate.is_valid()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HardwareVolumeSelectionSource { + Persisted, + Ucm, + OnlyCandidate, + UserChoice, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HardwareVolumeSelection { + pub route_key: StableAlsaRouteKey, + pub control: AlsaMixerControlId, + pub candidate: HardwareVolumeCandidate, + pub source: HardwareVolumeSelectionSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HardwareVolumeChoiceReason { + Ambiguous, + UcmAmbiguous, + PersistedSelectionStale, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "detail", rename_all = "snake_case")] +pub enum HardwareVolumeUnsupportedReason { + Probe(HardwareVolumeProbeError), + NoValidControls, + PersistedSelectionStale(AlsaMixerControlId), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum HardwareVolumeDecision { + Selected { + selection: HardwareVolumeSelection, + }, + NeedsChoice { + route_key: StableAlsaRouteKey, + candidates: Vec, + reason: HardwareVolumeChoiceReason, + }, + Unsupported { + route_key: Option, + reason: HardwareVolumeUnsupportedReason, + }, +} + +/// A fresh physical-mixer snapshot after an exact read or write. +#[derive(Debug, Clone, PartialEq)] +pub struct HardwareVolumeSnapshot { + pub control: AlsaMixerControlId, + pub channels: Vec, + pub volume: f32, + pub muted: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum HardwareVolumeEvent { + Changed(HardwareVolumeSnapshot), + Unavailable(String), +} + +pub type HardwareVolumeEventCallback = Arc; + +/// Owns the ctl event thread. Dropping it stops and joins the subscription. +pub struct HardwareVolumeEventSubscription { + #[cfg(target_os = "linux")] + stop: Arc, + #[cfg(target_os = "linux")] + thread: Option>, +} + +#[cfg(target_os = "linux")] +impl Drop for HardwareVolumeEventSubscription { + fn drop(&mut self) { + use std::sync::atomic::Ordering; + self.stop.store(true, Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +/// Decide without touching hardware. A stale persisted identity is a hard +/// stop: it must never silently fall through to a different control. +pub fn decide_hardware_volume( + probe: &HardwareVolumeProbe, + persisted: &HashMap, +) -> HardwareVolumeDecision { + if let Some(error) = &probe.error { + return HardwareVolumeDecision::Unsupported { + route_key: probe.route_key.clone(), + reason: HardwareVolumeUnsupportedReason::Probe(error.clone()), + }; + } + + let Some(route_key) = probe.route_key.clone() else { + return HardwareVolumeDecision::Unsupported { + route_key: None, + reason: HardwareVolumeUnsupportedReason::Probe(HardwareVolumeProbeError { + kind: HardwareVolumeProbeErrorKind::InvalidDevice, + message: "ALSA route has no stable identity".to_string(), + }), + }; + }; + + let valid = probe + .valid_candidates() + .cloned() + .collect::>(); + + if let Some(saved) = persisted.get(&route_key) { + if let Some(candidate) = valid.iter().find(|candidate| candidate.id == *saved) { + return HardwareVolumeDecision::Selected { + selection: HardwareVolumeSelection { + route_key, + control: saved.clone(), + candidate: candidate.clone(), + source: HardwareVolumeSelectionSource::Persisted, + }, + }; + } + if valid.is_empty() { + return HardwareVolumeDecision::Unsupported { + route_key: Some(route_key), + reason: HardwareVolumeUnsupportedReason::PersistedSelectionStale(saved.clone()), + }; + } + return HardwareVolumeDecision::NeedsChoice { + route_key, + candidates: valid, + reason: HardwareVolumeChoiceReason::PersistedSelectionStale, + }; + } + + let ucm_ids = probe.ucm_controls.iter().cloned().collect::>(); + let ucm_matches = valid + .iter() + .filter(|candidate| ucm_ids.contains(&candidate.id)) + .cloned() + .collect::>(); + if ucm_matches.len() == 1 { + let candidate = ucm_matches.into_iter().next().expect("length checked"); + return HardwareVolumeDecision::Selected { + selection: HardwareVolumeSelection { + route_key, + control: candidate.id.clone(), + candidate, + source: HardwareVolumeSelectionSource::Ucm, + }, + }; + } + if ucm_matches.len() > 1 { + return HardwareVolumeDecision::NeedsChoice { + route_key, + candidates: valid, + reason: HardwareVolumeChoiceReason::UcmAmbiguous, + }; + } + + match valid.len() { + 0 => HardwareVolumeDecision::Unsupported { + route_key: Some(route_key), + reason: HardwareVolumeUnsupportedReason::NoValidControls, + }, + 1 => { + let candidate = valid.into_iter().next().expect("length checked"); + HardwareVolumeDecision::Selected { + selection: HardwareVolumeSelection { + route_key, + control: candidate.id.clone(), + candidate, + source: HardwareVolumeSelectionSource::OnlyCandidate, + }, + } + } + _ => HardwareVolumeDecision::NeedsChoice { + route_key, + candidates: valid, + reason: HardwareVolumeChoiceReason::Ambiguous, + }, + } +} + +pub fn resolve_hardware_volume( + device_id: &str, + persisted: &HashMap, +) -> HardwareVolumeDecision { + decide_hardware_volume(&enumerate_hardware_volume_controls(device_id), persisted) +} + +fn escape_key_component(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') { + escaped.push(char::from(byte)); + } else { + use std::fmt::Write; + let _ = write!(escaped, "%{byte:02X}"); + } + } + escaped +} + +fn hardware_volume_rank(name: &str) -> u8 { + let lower = name.to_ascii_lowercase(); + [ + ("master", 100), + ("pcm", 90), + ("speaker", 80), + ("headphone", 70), + ("digital", 60), + ("dac", 50), + ("line out", 40), + ("playback", 30), + ] + .into_iter() + .find_map(|(token, score)| lower.contains(token).then_some(score)) + .unwrap_or(1) +} + +fn unsafe_input_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + ["capture", "mic", "boost", "sidetone", "loopback"] + .iter() + .any(|token| lower.contains(token)) +} + +#[derive(Debug, Clone, Copy)] +struct CandidateFacts<'a> { + name: &'a str, + active: bool, + has_playback: bool, + has_capture: bool, + has_common_volume: bool, + raw_range: Option, + playback_channels: usize, +} + +/// Pure structural validation, shared by the live ALSA probe and unit tests. +/// Read failures and ctl writability are checked immediately after this gate. +fn structural_rejection(facts: CandidateFacts<'_>) -> Option { + if !facts.active { + return Some(HardwareVolumeRejectionReason::Inactive); + } + if facts.has_common_volume { + return Some(HardwareVolumeRejectionReason::CommonPlaybackCaptureVolume); + } + if facts.has_capture && !facts.has_playback { + return Some(HardwareVolumeRejectionReason::CaptureOnly); + } + if unsafe_input_name(facts.name) { + return Some(HardwareVolumeRejectionReason::UnsafeInputPath); + } + if !facts.has_playback { + return Some(HardwareVolumeRejectionReason::NoPlaybackVolume); + } + let range = facts + .raw_range + .unwrap_or(HardwareVolumeRange { min: 0, max: 0 }); + if range.max <= range.min { + return Some(HardwareVolumeRejectionReason::InvalidRange { + min: range.min, + max: range.max, + }); + } + if facts.playback_channels == 0 { + return Some(HardwareVolumeRejectionReason::NoPlaybackChannels); + } + None +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct HardwareVolumeWritePlan { + playback_switch: Option, + write_level: bool, +} + +fn hardware_volume_write_plan(scalar: f32, has_playback_switch: bool) -> HardwareVolumeWritePlan { + if scalar <= f32::EPSILON && has_playback_switch { + HardwareVolumeWritePlan { + playback_switch: Some(false), + write_level: false, + } + } else { + HardwareVolumeWritePlan { + playback_switch: has_playback_switch.then_some(true), + write_level: true, + } + } +} + +fn suppress_local_write_echo(written: f32, age_millis: u128, observed: f32) -> bool { + age_millis <= 300 && (written - observed).abs() < 0.001 +} + +fn writability_rejection(writable: Option) -> Option { + (writable == Some(false)).then_some(HardwareVolumeRejectionReason::ReadOnly) +} + +fn mark_recommended(candidates: &mut [HardwareVolumeCandidate]) { + let best = candidates + .iter() + .filter(|candidate| candidate.is_valid()) + .map(|candidate| hardware_volume_rank(&candidate.id.name)) + .max(); + for candidate in candidates { + candidate.recommended = + candidate.is_valid() && best == Some(hardware_volume_rank(&candidate.id.name)); + } +} + +fn normalize_level( + raw_range: HardwareVolumeRange, + db_range: Option, + values: &[HardwareVolumeChannelValue], +) -> f32 { + let switches = values + .iter() + .filter_map(|value| value.playback_switch) + .collect::>(); + if !switches.is_empty() && switches.iter().all(|enabled| !enabled) { + return 0.0; + } + + if let Some(db_range) = db_range { + let levels = values + .iter() + .map(|value| value.db_millibels) + .collect::>>(); + if let Some(levels) = levels.filter(|levels| !levels.is_empty()) { + return levels + .into_iter() + .map(|value| normalized(value, db_range)) + .fold(0.0_f32, f32::max); + } + } + + values + .iter() + .map(|value| normalized(value.raw, raw_range)) + .fold(0.0_f32, f32::max) +} + +fn normalized(value: i64, range: HardwareVolumeRange) -> f32 { + if range.max <= range.min { + return 0.0; + } + ((value - range.min) as f32 / (range.max - range.min) as f32).clamp(0.0, 1.0) +} + +fn channel_targets(values: &[i64], range: HardwareVolumeRange, scalar: f32) -> Vec { + if values.is_empty() || range.max <= range.min { + return Vec::new(); + } + let current_peak = values.iter().copied().max().unwrap_or(range.min); + let target_peak = + range.min + (((range.max - range.min) as f32 * scalar.clamp(0.0, 1.0)).round() as i64); + values + .iter() + .map(|value| (target_peak + (*value - current_peak)).clamp(range.min, range.max)) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedAlsaRoute { + card: String, + pcm: String, + device: u32, +} + +fn parse_alsa_route(device_id: &str) -> Option { + let (prefix, arguments) = device_id.split_once(':')?; + let pcm = match prefix { + "hw" | "plughw" => "hw", + "front" => "front", + "iec958" => "iec958", + "hdmi" => "hdmi", + "sysdefault" => "sysdefault", + other => other, + } + .to_string(); + + let mut card = None; + let mut device = None; + for (position, argument) in arguments.split(',').enumerate() { + let argument = argument.trim(); + if let Some(value) = argument.strip_prefix("CARD=") { + card = Some(value.to_string()); + } else if let Some(value) = argument.strip_prefix("DEV=") { + device = value.parse::().ok(); + } else if position == 0 && !argument.is_empty() { + card = Some(argument.to_string()); + } else if position == 1 { + device = argument.parse::().ok(); + } + } + + Some(ParsedAlsaRoute { + card: card?, + pcm, + device: device.unwrap_or(0), + }) +} + +#[cfg(target_os = "linux")] +fn ucm_parser_identifier(value_name: &str) -> Option<&'static str> { + match value_name { + "PlaybackVolume" => Some("PlaybackVolume"), + "PlaybackMasterElem" | "PlaybackMixerElem" => Some("PlaybackMixerId"), + _ => None, + } +} + +/// Resolve a PCM id to the card ctl name accepted by ALSA mixer/control APIs. +pub fn mixer_ctl_name(device_id: &str) -> String { + let Some(route) = parse_alsa_route(device_id) else { + return device_id.to_string(); + }; + if route + .card + .chars() + .all(|character| character.is_ascii_digit()) + { + format!("hw:{}", route.card) + } else { + format!("hw:CARD={}", route.card) + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use alsa::ctl::Ctl; + use alsa_sys as ffi; + use std::ffi::{CStr, CString}; + use std::fs; + use std::os::unix::fs::MetadataExt; + use std::path::{Path, PathBuf}; + use std::ptr; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::{Duration, Instant}; + + const DB_GAIN_MUTE: i64 = -9_999_999; + const CHANNELS: &[(ffi::snd_mixer_selem_channel_id_t, &str)] = &[ + (ffi::SND_MIXER_SCHN_FRONT_LEFT, "Front Left"), + (ffi::SND_MIXER_SCHN_FRONT_RIGHT, "Front Right"), + (ffi::SND_MIXER_SCHN_REAR_LEFT, "Rear Left"), + (ffi::SND_MIXER_SCHN_REAR_RIGHT, "Rear Right"), + (ffi::SND_MIXER_SCHN_FRONT_CENTER, "Front Center"), + (ffi::SND_MIXER_SCHN_WOOFER, "Woofer"), + (ffi::SND_MIXER_SCHN_SIDE_LEFT, "Side Left"), + (ffi::SND_MIXER_SCHN_SIDE_RIGHT, "Side Right"), + (ffi::SND_MIXER_SCHN_REAR_CENTER, "Rear Center"), + ]; + + #[cfg(target_pointer_width = "64")] + fn c_long_to_i64(value: libc::c_long) -> i64 { + value + } + + #[cfg(not(target_pointer_width = "64"))] + fn c_long_to_i64(value: libc::c_long) -> i64 { + i64::from(value) + } + + #[repr(C)] + struct SndUseCaseMgr { + _private: [u8; 0], + } + + #[link(name = "asound")] + unsafe extern "C" { + fn snd_use_case_mgr_open( + manager: *mut *mut SndUseCaseMgr, + card_name: *const libc::c_char, + ) -> libc::c_int; + fn snd_use_case_mgr_close(manager: *mut SndUseCaseMgr) -> libc::c_int; + fn snd_use_case_get( + manager: *mut SndUseCaseMgr, + identifier: *const libc::c_char, + value: *mut *const libc::c_char, + ) -> libc::c_int; + fn snd_use_case_get_list( + manager: *mut SndUseCaseMgr, + identifier: *const libc::c_char, + list: *mut *mut *const libc::c_char, + ) -> libc::c_int; + fn snd_use_case_free_list( + list: *const *const libc::c_char, + items: libc::c_int, + ) -> libc::c_int; + fn snd_use_case_parse_selem_id( + destination: *mut ffi::snd_mixer_selem_id_t, + ucm_id: *const libc::c_char, + value: *const libc::c_char, + ) -> libc::c_int; + fn snd_use_case_parse_ctl_elem_id( + destination: *mut ffi::snd_ctl_elem_id_t, + ucm_id: *const libc::c_char, + value: *const libc::c_char, + ) -> libc::c_int; + } + + struct UcmManager(*mut SndUseCaseMgr); + + impl UcmManager { + fn open(card: &str) -> Option { + let card = CString::new(format!("hw:{card}")).ok()?; + let mut manager = ptr::null_mut(); + let result = unsafe { snd_use_case_mgr_open(&mut manager, card.as_ptr()) }; + // Do not express this with bool::then_some(Self(manager)): + // then_some eagerly constructs and drops the owner on failure, + // calling snd_use_case_mgr_close on an invalid pointer. + if result < 0 || manager.is_null() { + return None; + } + Some(Self(manager)) + } + + fn get(&self, identifier: &str) -> Option { + let identifier = CString::new(identifier).ok()?; + let mut value = ptr::null(); + if unsafe { snd_use_case_get(self.0, identifier.as_ptr(), &mut value) } < 0 + || value.is_null() + { + return None; + } + let result = unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned(); + unsafe { + libc::free(value.cast_mut().cast()); + } + (!result.is_empty()).then_some(result) + } + + fn list(&self, identifier: &str) -> Vec { + let Ok(identifier) = CString::new(identifier) else { + return Vec::new(); + }; + let mut list = ptr::null_mut(); + let count = unsafe { snd_use_case_get_list(self.0, identifier.as_ptr(), &mut list) }; + if count <= 0 || list.is_null() { + return Vec::new(); + } + let values = (0..count) + .filter_map(|index| { + let value = unsafe { *list.add(index as usize) }; + (!value.is_null()).then(|| { + unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned() + }) + }) + .collect(); + unsafe { + snd_use_case_free_list(list.cast_const(), count); + } + values + } + } + + impl Drop for UcmManager { + fn drop(&mut self) { + unsafe { + snd_use_case_mgr_close(self.0); + } + } + } + + struct RawMixer(*mut ffi::snd_mixer_t); + + impl RawMixer { + fn open(device_id: &str) -> Result { + let ctl_name = mixer_ctl_name(device_id); + let ctl = CString::new(ctl_name.as_str()).map_err(|_| HardwareVolumeProbeError { + kind: HardwareVolumeProbeErrorKind::InvalidDevice, + message: format!("Invalid ALSA ctl name for {device_id}"), + })?; + let mut mixer = ptr::null_mut(); + alsa_call( + unsafe { ffi::snd_mixer_open(&mut mixer, 0) }, + "open ALSA mixer", + )?; + let owner = Self(mixer); + alsa_call( + unsafe { ffi::snd_mixer_attach(owner.0, ctl.as_ptr()) }, + &format!("attach ALSA mixer to {ctl_name}"), + )?; + alsa_call( + unsafe { ffi::snd_mixer_selem_register(owner.0, ptr::null_mut(), ptr::null_mut()) }, + "register ALSA simple mixer elements", + )?; + alsa_call(unsafe { ffi::snd_mixer_load(owner.0) }, "load ALSA mixer")?; + Ok(owner) + } + + fn find(&self, id: &AlsaMixerControlId) -> Option<*mut ffi::snd_mixer_elem_t> { + let mut element = unsafe { ffi::snd_mixer_first_elem(self.0) }; + while !element.is_null() { + if unsafe { ffi::snd_mixer_elem_get_type(element) } == ffi::SND_MIXER_ELEM_SIMPLE { + let candidate = selem_id(element); + if candidate.as_ref() == Some(id) { + return Some(element); + } + } + element = unsafe { ffi::snd_mixer_elem_next(element) }; + } + None + } + } + + impl Drop for RawMixer { + fn drop(&mut self) { + unsafe { + ffi::snd_mixer_close(self.0); + } + } + } + + struct RawCtl(*mut ffi::snd_ctl_t); + + impl RawCtl { + fn open(ctl_name: &str, nonblock: bool) -> Result { + let name = CString::new(ctl_name).map_err(|_| HardwareVolumeProbeError { + kind: HardwareVolumeProbeErrorKind::InvalidDevice, + message: format!("Invalid ALSA ctl name: {ctl_name}"), + })?; + let mut ctl = ptr::null_mut(); + alsa_call( + unsafe { ffi::snd_ctl_open(&mut ctl, name.as_ptr(), i32::from(nonblock)) }, + &format!("open ALSA ctl {ctl_name}"), + )?; + Ok(Self(ctl)) + } + + /// `None` means the Selem -> ctl translation was not reliable enough; + /// the explicit enable path will do a per-channel current-value writeback. + fn writable(&self, id: &AlsaMixerControlId) -> Option { + let raw_names = if id.name.ends_with(" Playback Volume") { + vec![id.name.clone()] + } else { + vec![format!("{} Playback Volume", id.name), id.name.clone()] + }; + for raw_name in raw_names { + let Ok(raw_name) = CString::new(raw_name) else { + continue; + }; + let mut info = ptr::null_mut(); + if unsafe { ffi::snd_ctl_elem_info_malloc(&mut info) } < 0 { + return None; + } + unsafe { + ffi::snd_ctl_elem_info_set_interface(info, ffi::SND_CTL_ELEM_IFACE_MIXER); + ffi::snd_ctl_elem_info_set_name(info, raw_name.as_ptr()); + ffi::snd_ctl_elem_info_set_index(info, id.index); + } + let result = unsafe { ffi::snd_ctl_elem_info(self.0, info) }; + let writable = + (result >= 0).then(|| unsafe { ffi::snd_ctl_elem_info_is_writable(info) > 0 }); + unsafe { + ffi::snd_ctl_elem_info_free(info); + } + if writable.is_some() { + return writable; + } + } + None + } + } + + impl Drop for RawCtl { + fn drop(&mut self) { + unsafe { + ffi::snd_ctl_close(self.0); + } + } + } + + fn alsa_call(code: i32, operation: &str) -> Result<(), HardwareVolumeProbeError> { + if code >= 0 { + return Ok(()); + } + let errno = -code; + let detail = unsafe { + let message = ffi::snd_strerror(code); + if message.is_null() { + format!("ALSA error {code}") + } else { + CStr::from_ptr(message).to_string_lossy().into_owned() + } + }; + let kind = match errno { + libc::EACCES | libc::EPERM => HardwareVolumeProbeErrorKind::PermissionDenied, + libc::EBUSY => HardwareVolumeProbeErrorKind::DeviceBusy, + libc::ENODEV | libc::ENXIO => HardwareVolumeProbeErrorKind::DeviceUnavailable, + libc::ENOENT => HardwareVolumeProbeErrorKind::NoMixer, + _ => HardwareVolumeProbeErrorKind::Other, + }; + Err(HardwareVolumeProbeError { + kind, + message: format!("Failed to {operation}: {detail}"), + }) + } + + fn selem_id(element: *mut ffi::snd_mixer_elem_t) -> Option { + let name = unsafe { ffi::snd_mixer_selem_get_name(element) }; + if name.is_null() { + return None; + } + Some(AlsaMixerControlId { + name: unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned(), + index: unsafe { ffi::snd_mixer_selem_get_index(element) }, + }) + } + + fn playback_channels(element: *mut ffi::snd_mixer_elem_t) -> Vec<(i32, String)> { + CHANNELS + .iter() + .filter(|(channel, _)| unsafe { + ffi::snd_mixer_selem_has_playback_channel(element, *channel) > 0 + }) + .map(|(channel, name)| (*channel, (*name).to_string())) + .collect() + } + + fn finite_db_range( + element: *mut ffi::snd_mixer_elem_t, + raw_range: HardwareVolumeRange, + ) -> Option { + let mut min = 0; + let mut max = 0; + if unsafe { ffi::snd_mixer_selem_get_playback_dB_range(element, &mut min, &mut max) } < 0 + || max <= min + { + return None; + } + let mut min = c_long_to_i64(min); + let max = c_long_to_i64(max); + if min <= DB_GAIN_MUTE { + if raw_range.max <= raw_range.min { + return None; + } + let mut finite = 0; + if unsafe { + ffi::snd_mixer_selem_ask_playback_vol_dB( + element, + (raw_range.min + 1) as _, + &mut finite, + ) + } < 0 + || c_long_to_i64(finite) <= DB_GAIN_MUTE + { + return None; + } + min = c_long_to_i64(finite); + } + (max > min).then_some(HardwareVolumeRange { min, max }) + } + + fn read_channels( + element: *mut ffi::snd_mixer_elem_t, + channels: &[(i32, String)], + db_range: Option, + has_switch: bool, + ) -> Result, String> { + let mut values = Vec::with_capacity(channels.len()); + for (channel, name) in channels { + let mut raw = 0; + let code = + unsafe { ffi::snd_mixer_selem_get_playback_volume(element, *channel, &mut raw) }; + if code < 0 { + return Err(format!("{}: {}", name, alsa_error_text(code))); + } + let db_millibels = db_range.and_then(|_| { + let mut value = 0; + (unsafe { ffi::snd_mixer_selem_get_playback_dB(element, *channel, &mut value) } + >= 0) + .then_some(c_long_to_i64(value)) + }); + let playback_switch = has_switch.then(|| { + let mut enabled = 0; + let code = unsafe { + ffi::snd_mixer_selem_get_playback_switch(element, *channel, &mut enabled) + }; + (code >= 0).then_some(enabled != 0) + }); + if has_switch && playback_switch.flatten().is_none() { + return Err(format!("{}: playback switch read failed", name)); + } + values.push(HardwareVolumeChannelValue { + channel: name.clone(), + raw: c_long_to_i64(raw), + db_millibels, + playback_switch: playback_switch.flatten(), + }); + } + Ok(values) + } + + fn alsa_error_text(code: i32) -> String { + unsafe { + let message = ffi::snd_strerror(code); + if message.is_null() { + format!("ALSA error {code}") + } else { + CStr::from_ptr(message).to_string_lossy().into_owned() + } + } + } + + fn probe_candidate( + element: *mut ffi::snd_mixer_elem_t, + ctl: Option<&RawCtl>, + ) -> Option { + let id = selem_id(element)?; + let mut candidate = HardwareVolumeCandidate { + label: if id.index == 0 { + id.name.clone() + } else { + format!("{} ({})", id.name, id.index) + }, + id, + channels: Vec::new(), + raw_range: None, + db_range: None, + current_values: Vec::new(), + current_volume: None, + has_playback_switch: unsafe { ffi::snd_mixer_selem_has_playback_switch(element) > 0 }, + recommended: false, + writability: HardwareVolumeWritability::NeedsEnableCheck, + rejection_reason: None, + }; + + let active = unsafe { ffi::snd_mixer_selem_is_active(element) } > 0; + let has_playback = unsafe { ffi::snd_mixer_selem_has_playback_volume(element) } > 0; + let has_capture = unsafe { ffi::snd_mixer_selem_has_capture_volume(element) } > 0; + let has_common_volume = unsafe { ffi::snd_mixer_selem_has_common_volume(element) } > 0; + + let mut min = 0; + let mut max = 0; + let raw_range = has_playback.then(|| { + let _ = unsafe { + ffi::snd_mixer_selem_get_playback_volume_range(element, &mut min, &mut max) + }; + HardwareVolumeRange { + min: c_long_to_i64(min), + max: c_long_to_i64(max), + } + }); + candidate.raw_range = raw_range; + let channels = if has_playback { + playback_channels(element) + } else { + Vec::new() + }; + candidate.channels = channels.iter().map(|(_, name)| name.clone()).collect(); + if let Some(reason) = structural_rejection(CandidateFacts { + name: &candidate.id.name, + active, + has_playback, + has_capture, + has_common_volume, + raw_range, + playback_channels: channels.len(), + }) { + candidate.rejection_reason = Some(reason); + return Some(candidate); + } + let raw_range = raw_range.expect("structural validation requires a valid range"); + candidate.db_range = finite_db_range(element, raw_range); + match read_channels( + element, + &channels, + candidate.db_range, + candidate.has_playback_switch, + ) { + Ok(values) => candidate.current_values = values, + Err(error) => { + candidate.rejection_reason = Some(HardwareVolumeRejectionReason::ReadFailed(error)); + return Some(candidate); + } + } + + let writable = ctl.and_then(|ctl| ctl.writable(&candidate.id)); + if let Some(reason) = writability_rejection(writable) { + candidate.rejection_reason = Some(reason); + return Some(candidate); + } + if writable == Some(true) { + candidate.writability = HardwareVolumeWritability::Verified; + } + candidate.current_volume = Some(normalize_level( + raw_range, + candidate.db_range, + &candidate.current_values, + )); + Some(candidate) + } + + fn ucm_identifier(value: &str, device: Option<&str>) -> String { + match device { + Some(device) => format!("={value}/{device}/"), + None => format!("={value}//"), + } + } + + fn ucm_route_matches( + manager: &UcmManager, + route: &ParsedAlsaRoute, + device: Option<&str>, + ) -> bool { + let identifier = ucm_identifier("PlaybackPCM", device); + let Some(pcm) = manager.get(&identifier) else { + return false; + }; + parse_alsa_route(&pcm) + .is_some_and(|ucm_route| ucm_route.device == route.device && ucm_route.pcm == route.pcm) + } + + fn parse_ucm_control( + value_name: &str, + value: &str, + candidates: &[HardwareVolumeCandidate], + ) -> Option { + // The parser API takes a canonical UCM value kind, not the + // `snd_use_case_get()` path used to retrieve it. ALSA names the + // simple-element parser kind `PlaybackMixerId` even when the value + // originated in PlaybackMasterElem or PlaybackMixerElem. + let parser_id = CString::new(ucm_parser_identifier(value_name)?).ok()?; + let value_c = CString::new(value).ok()?; + let parsed = if value_name == "PlaybackVolume" { + let mut raw_id = ptr::null_mut(); + if unsafe { ffi::snd_ctl_elem_id_malloc(&mut raw_id) } < 0 || raw_id.is_null() { + return None; + } + let code = unsafe { + snd_use_case_parse_ctl_elem_id(raw_id, parser_id.as_ptr(), value_c.as_ptr()) + }; + let result = (code >= 0).then(|| { + let name = unsafe { ffi::snd_ctl_elem_id_get_name(raw_id) }; + AlsaMixerControlId { + name: if name.is_null() { + String::new() + } else { + unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned() + }, + index: unsafe { ffi::snd_ctl_elem_id_get_index(raw_id) }, + } + }); + unsafe { + ffi::snd_ctl_elem_id_free(raw_id); + } + result + } else { + let mut selem_id = ptr::null_mut(); + if unsafe { ffi::snd_mixer_selem_id_malloc(&mut selem_id) } < 0 || selem_id.is_null() { + return None; + } + let code = unsafe { + snd_use_case_parse_selem_id(selem_id, parser_id.as_ptr(), value_c.as_ptr()) + }; + let result = (code >= 0).then(|| { + let name = unsafe { ffi::snd_mixer_selem_id_get_name(selem_id) }; + AlsaMixerControlId { + name: if name.is_null() { + String::new() + } else { + unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned() + }, + index: unsafe { ffi::snd_mixer_selem_id_get_index(selem_id) }, + } + }); + unsafe { + ffi::snd_mixer_selem_id_free(selem_id); + } + result + } + .or_else(|| parse_ucm_control_text(value))?; + + candidates + .iter() + .find(|candidate| candidate.id == parsed) + .map(|candidate| candidate.id.clone()) + .or_else(|| { + let selem_name = parsed + .name + .strip_suffix(" Playback Volume") + .or_else(|| parsed.name.strip_suffix(" Volume"))?; + candidates + .iter() + .find(|candidate| { + candidate.id.index == parsed.index && candidate.id.name == selem_name + }) + .map(|candidate| candidate.id.clone()) + }) + } + + fn parse_ucm_control_text(value: &str) -> Option { + let trimmed = value.trim(); + let name = if let Some(position) = trimmed.find("name=") { + let rest = trimmed[position + 5..].trim_start(); + quoted_or_token(rest)? + } else { + quoted_or_token(trimmed)? + }; + let index = trimmed + .find("index=") + .and_then(|position| { + trimmed[position + 6..] + .trim_start() + .split(|character: char| !character.is_ascii_digit()) + .next() + }) + .and_then(|value| value.parse::().ok()) + .or_else(|| { + trimmed + .rsplit_once(',') + .and_then(|(_, value)| value.trim().parse::().ok()) + }) + .unwrap_or(0); + Some(AlsaMixerControlId { name, index }) + } + + fn quoted_or_token(value: &str) -> Option { + let value = value.trim_start(); + let first = value.chars().next()?; + if first == '\'' || first == '"' { + let rest = &value[first.len_utf8()..]; + let end = rest.find(first)?; + return Some(rest[..end].to_string()); + } + let end = value + .find(|character: char| character == ',' || character.is_whitespace()) + .unwrap_or(value.len()); + (end > 0).then(|| value[..end].to_string()) + } + + fn ucm_controls_for_route( + route: &ParsedAlsaRoute, + candidates: &[HardwareVolumeCandidate], + ) -> Vec { + let Some(manager) = UcmManager::open(&route.card) else { + return Vec::new(); + }; + let Some(verb) = manager.get("_verb") else { + return Vec::new(); + }; + if verb.eq_ignore_ascii_case("Inactive") { + return Vec::new(); + } + + let enabled_devices = manager.list("_enadevs"); + let mut contexts = Vec::new(); + if ucm_route_matches(&manager, route, None) { + contexts.push(None); + } + contexts.extend( + enabled_devices + .iter() + .filter(|device| ucm_route_matches(&manager, route, Some(device))) + .map(|device| Some(device.as_str())), + ); + if contexts.is_empty() { + return Vec::new(); + } + + for value_name in ["PlaybackMasterElem", "PlaybackMixerElem", "PlaybackVolume"] { + let mut controls = HashSet::new(); + for device in &contexts { + if value_name == "PlaybackMasterElem" { + let master_type = manager + .get(&ucm_identifier("PlaybackMasterType", *device)) + .or_else(|| { + device + .is_some() + .then(|| manager.get(&ucm_identifier("PlaybackMasterType", None))) + .flatten() + }); + if master_type.is_some_and(|value| value.eq_ignore_ascii_case("soft")) { + continue; + } + } + let identifier = ucm_identifier(value_name, *device); + let Some(value) = manager.get(&identifier) else { + continue; + }; + if let Some(control) = parse_ucm_control(value_name, &value, candidates) { + controls.insert(control); + } + } + if !controls.is_empty() { + let mut controls = controls.into_iter().collect::>(); + controls.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.index.cmp(&right.index)) + }); + return controls; + } + } + Vec::new() + } + + pub(super) fn stable_route_key( + device_id: &str, + ) -> Result { + let route = parse_alsa_route(device_id).ok_or_else(|| HardwareVolumeProbeError { + kind: HardwareVolumeProbeErrorKind::InvalidDevice, + message: format!("Cannot parse ALSA PCM route: {device_id}"), + })?; + let (card_number, short_id) = + resolve_card(&route.card).ok_or_else(|| HardwareVolumeProbeError { + kind: HardwareVolumeProbeErrorKind::DeviceUnavailable, + message: format!("ALSA card '{}' is not present", route.card), + })?; + let control = PathBuf::from(format!("/dev/snd/controlC{card_number}")); + let identity = select_stable_card_identity( + has_real_card_serial(card_number), + find_stable_link("/dev/snd/by-id", &control), + find_stable_link("/dev/snd/by-path", &control), + short_id, + ); + Ok(StableAlsaRouteKey::from_parts( + &identity, + &route.pcm, + route.device, + )) + } + + /// A udev `by-id` link may exist even when USB's ID_SERIAL was synthesized + /// from vendor + model alone (for example `Fosi_Fosi_Audio_ZH3`). Only a + /// usable serial attribute on the owning USB device is strong enough to + /// distinguish two otherwise-identical DACs; without it, port identity + /// (`by-path`) is the safe key. Some devices publish placeholders such as + /// `0000`, which udev still includes in `ID_SERIAL`; those are not stable + /// identities. Stop at the first USB device so a root hub's own serial + /// cannot be mistaken for the DAC's. + fn has_real_card_serial(card_number: u32) -> bool { + let Ok(device) = fs::canonicalize(format!("/sys/class/sound/card{card_number}/device")) + else { + return false; + }; + has_real_usb_serial(device, Path::new("/sys/devices")) + } + + fn has_real_usb_serial(mut device: PathBuf, devices_root: &Path) -> bool { + while device.starts_with(devices_root) { + if device.join("idVendor").is_file() && device.join("idProduct").is_file() { + return fs::read_to_string(device.join("serial")) + .ok() + .is_some_and(|serial| is_usable_usb_serial(&serial)); + } + if !device.pop() { + break; + } + } + false + } + + fn is_usable_usb_serial(serial: &str) -> bool { + let serial = serial.trim(); + if serial.is_empty() + || matches!( + serial.to_ascii_lowercase().as_str(), + "none" | "null" | "unknown" | "default" | "n/a" | "na" + ) + { + return false; + } + + // A run of zeroes is a common firmware placeholder (the DacMagic Plus + // reports `0000`). Keep zero-padded real identifiers such as `0001`. + serial + .chars() + .any(|character| character.is_ascii_alphanumeric() && character != '0') + } + + fn resolve_card(card: &str) -> Option<(u32, String)> { + if let Ok(number) = card.parse::() { + let short = fs::read_to_string(format!("/proc/asound/card{number}/id")) + .ok()? + .trim() + .to_string(); + return (!short.is_empty()).then_some((number, short)); + } + let entries = fs::read_dir("/proc/asound").ok()?; + for entry in entries.flatten() { + let file_name = entry.file_name(); + let Some(suffix) = file_name + .to_string_lossy() + .strip_prefix("card") + .map(str::to_string) + else { + continue; + }; + let Ok(number) = suffix.parse::() else { + continue; + }; + let Ok(short) = fs::read_to_string(entry.path().join("id")) else { + continue; + }; + if short.trim() == card { + return Some((number, card.to_string())); + } + } + None + } + + fn find_stable_link(directory: &str, control: &Path) -> Option { + let target = fs::canonicalize(control).ok()?; + let target_metadata = fs::metadata(&target).ok(); + let mut matches = fs::read_dir(directory) + .ok()? + .flatten() + .filter_map(|entry| { + let resolved = fs::canonicalize(entry.path()).ok()?; + let same_path = resolved == target; + let same_device = target_metadata.as_ref().is_some_and(|target| { + fs::metadata(&resolved) + .ok() + .is_some_and(|candidate| candidate.rdev() == target.rdev()) + }); + (same_path || same_device).then(|| entry.file_name().to_string_lossy().into_owned()) + }) + .collect::>(); + matches.sort(); + matches.into_iter().next() + } + + pub(super) fn enumerate(device_id: &str) -> HardwareVolumeProbe { + let ctl_name = mixer_ctl_name(device_id); + let route_key = stable_route_key(device_id); + let mixer = match RawMixer::open(device_id) { + Ok(mixer) => mixer, + Err(error) => { + return HardwareVolumeProbe { + device_id: device_id.to_string(), + ctl_name, + route_key: route_key.ok(), + candidates: Vec::new(), + ucm_controls: Vec::new(), + error: Some(error), + }; + } + }; + let ctl = RawCtl::open(&ctl_name, false).ok(); + let mut candidates = Vec::new(); + let mut element = unsafe { ffi::snd_mixer_first_elem(mixer.0) }; + while !element.is_null() { + if unsafe { ffi::snd_mixer_elem_get_type(element) } == ffi::SND_MIXER_ELEM_SIMPLE { + if let Some(candidate) = probe_candidate(element, ctl.as_ref()) { + candidates.push(candidate); + } + } + element = unsafe { ffi::snd_mixer_elem_next(element) }; + } + mark_recommended(&mut candidates); + candidates.sort_by(|left, right| { + right + .is_valid() + .cmp(&left.is_valid()) + .then_with(|| right.recommended.cmp(&left.recommended)) + .then_with(|| left.id.name.cmp(&right.id.name)) + .then_with(|| left.id.index.cmp(&right.id.index)) + }); + + let (route_key, error) = match route_key { + Ok(route_key) => (Some(route_key), None), + Err(error) => (None, Some(error)), + }; + let ucm_controls = parse_alsa_route(device_id) + .map(|route| ucm_controls_for_route(&route, &candidates)) + .unwrap_or_default(); + HardwareVolumeProbe { + device_id: device_id.to_string(), + ctl_name, + route_key, + candidates, + ucm_controls, + error, + } + } + + fn exact_candidate( + device_id: &str, + id: &AlsaMixerControlId, + ) -> Result { + let probe = enumerate(device_id); + if let Some(error) = probe.error { + return Err(error.message); + } + probe + .candidates + .into_iter() + .find(|candidate| candidate.id == *id && candidate.is_valid()) + .ok_or_else(|| format!("ALSA mixer control {id} is missing or no longer valid")) + } + + pub(super) fn activate( + device_id: &str, + id: &AlsaMixerControlId, + ) -> Result { + let candidate = exact_candidate(device_id, id)?; + if candidate.writability == HardwareVolumeWritability::NeedsEnableCheck { + let mixer = RawMixer::open(device_id).map_err(|error| error.message)?; + let element = mixer + .find(id) + .ok_or_else(|| format!("ALSA mixer control {id} disappeared during enable"))?; + let channels = playback_channels(element); + let values = read_channels( + element, + &channels, + candidate.db_range, + candidate.has_playback_switch, + )?; + // Explicit user action only: write each channel's current value + // back independently so the capability check cannot alter balance. + for ((channel, name), value) in channels.iter().zip(&values) { + let code = unsafe { + ffi::snd_mixer_selem_set_playback_volume(element, *channel, value.raw as _) + }; + if code < 0 { + return Err(format!( + "ALSA mixer control {id} is not writable on {name}: {}", + alsa_error_text(code) + )); + } + } + } + read(device_id, id) + } + + pub(super) fn read( + device_id: &str, + id: &AlsaMixerControlId, + ) -> Result { + let mixer = RawMixer::open(device_id).map_err(|error| error.message)?; + let element = mixer + .find(id) + .ok_or_else(|| format!("ALSA mixer control {id} is missing"))?; + let channels = playback_channels(element); + if channels.is_empty() { + return Err(format!("ALSA mixer control {id} has no playback channels")); + } + let mut min = 0; + let mut max = 0; + if unsafe { ffi::snd_mixer_selem_get_playback_volume_range(element, &mut min, &mut max) } + < 0 + || max <= min + { + return Err(format!( + "ALSA mixer control {id} has no usable volume range" + )); + } + let raw_range = HardwareVolumeRange { + min: c_long_to_i64(min), + max: c_long_to_i64(max), + }; + let db_range = finite_db_range(element, raw_range); + let has_switch = unsafe { ffi::snd_mixer_selem_has_playback_switch(element) } > 0; + let values = read_channels(element, &channels, db_range, has_switch)?; + let muted = has_switch + && values + .iter() + .filter_map(|value| value.playback_switch) + .all(|enabled| !enabled); + Ok(HardwareVolumeSnapshot { + control: id.clone(), + volume: normalize_level(raw_range, db_range, &values), + channels: values, + muted, + }) + } + + pub(super) fn set( + device_id: &str, + id: &AlsaMixerControlId, + scalar: f32, + ) -> Result { + let candidate = exact_candidate(device_id, id)?; + let raw_range = candidate + .raw_range + .ok_or_else(|| format!("ALSA mixer control {id} has no usable raw range"))?; + let mixer = RawMixer::open(device_id).map_err(|error| error.message)?; + let element = mixer + .find(id) + .ok_or_else(|| format!("ALSA mixer control {id} disappeared before write"))?; + let channels = playback_channels(element); + let scalar = scalar.clamp(0.0, 1.0); + let write_plan = hardware_volume_write_plan(scalar, candidate.has_playback_switch); + + if write_plan.playback_switch == Some(false) { + for (channel, name) in &channels { + let code = + unsafe { ffi::snd_mixer_selem_set_playback_switch(element, *channel, 0) }; + if code < 0 { + return Err(format!( + "Failed to mute {id} on {name}: {}", + alsa_error_text(code) + )); + } + } + let snapshot = read(device_id, id)?; + remember_local_write(device_id, id, snapshot.volume); + return Ok(snapshot); + } + + if write_plan.playback_switch == Some(true) { + // Restore the hardware route before moving its volume away from zero. + for (channel, name) in &channels { + let code = + unsafe { ffi::snd_mixer_selem_set_playback_switch(element, *channel, 1) }; + if code < 0 { + return Err(format!( + "Failed to unmute {id} on {name}: {}", + alsa_error_text(code) + )); + } + } + } + debug_assert!(write_plan.write_level); + + let values = read_channels( + element, + &channels, + candidate.db_range, + candidate.has_playback_switch, + )?; + let mut used_db = false; + // With no playback switch, UI zero means the raw minimum even when a + // driver's finite dB range deliberately excludes its mute sentinel. + if scalar > f32::EPSILON { + if let Some(db_range) = candidate.db_range { + let current_db = values + .iter() + .map(|value| value.db_millibels) + .collect::>>(); + if let Some(current_db) = current_db { + let targets = channel_targets(¤t_db, db_range, scalar); + if targets.len() == channels.len() { + let mut failed = None; + for ((channel, name), target) in channels.iter().zip(targets) { + let code = unsafe { + ffi::snd_mixer_selem_set_playback_dB( + element, + *channel, + target as _, + 0, + ) + }; + if code < 0 { + failed = Some(format!("{name}: {}", alsa_error_text(code))); + break; + } + } + if let Some(error) = failed { + log::warn!( + "[ALSA HW Volume] dB write failed for {id}; using raw fallback: {error}" + ); + } else { + used_db = true; + } + } + } + } + } + + if !used_db { + let current_raw = values.iter().map(|value| value.raw).collect::>(); + let targets = channel_targets(¤t_raw, raw_range, scalar); + for ((channel, name), target) in channels.iter().zip(targets) { + let code = unsafe { + ffi::snd_mixer_selem_set_playback_volume(element, *channel, target as _) + }; + if code < 0 { + return Err(format!( + "Failed to set raw ALSA volume for {id} on {name}: {}", + alsa_error_text(code) + )); + } + } + } + + let snapshot = read(device_id, id)?; + remember_local_write(device_id, id, snapshot.volume); + Ok(snapshot) + } + + type LocalWriteMap = HashMap; + static LOCAL_WRITES: std::sync::OnceLock> = + std::sync::OnceLock::new(); + + fn runtime_control_key(device_id: &str, id: &AlsaMixerControlId) -> String { + format!("{device_id}\u{1f}{}\u{1f}{}", id.name, id.index) + } + + fn remember_local_write(device_id: &str, id: &AlsaMixerControlId, volume: f32) { + let writes = LOCAL_WRITES.get_or_init(Default::default); + if let Ok(mut writes) = writes.lock() { + writes.insert(runtime_control_key(device_id, id), (volume, Instant::now())); + } + } + + fn is_recent_local_write(device_id: &str, id: &AlsaMixerControlId, volume: f32) -> bool { + let writes = LOCAL_WRITES.get_or_init(Default::default); + let Ok(mut writes) = writes.lock() else { + return false; + }; + let key = runtime_control_key(device_id, id); + let suppress = writes.get(&key).is_some_and(|(written, at)| { + suppress_local_write_echo(*written, at.elapsed().as_millis(), volume) + }); + if suppress { + writes.remove(&key); + } + suppress + } + + pub(super) fn subscribe( + device_id: &str, + id: &AlsaMixerControlId, + callback: HardwareVolumeEventCallback, + ) -> Result { + let ctl_name = mixer_ctl_name(device_id); + let ctl = Ctl::new(&ctl_name, true) + .map_err(|error| format!("Failed to open ALSA ctl {ctl_name}: {error}"))?; + ctl.subscribe_events(true) + .map_err(|error| format!("Failed to subscribe to ALSA ctl {ctl_name}: {error}"))?; + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = stop.clone(); + let device_id = device_id.to_string(); + let id = id.clone(); + let thread = std::thread::Builder::new() + .name("qbz-alsa-volume-events".to_string()) + .spawn(move || { + let mut last_published = read(&device_id, &id).ok().map(|snapshot| snapshot.volume); + 'events: while !thread_stop.load(Ordering::SeqCst) { + match ctl.wait(Some(100)) { + Ok(true) => { + let mut changed = false; + loop { + match ctl.read() { + Ok(Some(event)) => changed |= event.get_mask().value(), + Ok(None) => break, + Err(error) => { + callback(HardwareVolumeEvent::Unavailable(format!( + "ALSA ctl event read failed: {error}" + ))); + break 'events; + } + } + } + if !changed { + continue; + } + std::thread::sleep(Duration::from_millis(35)); + match read(&device_id, &id) { + Ok(snapshot) => { + if is_recent_local_write(&device_id, &id, snapshot.volume) { + last_published = Some(snapshot.volume); + continue; + } + if last_published.is_some_and(|previous| { + (previous - snapshot.volume).abs() < 0.001 + }) { + continue; + } + last_published = Some(snapshot.volume); + callback(HardwareVolumeEvent::Changed(snapshot)); + } + Err(error) => { + callback(HardwareVolumeEvent::Unavailable(error)); + break; + } + } + } + Ok(false) => {} + Err(error) => { + callback(HardwareVolumeEvent::Unavailable(format!( + "ALSA ctl event subscription failed: {error}" + ))); + break; + } + } + } + let _ = ctl.subscribe_events(false); + }) + .map_err(|error| format!("Failed to start ALSA volume event thread: {error}"))?; + Ok(HardwareVolumeEventSubscription { + stop, + thread: Some(thread), + }) + } + + #[cfg(test)] + mod sysfs_tests { + use super::*; + + #[test] + fn dac_without_serial_does_not_inherit_the_root_hub_serial() { + let root = std::env::temp_dir().join(format!( + "qbz-alsa-serial-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let hub = root.join("usb3"); + let dac = hub.join("3-5"); + let interface = dac.join("3-5:1.1"); + fs::create_dir_all(&interface).expect("create fake sysfs tree"); + for device in [&hub, &dac] { + fs::write(device.join("idVendor"), "1234").expect("write vendor"); + fs::write(device.join("idProduct"), "5678").expect("write product"); + } + fs::write(hub.join("serial"), "0000:00:14.0").expect("write hub serial"); + + assert!(!has_real_usb_serial(interface.clone(), &root)); + fs::write(dac.join("serial"), "0000").expect("write placeholder DAC serial"); + assert!(!has_real_usb_serial(interface.clone(), &root)); + fs::write(dac.join("serial"), "REAL-DAC-SERIAL").expect("write DAC serial"); + assert!(has_real_usb_serial(interface, &root)); + + fs::remove_dir_all(root).expect("remove fake sysfs tree"); + } + + #[test] + fn usb_serial_placeholders_are_not_stable_identities() { + for placeholder in [ + "", " ", "0000", "none", "NULL", "unknown", "default", "N/A", + ] { + assert!(!is_usable_usb_serial(placeholder), "{placeholder:?}"); + } + assert!(is_usable_usb_serial("0001")); + assert!(is_usable_usb_serial("REAL-DAC-SERIAL")); + } + } +} + +/// Enumerate every simple-mixer element and retain rejected candidates for +/// diagnostics. This function never writes to ALSA. +pub fn enumerate_hardware_volume_controls(device_id: &str) -> HardwareVolumeProbe { + #[cfg(target_os = "linux")] + { + linux::enumerate(device_id) + } + #[cfg(not(target_os = "linux"))] + { + HardwareVolumeProbe { + device_id: device_id.to_string(), + ctl_name: device_id.to_string(), + route_key: None, + candidates: Vec::new(), + ucm_controls: Vec::new(), + error: Some(HardwareVolumeProbeError { + kind: HardwareVolumeProbeErrorKind::UnsupportedPlatform, + message: "ALSA hardware volume is only available on Linux".to_string(), + }), + } + } +} + +pub fn stable_alsa_route_key(device_id: &str) -> Result { + #[cfg(target_os = "linux")] + { + linux::stable_route_key(device_id).map_err(|error| error.message) + } + #[cfg(not(target_os = "linux"))] + { + let _ = device_id; + Err("ALSA route identities are only available on Linux".to_string()) + } +} + +/// Explicit enable-time validation. If ctl identity cannot prove writability, +/// this performs a per-channel writeback of the values just read. +pub fn activate_hardware_volume_control( + device_id: &str, + id: &AlsaMixerControlId, +) -> Result { + #[cfg(target_os = "linux")] + { + linux::activate(device_id, id) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (device_id, id); + Err("ALSA hardware volume is only available on Linux".to_string()) + } +} + +pub fn read_hardware_volume( + device_id: &str, + id: &AlsaMixerControlId, +) -> Result { + #[cfg(target_os = "linux")] + { + linux::read(device_id, id) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (device_id, id); + Err("ALSA hardware volume is only available on Linux".to_string()) + } +} + +pub fn set_hardware_volume( + device_id: &str, + id: &AlsaMixerControlId, + volume: f32, +) -> Result { + #[cfg(target_os = "linux")] + { + linux::set(device_id, id, volume) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (device_id, id, volume); + Err("ALSA hardware volume is only available on Linux".to_string()) + } +} + +pub fn subscribe_hardware_volume_events( + device_id: &str, + id: &AlsaMixerControlId, + callback: HardwareVolumeEventCallback, +) -> Result { + #[cfg(target_os = "linux")] + { + linux::subscribe(device_id, id, callback) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (device_id, id, callback); + Err("ALSA hardware volume is only available on Linux".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candidate(name: &str, index: u32, valid: bool) -> HardwareVolumeCandidate { + HardwareVolumeCandidate { + id: AlsaMixerControlId { + name: name.to_string(), + index, + }, + label: name.to_string(), + channels: vec!["Front Left".to_string(), "Front Right".to_string()], + raw_range: Some(HardwareVolumeRange { min: 0, max: 100 }), + db_range: Some(HardwareVolumeRange { min: -6000, max: 0 }), + current_values: Vec::new(), + current_volume: Some(0.5), + has_playback_switch: true, + recommended: false, + writability: HardwareVolumeWritability::Verified, + rejection_reason: (!valid).then_some(HardwareVolumeRejectionReason::ReadOnly), + } + } + + fn probe(candidates: Vec) -> HardwareVolumeProbe { + HardwareVolumeProbe { + device_id: "front:CARD=D50,DEV=0".to_string(), + ctl_name: "hw:CARD=D50".to_string(), + route_key: Some(StableAlsaRouteKey( + "alsa-route-v1|card=card:D50|pcm=front|DEV=0".to_string(), + )), + candidates, + ucm_controls: Vec::new(), + error: None, + } + } + + #[test] + fn unknown_single_candidate_is_selected() { + let decision = + decide_hardware_volume(&probe(vec![candidate("D50 III", 0, true)]), &HashMap::new()); + assert!(matches!( + decision, + HardwareVolumeDecision::Selected { + selection: HardwareVolumeSelection { + source: HardwareVolumeSelectionSource::OnlyCandidate, + .. + } + } + )); + } + + #[test] + fn two_unknown_candidates_need_choice() { + let decision = decide_hardware_volume( + &probe(vec![ + candidate("Output A", 0, true), + candidate("Output B", 0, true), + ]), + &HashMap::new(), + ); + assert!(matches!( + decision, + HardwareVolumeDecision::NeedsChoice { + reason: HardwareVolumeChoiceReason::Ambiguous, + .. + } + )); + } + + #[test] + fn same_name_different_indices_remain_distinct() { + let decision = decide_hardware_volume( + &probe(vec![candidate("DAC", 0, true), candidate("DAC", 1, true)]), + &HashMap::new(), + ); + let HardwareVolumeDecision::NeedsChoice { candidates, .. } = decision else { + panic!("expected choice"); + }; + assert_eq!(candidates.len(), 2); + assert_ne!(candidates[0].id, candidates[1].id); + } + + #[test] + fn invalid_master_does_not_block_valid_unknown_control() { + let decision = decide_hardware_volume( + &probe(vec![ + candidate("Master", 0, false), + candidate("D50 III", 0, true), + ]), + &HashMap::new(), + ); + let HardwareVolumeDecision::Selected { selection } = decision else { + panic!("expected selection"); + }; + assert_eq!(selection.control.name, "D50 III"); + } + + #[test] + fn persisted_exact_match_wins_but_stale_does_not_fall_through() { + let probe = probe(vec![ + candidate("Master", 0, true), + candidate("DAC", 0, true), + ]); + let route = probe.route_key.clone().unwrap(); + let mut persisted = HashMap::new(); + persisted.insert( + route.clone(), + AlsaMixerControlId { + name: "DAC".into(), + index: 0, + }, + ); + let selected = decide_hardware_volume(&probe, &persisted); + assert!( + matches!(selected, HardwareVolumeDecision::Selected { selection } if selection.control.name == "DAC") + ); + + persisted.insert( + route, + AlsaMixerControlId { + name: "Gone".into(), + index: 0, + }, + ); + assert!(matches!( + decide_hardware_volume(&probe, &persisted), + HardwareVolumeDecision::NeedsChoice { + reason: HardwareVolumeChoiceReason::PersistedSelectionStale, + .. + } + )); + } + + #[test] + fn ucm_must_be_unambiguous() { + let mut probe = probe(vec![ + candidate("Speaker", 0, true), + candidate("Headphone", 0, true), + ]); + probe.ucm_controls = vec![probe.candidates[1].id.clone()]; + assert!(matches!( + decide_hardware_volume(&probe, &HashMap::new()), + HardwareVolumeDecision::Selected { selection } + if selection.source == HardwareVolumeSelectionSource::Ucm + && selection.control.name == "Headphone" + )); + probe.ucm_controls = probe + .candidates + .iter() + .map(|candidate| candidate.id.clone()) + .collect(); + assert!(matches!( + decide_hardware_volume(&probe, &HashMap::new()), + HardwareVolumeDecision::NeedsChoice { + reason: HardwareVolumeChoiceReason::UcmAmbiguous, + .. + } + )); + } + + #[test] + fn rejected_ucm_hint_does_not_make_one_valid_ucm_control_ambiguous() { + let mut probe = probe(vec![ + candidate("Speaker", 0, true), + candidate("Mic Boost", 0, false), + ]); + probe.ucm_controls = probe + .candidates + .iter() + .map(|candidate| candidate.id.clone()) + .collect(); + + assert!(matches!( + decide_hardware_volume(&probe, &HashMap::new()), + HardwareVolumeDecision::Selected { selection } + if selection.source == HardwareVolumeSelectionSource::Ucm + && selection.control.name == "Speaker" + )); + } + + #[test] + fn stable_keys_prefer_serial_then_path_then_symbolic_card_and_include_route() { + assert_eq!( + select_stable_card_identity( + true, + Some("usb-Fosi_ZH3_REAL123-00".into()), + Some("pci-usb-port-1".into()), + "ZH3".into(), + ), + StableAlsaCardIdentity::ById("usb-Fosi_ZH3_REAL123-00".into()) + ); + assert_eq!( + select_stable_card_identity( + false, + Some("usb-Fosi_Fosi_Audio_ZH3-00".into()), + Some("pci-usb-port-1".into()), + "ZH3".into(), + ), + StableAlsaCardIdentity::ByPath("pci-usb-port-1".into()) + ); + assert_eq!( + select_stable_card_identity( + false, + Some("usb-iBasso_Macaron-01".into()), + None, + "Macaron".into(), + ), + StableAlsaCardIdentity::Card("Macaron".into()) + ); + + let by_id = StableAlsaRouteKey::from_parts( + &StableAlsaCardIdentity::ById("usb-Topping_D50_III_1234-00".into()), + "front", + 0, + ); + let by_path = StableAlsaRouteKey::from_parts( + &StableAlsaCardIdentity::ByPath("pci-0000:00:14.0-usb-0:2:1.0".into()), + "front", + 0, + ); + let card = + StableAlsaRouteKey::from_parts(&StableAlsaCardIdentity::Card("D50".into()), "hdmi", 1); + assert!(by_id.as_str().contains("card=by-id:")); + assert!(by_path.as_str().contains("card=by-path:")); + assert!(card.as_str().contains("card=card:D50")); + assert!(card.as_str().contains("pcm=hdmi|DEV=1")); + assert!(!card.as_str().contains("hw:0")); + } + + #[test] + fn parser_keeps_pcm_device_in_route_identity() { + assert_eq!( + parse_alsa_route("iec958:CARD=PCH,DEV=1"), + Some(ParsedAlsaRoute { + card: "PCH".into(), + pcm: "iec958".into(), + device: 1, + }) + ); + assert_eq!(mixer_ctl_name("hw:2,7"), "hw:2"); + assert_eq!(mixer_ctl_name("front:CARD=USB,DEV=0"), "hw:CARD=USB"); + } + + #[cfg(target_os = "linux")] + #[test] + fn ucm_parser_uses_canonical_alsa_value_identifiers() { + assert_eq!( + ucm_parser_identifier("PlaybackVolume"), + Some("PlaybackVolume") + ); + assert_eq!( + ucm_parser_identifier("PlaybackMasterElem"), + Some("PlaybackMixerId") + ); + assert_eq!( + ucm_parser_identifier("PlaybackMixerElem"), + Some("PlaybackMixerId") + ); + assert_eq!(ucm_parser_identifier("PlaybackSwitch"), None); + } + + #[test] + fn channel_mapping_preserves_balance_and_clamps() { + let range = HardwareVolumeRange { min: -6000, max: 0 }; + assert_eq!( + channel_targets(&[-1200, -1800], range, 0.5), + vec![-3000, -3600] + ); + assert_eq!(channel_targets(&[-1200, -1800], range, 1.0), vec![0, -600]); + assert_eq!( + channel_targets(&[-1200, -1800], range, 0.0), + vec![-6000, -6000] + ); + } + + #[test] + fn recommendation_never_resurrects_rejected_candidate() { + let mut candidates = vec![candidate("Master", 0, false), candidate("D50 III", 0, true)]; + mark_recommended(&mut candidates); + assert!(!candidates[0].recommended); + assert!(candidates[1].recommended); + } + + #[test] + fn structural_validation_rejects_unsafe_candidate_shapes_with_exact_reasons() { + let valid = CandidateFacts { + name: "D50 III", + active: true, + has_playback: true, + has_capture: false, + has_common_volume: false, + raw_range: Some(HardwareVolumeRange { min: 0, max: 100 }), + playback_channels: 2, + }; + assert_eq!(structural_rejection(valid), None); + assert_eq!( + structural_rejection(CandidateFacts { + raw_range: Some(HardwareVolumeRange { min: 7, max: 7 }), + ..valid + }), + Some(HardwareVolumeRejectionReason::InvalidRange { min: 7, max: 7 }) + ); + assert_eq!( + structural_rejection(CandidateFacts { + playback_channels: 0, + ..valid + }), + Some(HardwareVolumeRejectionReason::NoPlaybackChannels) + ); + assert_eq!( + structural_rejection(CandidateFacts { + has_common_volume: true, + ..valid + }), + Some(HardwareVolumeRejectionReason::CommonPlaybackCaptureVolume) + ); + assert_eq!( + structural_rejection(CandidateFacts { + has_playback: false, + has_capture: true, + raw_range: None, + playback_channels: 0, + ..valid + }), + Some(HardwareVolumeRejectionReason::CaptureOnly) + ); + assert_eq!( + structural_rejection(CandidateFacts { + name: "Mic Boost", + ..valid + }), + Some(HardwareVolumeRejectionReason::UnsafeInputPath) + ); + assert_eq!( + writability_rejection(Some(false)), + Some(HardwareVolumeRejectionReason::ReadOnly) + ); + assert_eq!(writability_rejection(Some(true)), None); + assert_eq!(writability_rejection(None), None); + } + + #[test] + fn raw_and_db_normalization_use_db_when_complete_and_raw_as_fallback() { + let raw_range = HardwareVolumeRange { min: 0, max: 100 }; + let db_range = HardwareVolumeRange { min: -6000, max: 0 }; + let db_values = vec![ + HardwareVolumeChannelValue { + channel: "L".into(), + raw: 80, + db_millibels: Some(-3000), + playback_switch: Some(true), + }, + HardwareVolumeChannelValue { + channel: "R".into(), + raw: 70, + db_millibels: Some(-3600), + playback_switch: Some(true), + }, + ]; + assert!((normalize_level(raw_range, Some(db_range), &db_values) - 0.5).abs() < 0.001); + + let mut broken_db = db_values.clone(); + broken_db[0].db_millibels = None; + broken_db[1].db_millibels = None; + assert!((normalize_level(raw_range, Some(db_range), &broken_db) - 0.8).abs() < 0.001); + + let mut partial_db = db_values.clone(); + partial_db[0].db_millibels = None; + assert!((normalize_level(raw_range, Some(db_range), &partial_db) - 0.8).abs() < 0.001); + } + + #[test] + fn mute_unmute_plan_and_partial_channel_switches_are_safe() { + assert_eq!( + hardware_volume_write_plan(0.0, true), + HardwareVolumeWritePlan { + playback_switch: Some(false), + write_level: false, + } + ); + assert_eq!( + hardware_volume_write_plan(0.25, true), + HardwareVolumeWritePlan { + playback_switch: Some(true), + write_level: true, + } + ); + assert_eq!( + hardware_volume_write_plan(0.0, false), + HardwareVolumeWritePlan { + playback_switch: None, + write_level: true, + } + ); + + let range = HardwareVolumeRange { min: 0, max: 100 }; + let mut values = vec![ + HardwareVolumeChannelValue { + channel: "L".into(), + raw: 40, + db_millibels: None, + playback_switch: Some(false), + }, + HardwareVolumeChannelValue { + channel: "R".into(), + raw: 60, + db_millibels: None, + playback_switch: Some(true), + }, + ]; + assert!((normalize_level(range, None, &values) - 0.6).abs() < 0.001); + values[1].playback_switch = Some(false); + assert_eq!(normalize_level(range, None, &values), 0.0); + } + + #[test] + fn local_write_feedback_is_suppressed_only_for_matching_recent_echoes() { + assert!(suppress_local_write_echo(0.42, 10, 0.420_4)); + assert!(!suppress_local_write_echo(0.42, 301, 0.42)); + assert!(!suppress_local_write_echo(0.42, 10, 0.5)); + } +} diff --git a/crates/qbz-audio/src/analyzer_tap.rs b/crates/qbz-audio/src/analyzer_tap.rs index 6461fa6db..3d0916d9b 100644 --- a/crates/qbz-audio/src/analyzer_tap.rs +++ b/crates/qbz-audio/src/analyzer_tap.rs @@ -14,23 +14,30 @@ use rodio::Source; /// Messages sent from the audio pipeline to the loudness analyzer thread. pub enum AnalyzerMessage { - /// A batch of interleaved f32 samples. - Samples(Vec), - /// A new track has started — reset analyzer state. - NewTrack { - track_id: u64, - sample_rate: u32, - channels: u16, - target_lufs: f32, - /// Shared gain atomic — analyzer writes, DynamicAmplify reads. - gain_atomic: Arc, - }, + /// A batch of interleaved f32 samples with its absolute track frame. + Samples { start_frame: u64, samples: Vec }, + /// The source reached its first audible frame. Emitting this from the tap, + /// rather than when a gapless source is queued, preserves the true track + /// boundary inside one output stream for both analyzers. + NewTrack(AnalyzerWaveformTrack), /// Seek occurred — reset accumulated samples but keep current gain. Reset, /// Shut down the analyzer thread. Shutdown, } +#[derive(Clone, Debug)] +pub struct AnalyzerWaveformTrack { + pub track_id: u64, + pub sample_rate: u32, + pub channels: u16, + pub duration_secs: u64, + pub start_frame: u64, + pub target_lufs: Option, + /// Shared gain atomic — loudness analyzer writes, DynamicAmplify reads. + pub gain_atomic: Option>, +} + const BATCH_SIZE: usize = 4096; pub struct AnalyzerTap @@ -41,6 +48,11 @@ where sender: SyncSender, enabled: Arc, buffer: Vec, + channels: u16, + sample_cursor: u64, + batch_start_frame: u64, + waveform_track: Option, + waveform_boundary_sent: bool, } impl AnalyzerTap @@ -48,22 +60,87 @@ where S: Source, { pub fn new(source: S, sender: SyncSender, enabled: Arc) -> Self { + let channels = source.channels().get(); Self { inner: source, sender, enabled, buffer: Vec::with_capacity(BATCH_SIZE), + channels, + sample_cursor: 0, + batch_start_frame: 0, + waveform_track: None, + waveform_boundary_sent: true, + } + } + + pub fn new_with_waveform( + source: S, + sender: SyncSender, + enabled: Arc, + waveform_track: Option, + start_frame: u64, + ) -> Self { + let channels = source.channels().get(); + Self { + inner: source, + sender, + enabled, + buffer: Vec::with_capacity(BATCH_SIZE), + channels, + sample_cursor: start_frame.saturating_mul(channels as u64), + batch_start_frame: start_frame, + waveform_boundary_sent: waveform_track.is_none(), + waveform_track, + } + } + + #[inline] + fn announce_waveform_track(&mut self) { + if self.waveform_boundary_sent + || (!self.enabled.load(Ordering::Relaxed) + && !crate::seek_waveform::seek_waveform_enabled()) + { + return; + } + let Some(track) = self.waveform_track.clone() else { + self.waveform_boundary_sent = true; + return; + }; + if self + .sender + .try_send(AnalyzerMessage::NewTrack(track)) + .is_ok() + { + self.waveform_boundary_sent = true; } } #[inline] fn flush_if_full(&mut self) { - if self.buffer.len() >= BATCH_SIZE { - let batch = std::mem::replace(&mut self.buffer, Vec::with_capacity(BATCH_SIZE)); - // Non-blocking send — drop batch if channel is full - let _ = self.sender.try_send(AnalyzerMessage::Samples(batch)); + // Keep every message frame-aligned. 4096 is not divisible by every + // legal channel count (notably 3), and splitting an interleaved frame + // would shift both RMS analyzers at the next batch boundary. + if self.buffer.len() >= BATCH_SIZE && self.buffer.len() % self.channels.max(1) as usize == 0 + { + self.flush_buffer(); } } + + #[inline] + fn flush_buffer(&mut self) { + if self.buffer.is_empty() { + return; + } + let batch = std::mem::replace(&mut self.buffer, Vec::with_capacity(BATCH_SIZE)); + // Non-blocking send — drop batch if channel is full. The absolute + // frame on the next successful batch preserves the timeline gap. + let _ = self.sender.try_send(AnalyzerMessage::Samples { + start_frame: self.batch_start_frame, + samples: batch, + }); + self.batch_start_frame = self.sample_cursor / self.channels.max(1) as u64; + } } impl Iterator for AnalyzerTap @@ -74,11 +151,23 @@ where #[inline] fn next(&mut self) -> Option { - let sample = self.inner.next()?; + let Some(sample) = self.inner.next() else { + self.flush_buffer(); + return None; + }; - if self.enabled.load(Ordering::Relaxed) { + self.announce_waveform_track(); + let capture = + self.enabled.load(Ordering::Relaxed) || crate::seek_waveform::seek_waveform_enabled(); + if capture { + if self.buffer.is_empty() { + self.batch_start_frame = self.sample_cursor / self.channels.max(1) as u64; + } self.buffer.push(sample); + self.sample_cursor = self.sample_cursor.saturating_add(1); self.flush_if_full(); + } else { + self.sample_cursor = self.sample_cursor.saturating_add(1); } Some(sample) @@ -114,3 +203,88 @@ where self.inner.total_duration() } } + +#[cfg(test)] +mod tests { + use super::*; + use rodio::buffer::SamplesBuffer; + use std::num::NonZero; + use std::sync::mpsc; + + fn track(track_id: u64) -> AnalyzerWaveformTrack { + AnalyzerWaveformTrack { + track_id, + sample_rate: 48_000, + channels: 2, + duration_secs: 1, + start_frame: 0, + target_lufs: None, + gain_atomic: None, + } + } + + fn source(samples: Vec) -> SamplesBuffer { + SamplesBuffer::new( + NonZero::new(2u16).unwrap(), + NonZero::new(48_000u32).unwrap(), + samples, + ) + } + + #[test] + fn boundary_is_emitted_only_when_gapless_source_becomes_audible() { + let (tx, rx) = mpsc::sync_channel(8); + let enabled = Arc::new(AtomicBool::new(true)); + let mut first = AnalyzerTap::new_with_waveform( + source(vec![0.1, 0.1]), + tx.clone(), + enabled.clone(), + Some(track(41)), + 0, + ); + assert!(matches!(first.next(), Some(_))); + assert!( + matches!(rx.recv().unwrap(), AnalyzerMessage::NewTrack(info) if info.track_id == 41) + ); + let _: Vec<_> = first.collect(); + assert!(matches!( + rx.recv().unwrap(), + AnalyzerMessage::Samples { start_frame: 0, .. } + )); + + let mut queued = + AnalyzerTap::new_with_waveform(source(vec![0.2, 0.2]), tx, enabled, Some(track(42)), 0); + assert!(rx.try_recv().is_err()); + assert!(matches!(queued.next(), Some(_))); + assert!( + matches!(rx.recv().unwrap(), AnalyzerMessage::NewTrack(info) if info.track_id == 42) + ); + } + + #[test] + fn eof_flushes_partial_batch_with_absolute_start_frame() { + let (tx, rx) = mpsc::sync_channel(8); + let enabled = Arc::new(AtomicBool::new(true)); + let tap = AnalyzerTap::new_with_waveform( + source(vec![0.3, 0.3, 0.4, 0.4]), + tx, + enabled, + Some(track(51)), + 125, + ); + let _: Vec<_> = tap.collect(); + assert!( + matches!(rx.recv().unwrap(), AnalyzerMessage::NewTrack(info) if info.track_id == 51) + ); + match rx.recv().unwrap() { + AnalyzerMessage::Samples { + start_frame, + samples, + } => { + assert_eq!(start_frame, 125); + assert_eq!(samples, vec![0.3, 0.3, 0.4, 0.4]); + } + _ => panic!("expected samples"), + } + } +} diff --git a/crates/qbz-audio/src/backend.rs b/crates/qbz-audio/src/backend.rs index fa894e0cc..1ac4ebf0d 100644 --- a/crates/qbz-audio/src/backend.rs +++ b/crates/qbz-audio/src/backend.rs @@ -42,6 +42,16 @@ pub enum AudioBackendType { /// bit-perfect ALSA-exclusive / DAC-passthrough paths. Jack, + /// WASAPI EXCLUSIVE (Windows) - the bit-perfect path. + /// - Takes the endpoint exclusively; other apps go silent + /// - No resampling: the DAC is commanded to the track's own rate + /// - Selecting a device is required, by ENDPOINT ID (Windows gives several + /// endpoints the same friendly name) + /// The trait's `create_output_stream` for this type is the SHARED CPAL one: + /// the exclusive stream bypasses the trait entirely, exactly as ALSA Direct + /// and JACK do. + WasapiExclusive, + /// System default backend (non-Linux platforms) /// - Uses CPAL default host (CoreAudio on macOS, WASAPI on Windows) /// - Automatic device selection via OS audio system @@ -308,6 +318,14 @@ impl BackendManager { backends.push(AudioBackendType::SystemDefault); } + #[cfg(windows)] + { + // Offered unconditionally: whether a given DEVICE accepts exclusive + // mode is answered per device when the stream opens, not here, and + // hiding the backend would hide the answer too. + backends.push(AudioBackendType::WasapiExclusive); + } + backends } @@ -333,6 +351,14 @@ impl BackendManager { Ok(Box::new(CpalDefaultBackend::new()?)) } } + AudioBackendType::WasapiExclusive => { + // The SHARED stream, deliberately. `create_stream_for_backend` + // tries the exclusive stream first and only reaches here when + // that is impossible - no device chosen, or the endpoint + // refusing every format - so this is the graceful fallback + // rather than the intended path. + Ok(Box::new(CpalDefaultBackend::new()?)) + } AudioBackendType::SystemDefault => { // "System": play through the OS default output via CPAL's default // host — CoreAudio/WASAPI off-Linux, the ALSA "default" PCM on Linux @@ -390,7 +416,10 @@ impl BackendManager { // (e.g. a non-default socket name, or Flatpak where the socket path // differs but the pulse shim is bridged). if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { - if std::path::Path::new(&runtime_dir).join("pipewire-0").exists() { + if std::path::Path::new(&runtime_dir) + .join("pipewire-0") + .exists() + { return true; } } @@ -453,6 +482,20 @@ impl AudioBackend for CpalDefaultBackend { .map(|desc| desc.name().to_string()) .unwrap_or_else(|_| "Default Output".to_string()); + // WINDOWS ONLY, additive. Endpoint FRIENDLY NAMES are not unique + // there: measured on a real box, three active render endpoints were + // all called "Altavoces" - the motherboard Realtek, a Bluetooth + // handsfree and a Cambridge Audio USB DAC. Matching the default by + // name would mark all three. + #[cfg(windows)] + let default_endpoint_id = default_device.id().ok().map(|i| i.1); + + // Idempotent, and this is the natural place: the first time anything + // asks what devices exist is the first time it matters that the answer + // can go stale. + #[cfg(windows)] + crate::wasapi_backend::start_hotplug_watch(); + let mut devices = Vec::new(); for device in self .host @@ -463,23 +506,92 @@ impl AudioBackend for CpalDefaultBackend { .description() .map(|desc| desc.name().to_string()) .unwrap_or_else(|_| "Unknown Device".to_string()); - let is_default = name == default_name; // On macOS, probe device capabilities via CoreAudio #[cfg(target_os = "macos")] let (supported_rates, max_rate, bus_type, is_hw) = { Self::probe_macos_device(&name) }; - #[cfg(not(target_os = "macos"))] + // The placeholder for every platform that has no probe. On + // Windows `supported_rates`/`max_rate` are SHADOWED further down + // by the exclusive-mode sweep, which is why they carry an + // underscore prefix there and nowhere else. + #[cfg(all(not(target_os = "macos"), not(windows)))] let (supported_rates, max_rate, bus_type, is_hw): ( Option>, Option, Option, bool, ) = (None, None, None, false); + #[cfg(windows)] + let (bus_type, is_hw): (Option, bool) = (None, false); + + // WINDOWS ONLY, additive: id, display and description all come + // from fields cpal already fills but this backend ignored. + // + // `name` is the endpoint ("Altavoces") and repeats across + // devices; `driver()` is the ADAPTER ("Cambridge Audio USB Audio + // 2.0") and is what tells them apart; `id()` is the stable WASAPI + // endpoint GUID. Using the name as the id meant device_filter + // folded every same-named endpoint into one entry, so a USB DAC + // sharing the name "Altavoces" with the onboard card could not be + // selected at all - and an unselectable DAC blocks bit-perfect. + #[cfg(windows)] + let (id, display, description, is_default) = { + let driver = device + .description() + .ok() + .and_then(|d| d.driver().map(str::to_string)) + .filter(|d| !d.is_empty() && d.as_str() != name.as_str()); + let endpoint_id = device.id().ok().map(|i| i.1); + let display = match &driver { + Some(d) => format!("{name} ({d})"), + None => name.clone(), + }; + let is_default = match (&endpoint_id, &default_endpoint_id) { + (Some(a), Some(b)) => a == b, + _ => name == default_name, + }; + ( + endpoint_id.unwrap_or_else(|| name.clone()), + display, + driver, + is_default, + ) + }; + #[cfg(not(windows))] + let (id, display, description, is_default) = + (name.clone(), name.clone(), None, name == default_name); + + // WINDOWS: the rates the endpoint accepts in EXCLUSIVE mode, which + // is the only list that describes what reaches the converter + // untouched. Shadows the `(None, None, ...)` placeholder above. + // + // It cannot be inferred, which is the whole reason it is measured: + // the owner's DAC accepts 44100/48000/88200/96000/192000 and + // REFUSES 176400, so the 44.1 family stops at 88.2 while the 48 + // family reaches 192. Cached per endpoint, so this costs once. + // + // `supported_rates` answers `None` when the endpoint could not + // be asked at all -- busy, gone, or COM refused -- and that must + // not reach a caller as "supports nothing", which would narrow a + // quality cap on the strength of a failed question. An endpoint + // that answers and lists nothing is reported the same way for the + // same reason: there is nothing to offer either way, and neither + // is a fact worth caching upstream. + #[cfg(windows)] + let (supported_rates, max_rate) = { + match crate::wasapi_backend::supported_rates(&id) { + Some(rates) if !rates.is_empty() => { + let top = rates.iter().copied().max(); + (Some(rates), top) + } + _ => (None, None), + } + }; devices.push(AudioDevice { - id: name.clone(), - name, - description: None, + id, + name: display, + description, is_default, max_sample_rate: max_rate, supported_sample_rates: supported_rates, @@ -560,6 +672,14 @@ impl AudioBackend for CpalDefaultBackend { .output_devices() .map_err(|e| format!("Failed to enumerate devices: {}", e))? .find(|d| { + // WINDOWS ONLY, additive: enumerate_devices publishes the + // stable endpoint GUID as the id there, because friendly + // names repeat. The name comparison stays as the fallback + // so a setting saved before this change still resolves. + #[cfg(windows)] + if d.id().map(|i| i.1 == *device_id).unwrap_or(false) { + return true; + } d.description() .map(|desc| desc.name() == device_id.as_str()) .unwrap_or(false) @@ -930,3 +1050,56 @@ impl CpalDefaultBackend { } } } + +/// The two-method surface a bit-perfect direct stream owes the player's writer +/// thread, plus the three it owes the engine around it. +/// +/// It exists so ONE writer thread serves both `AlsaDirectStream` and +/// `WasapiDirectStream` instead of two copies drifting apart. The set is +/// exactly what `playback_engine.rs` already called on the ALSA stream - no +/// method was invented for the trait, and neither implementation gains or +/// loses behaviour by having it. +pub trait DirectSink: Send + Sync + 'static { + /// Interleaved f32, blocking when the device's queue is full. That block + /// IS the back-pressure that paces the writer thread. + fn write_f32(&self, samples: &[f32]) -> Result<(), String>; + /// Play out what is queued, then return. Bounded internally. + fn drain(&self) -> Result<(), String>; + fn stop(&self) -> Result<(), String>; + fn sample_rate(&self) -> u32; + fn channels(&self) -> u16; + + /// Frames submitted to the sink but not yet heard, when the backend can + /// measure them. The visualizer uses this only as latency compensation; + /// an unsupported query keeps playback intact and falls back to the + /// bounded direct-writer pacing. + fn playback_delay_frames(&self) -> Result { + Err("this direct sink cannot measure queued playback frames".to_string()) + } + + /// Log prefix for this sink. On the trait rather than a parameter so a + /// call site cannot label an ALSA stream "WASAPI" or the reverse - which + /// is exactly what a hard-coded label at four call sites invites. + fn log_label(&self) -> &'static str; + + /// ALSA PCM identity for control-plane operations. Other direct sinks do + /// not expose an ALSA route and keep the default `None`. + fn alsa_device_id(&self) -> Option<&str> { + None + } + + /// Device-side volume, where the sink has one. + /// + /// The default REFUSES rather than silently succeeding: a sink without a + /// hardware mixer must not pretend it moved the volume. Every call site is + /// already gated on the engine's `hardware_volume` flag, which is false for + /// any such sink - permanently so for WASAPI exclusive, where the phase B + /// contract rules out ISimpleAudioVolume. + fn set_hardware_volume( + &self, + _control: &crate::alsa_hardware_volume::AlsaMixerControlId, + _volume: f32, + ) -> Result<(), String> { + Err("this sink has no hardware volume".to_string()) + } +} diff --git a/crates/qbz-audio/src/dac_capabilities.rs b/crates/qbz-audio/src/dac_capabilities.rs index 358e40c2e..1491ac04d 100644 --- a/crates/qbz-audio/src/dac_capabilities.rs +++ b/crates/qbz-audio/src/dac_capabilities.rs @@ -34,7 +34,11 @@ const FALLBACK_RATES: [u32; 6] = [44100, 48000, 88200, 96000, 176400, 192000]; /// clean per-node format capability here, so we report the common set honestly /// as nominal (matches the prior Tauri behavior; do not present it as measured). fn nominal_formats() -> Vec { - vec!["S16LE".to_string(), "S24LE".to_string(), "F32LE".to_string()] + vec![ + "S16LE".to_string(), + "S24LE".to_string(), + "F32LE".to_string(), + ] } /// Pure assembly (testable): combine detected rates + description into the DTO, @@ -64,16 +68,15 @@ fn assemble( /// Read-only — reads `/proc/asound` and runs `pw-dump`; never opens a stream. pub fn query_dac_capabilities(node_name: &str) -> DacCapabilities { // Pretty description from robust (pw-dump-backed, Slice 0) enumeration. - let description = crate::backend::BackendManager::create_backend( - crate::backend::AudioBackendType::PipeWire, - ) - .ok() - .and_then(|b| b.enumerate_devices().ok()) - .and_then(|devs| { - devs.into_iter() - .find(|d| d.id == node_name || d.name == node_name) - }) - .map(|d| d.description.unwrap_or(d.name)); + let description = + crate::backend::BackendManager::create_backend(crate::backend::AudioBackendType::PipeWire) + .ok() + .and_then(|b| b.enumerate_devices().ok()) + .and_then(|devs| { + devs.into_iter() + .find(|d| d.id == node_name || d.name == node_name) + }) + .map(|d| d.description.unwrap_or(d.name)); // Real sample rates: PipeWire sink -> ALSA card -> /proc/asound, with an // ALSA-direct fallback. Hardware-only; non-Linux gets the fallback set. @@ -92,7 +95,11 @@ mod tests { #[test] fn uses_detected_rates_when_present() { - let caps = assemble("alsa_output.usb-x", Some(vec![44100, 96000, 192000]), Some("My DAC".into())); + let caps = assemble( + "alsa_output.usb-x", + Some(vec![44100, 96000, 192000]), + Some("My DAC".into()), + ); assert_eq!(caps.sample_rates, vec![44100, 96000, 192000]); assert_eq!(caps.description.as_deref(), Some("My DAC")); assert_eq!(caps.channels, Some(2)); diff --git a/crates/qbz-audio/src/dac_probe.rs b/crates/qbz-audio/src/dac_probe.rs index 7d6f2fc1e..bb92fce8b 100644 --- a/crates/qbz-audio/src/dac_probe.rs +++ b/crates/qbz-audio/src/dac_probe.rs @@ -11,6 +11,8 @@ //! bit-perfect / sample-rate-passthrough path is untouched. use serde::{Deserialize, Serialize}; +// Every target but Windows still runs `pw-dump`. +#[cfg(not(windows))] use std::process::Command; /// The DAC's live, negotiated hardware state — what the card is REALLY clocked @@ -94,6 +96,18 @@ pub fn parse_alsa_card_for_node(json: &str, node_name: &str) -> Option { } /// Resolve the ALSA card number for a sink node by running `pw-dump`. +/// +/// W14, Windows only: `pw-dump` does not exist there, so the spawn already +/// failed into this same None. Scoped to Windows rather than `not(linux)` -- +/// a macOS or BSD box can have a real `pw-dump`, and this function is not the +/// place to decide it may not speak. +#[cfg(windows)] +fn alsa_card_for_node(_node_name: &str) -> Option { + None +} + +/// Resolve the ALSA card number for a sink node by running `pw-dump`. +#[cfg(not(windows))] fn alsa_card_for_node(node_name: &str) -> Option { let output = Command::new("pw-dump").output().ok()?; if !output.status.success() { diff --git a/crates/qbz-audio/src/device_filter.rs b/crates/qbz-audio/src/device_filter.rs index b6f6d920b..517a33016 100644 --- a/crates/qbz-audio/src/device_filter.rs +++ b/crates/qbz-audio/src/device_filter.rs @@ -40,7 +40,11 @@ pub fn is_discard_sink(display: &str) -> bool { /// description collapses the wrappers and keeps the genuinely distinct /// outputs. fn dedup_key(display: &str) -> String { - display.split_whitespace().collect::>().join(" ").to_ascii_lowercase() + display + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() } /// Preference within a display group (lower = kept). When several PCM ids @@ -147,8 +151,14 @@ mod tests { )); // null appears FIRST in the raw list but must be emitted last. let out = run(&[ - ("null", "Discard all samples (playback) or generate zero samples (capture)"), - ("default", "Default ALSA Output (currently PipeWire Media Server)"), + ( + "null", + "Discard all samples (playback) or generate zero samples (capture)", + ), + ( + "default", + "Default ALSA Output (currently PipeWire Media Server)", + ), ("front:CARD=PCH,DEV=0", "HDA Intel PCH, ALC3254 Analog"), ]); let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect(); @@ -173,12 +183,21 @@ mod tests { #[test] fn keeps_genuinely_distinct_outputs() { let out = run(&[ - ("default", "Default ALSA Output (currently PipeWire Media Server)"), + ( + "default", + "Default ALSA Output (currently PipeWire Media Server)", + ), ("front:CARD=PCH,DEV=0", "HDA Intel PCH, ALC3254 Analog"), ("iec958:CARD=PCH,DEV=1", "HDA Intel PCH, ALC3254 Digital"), ("hdmi:CARD=PCH,DEV=3", "HDA Intel PCH, HDMI 0"), - ("front:CARD=C20,DEV=0", "Cambridge Audio USB Audio 2.0, USB Audio"), - ("surround40:CARD=C20,DEV=0", "Cambridge Audio USB Audio 2.0, USB Audio"), + ( + "front:CARD=C20,DEV=0", + "Cambridge Audio USB Audio 2.0, USB Audio", + ), + ( + "surround40:CARD=C20,DEV=0", + "Cambridge Audio USB Audio 2.0, USB Audio", + ), ]); let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect(); assert_eq!( @@ -197,7 +216,10 @@ mod tests { fn passes_pipewire_node_names_through() { let out = run(&[ ("alsa_output.usb-Cambridge", "alsa_output.usb-Cambridge"), - ("alsa_output.pci-0000_00_1f.3", "alsa_output.pci-0000_00_1f.3"), + ( + "alsa_output.pci-0000_00_1f.3", + "alsa_output.pci-0000_00_1f.3", + ), ]); assert_eq!(out.len(), 2); } @@ -205,11 +227,17 @@ mod tests { #[test] fn first_seen_order_is_preserved() { let out = run(&[ - ("hw:CARD=C20,DEV=0", "Cambridge Audio USB Audio 2.0, USB Audio"), + ( + "hw:CARD=C20,DEV=0", + "Cambridge Audio USB Audio 2.0, USB Audio", + ), ("default", "Default ALSA Output"), // Better-ranked id for Cambridge appears later; it wins the group // but the group keeps its first-seen position (before Default). - ("front:CARD=C20,DEV=0", "Cambridge Audio USB Audio 2.0, USB Audio"), + ( + "front:CARD=C20,DEV=0", + "Cambridge Audio USB Audio 2.0, USB Audio", + ), ]); let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect(); assert_eq!(ids, vec!["front:CARD=C20,DEV=0", "default"]); diff --git a/crates/qbz-audio/src/device_reservation/linux.rs b/crates/qbz-audio/src/device_reservation/linux.rs index 58f2a1b57..c4fa5fa29 100644 --- a/crates/qbz-audio/src/device_reservation/linux.rs +++ b/crates/qbz-audio/src/device_reservation/linux.rs @@ -311,9 +311,8 @@ fn try_acquire_name( bus_name: &str, replace: bool, ) -> Result { - let proxy = DBusProxy::new(conn).map_err(|e| { - ReservationError::DbusError(format!("DBusProxy::new failed: {}", e)) - })?; + let proxy = DBusProxy::new(conn) + .map_err(|e| ReservationError::DbusError(format!("DBusProxy::new failed: {}", e)))?; let well_known: WellKnownName<'_> = bus_name.try_into().map_err(|e| { ReservationError::DbusError(format!("invalid bus name '{}': {}", bus_name, e)) })?; @@ -352,9 +351,8 @@ fn resolve_contention( // it twice for back-to-back property reads has been observed to cost an // extra GetAll round trip on some bus daemons; one proxy is both faster // and clearer. - let holder_proxy = open_holder_proxy(conn, bus_name, object_path).map_err(|e| { - ReservationError::DbusError(format!("Proxy::new for holder failed: {}", e)) - })?; + let holder_proxy = open_holder_proxy(conn, bus_name, object_path) + .map_err(|e| ReservationError::DbusError(format!("Proxy::new for holder failed: {}", e)))?; // Default to 0 if the holder is uncooperative or doesn't expose Priority. // Rationale: PulseAudio/PipeWire are the most common holders and run at @@ -681,10 +679,7 @@ mod tests { ] { match parse_card_index(s) { Ok(_) | Err(ReservationError::InvalidDevice(_)) => {} - Err(other) => panic!( - "unexpected error variant for {:?}: {:?}", - s, other - ), + Err(other) => panic!("unexpected error variant for {:?}: {:?}", s, other), } } } diff --git a/crates/qbz-audio/src/health.rs b/crates/qbz-audio/src/health.rs index a67f34f1a..1db1fa5e7 100644 --- a/crates/qbz-audio/src/health.rs +++ b/crates/qbz-audio/src/health.rs @@ -5,6 +5,8 @@ //! the check step can show the right `apt`/`dnf`/`pacman` remediation. None of //! this opens a stream — purely diagnostic. +// Every target but Windows still probes; see `audio_stack_health`. +#[cfg(not(windows))] use std::process::Command; /// Result of the audio-stack probes. All best-effort; a failed probe reads as @@ -32,7 +34,8 @@ impl AudioStackHealth { } } -/// True if `sh -c ""` exits 0. +/// True if `sh -c ""` exits 0. Windows never probes; see below. +#[cfg(not(windows))] fn sh_ok(probe: &str) -> bool { Command::new("sh") .arg("-c") @@ -44,6 +47,31 @@ fn sh_ok(probe: &str) -> bool { /// Run the audio-stack probes. Linux-only meaningfully; elsewhere everything /// reads false except where trivially true. +/// +/// W14: on WINDOWS ONLY the answer is all-false, returned WITHOUT running the +/// probes. `systemctl`, `pactl` and `pw-dump` do not exist there (a spawn of a +/// missing image fails before a process is made, which is harmless), but `sh` +/// DOES exist wherever Git for Windows is installed, so the five `sh -c` +/// probes really run and flash five console windows past a GUI-subsystem +/// binary that owns no console. +/// +/// Scoped to Windows, not to `not(linux)`: macOS and the BSDs can have a real +/// `pactl`/`pw-dump` from Homebrew or ports, and answering false for them +/// would be a lie this diff has no business telling. +#[cfg(windows)] +pub fn audio_stack_health() -> AudioStackHealth { + AudioStackHealth { + wireplumber_active: false, + has_pw_dump: false, + cpal_sees_pipewire: false, + has_pactl: false, + any_devices: false, + } +} + +/// Run the audio-stack probes. Linux-only meaningfully; elsewhere everything +/// reads false except where trivially true. +#[cfg(not(windows))] pub fn audio_stack_health() -> AudioStackHealth { // systemd path first; fall back to a process check so non-systemd inits // (Gentoo/OpenRC, Void/runit) don't read as "WirePlumber down". @@ -258,9 +286,15 @@ fn parse_distro(os_release: &str) -> Distro { // os-release values may be bare, double-quoted, or single-quoted // (Gentoo uses single quotes), so strip both quote styles. if let Some(v) = line.strip_prefix("ID=") { - id = v.trim().trim_matches(|c| c == '"' || c == '\'').to_lowercase(); + id = v + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_lowercase(); } else if let Some(v) = line.strip_prefix("ID_LIKE=") { - id_like = v.trim().trim_matches(|c| c == '"' || c == '\'').to_lowercase(); + id_like = v + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_lowercase(); } } let hay = format!("{} {}", id, id_like); @@ -315,7 +349,10 @@ mod tests { fn classifies_fedora_arch_suse_gentoo_void_other() { assert_eq!(parse_distro("ID=fedora\n"), Distro::Fedora); assert_eq!(parse_distro("ID=arch\n"), Distro::Arch); - assert_eq!(parse_distro("ID=opensuse-tumbleweed\nID_LIKE=\"suse opensuse\"\n"), Distro::OpenSuse); + assert_eq!( + parse_distro("ID=opensuse-tumbleweed\nID_LIKE=\"suse opensuse\"\n"), + Distro::OpenSuse + ); assert_eq!(parse_distro("ID=gentoo\n"), Distro::Gentoo); // Gentoo's real os-release single-quotes the value. assert_eq!(parse_distro("ID='gentoo'\n"), Distro::Gentoo); diff --git a/crates/qbz-audio/src/jack_backend.rs b/crates/qbz-audio/src/jack_backend.rs index 150b8bb22..941e9b0e4 100644 --- a/crates/qbz-audio/src/jack_backend.rs +++ b/crates/qbz-audio/src/jack_backend.rs @@ -80,8 +80,10 @@ impl JackStream { /// Open the JACK client, register stable stereo ports, activate, and /// auto-connect to the system's physical playback. pub fn new(channels: u16) -> Result { - let (client, _status) = Client::new("qbz", ClientOptions::NO_START_SERVER) - .map_err(|e| format!("JACK client open failed (is a JACK/pipewire-jack server running?): {e}"))?; + let (client, _status) = + Client::new("qbz", ClientOptions::NO_START_SERVER).map_err(|e| { + format!("JACK client open failed (is a JACK/pipewire-jack server running?): {e}") + })?; let sample_rate = client.sample_rate() as u32; @@ -123,7 +125,8 @@ impl JackStream { } log::info!( "[JACK] auto-connected qbz:out_FL/FR -> {} / {}", - playback[0], playback[1] + playback[0], + playback[1] ); } else { log::warn!( diff --git a/crates/qbz-audio/src/lib.rs b/crates/qbz-audio/src/lib.rs index 0cda5e3c8..c2345d080 100644 --- a/crates/qbz-audio/src/lib.rs +++ b/crates/qbz-audio/src/lib.rs @@ -26,33 +26,39 @@ //! └───────────────┘ //! ``` -pub mod backend; -#[cfg(target_os = "linux")] -pub mod pipewire_backend; #[cfg(target_os = "linux")] pub mod alsa_backend; +pub mod alsa_direct; +pub mod alsa_hardware_volume; +pub mod wasapi_direct; +/// Endpoint capabilities: the exclusive-mode rate sweep and the hotplug watch. +#[cfg(windows)] +pub mod wasapi_backend; #[cfg(target_os = "linux")] pub mod alsa_error_handler; -#[cfg(target_os = "linux")] -pub mod pulse_backend; -#[cfg(target_os = "linux")] -pub mod jack_backend; -pub mod alsa_direct; +pub mod analysis; +pub mod analyzer_tap; +pub mod backend; pub mod coreaudio_direct; pub mod dac_capabilities; pub mod dac_probe; -pub mod health; -pub mod analysis; -pub mod analyzer_tap; pub mod device_filter; pub mod device_reservation; pub mod diagnostic; pub mod dynamic_amplify; +pub mod health; +#[cfg(target_os = "linux")] +pub mod jack_backend; pub mod loudness; pub mod loudness_analyzer; pub mod loudness_cache; pub mod network_throttle; pub mod output_sinks; +#[cfg(target_os = "linux")] +pub mod pipewire_backend; +#[cfg(target_os = "linux")] +pub mod pulse_backend; +pub mod seek_waveform; pub mod settings; pub mod visualizer; @@ -63,10 +69,9 @@ pub use alsa_backend::{ resolve_stable_to_current_hw, }; pub use alsa_direct::AlsaDirectStream; -#[cfg(target_os = "linux")] -pub use jack_backend::JackStream; +pub use wasapi_direct::{Rung, LADDER}; pub use analysis::SpectralAnalyzer; -pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap}; +pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap, AnalyzerWaveformTrack}; pub use backend::{ AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendManager, BackendResult, BitPerfectMode, @@ -74,32 +79,46 @@ pub use backend::{ pub use coreaudio_direct::CoreAudioExclusiveGuard; pub use dac_capabilities::{query_dac_capabilities, DacCapabilities}; pub use dac_probe::{negotiated_active_rate, negotiated_stream_rate, NegotiatedRate}; +pub use device_reservation::{DeviceReservation, ReservationError}; +pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource}; +pub use dynamic_amplify::DynamicAmplify; pub use health::{ audio_stack_health, detect_distro, detect_init, detect_sandbox, AudioStackHealth, Distro, InitSystem, Sandbox, }; -pub use device_reservation::{DeviceReservation, ReservationError}; -pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource}; -pub use dynamic_amplify::DynamicAmplify; +#[cfg(target_os = "linux")] +pub use jack_backend::JackStream; pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData}; pub use loudness_analyzer::LoudnessAnalyzer; pub use loudness_cache::LoudnessCache; pub use output_sinks::{list_output_sinks, OutputSinkInfo}; +pub use seek_waveform::{ + register_seek_waveform_key, seek_waveform_content_key, seek_waveform_snapshot, + set_seek_waveform_enabled, SeekWaveformSnapshot, SEEK_WAVEFORM_BINS, +}; pub use settings::AudioSettings; pub use visualizer::{RingBuffer, TappedSource, VisualizerTap}; /// Stub: returns the ID unchanged on non-Linux (no ALSA normalization needed). #[cfg(not(target_os = "linux"))] -pub fn normalize_device_id_to_stable(id: &str) -> String { id.to_string() } +pub fn normalize_device_id_to_stable(id: &str) -> String { + id.to_string() +} /// Stub: no ALSA device resolution on non-Linux. #[cfg(not(target_os = "linux"))] -pub fn resolve_stable_to_current_hw(_stable: &str) -> Option { None } +pub fn resolve_stable_to_current_hw(_stable: &str) -> Option { + None +} /// Stub: no ALSA sample rate probing on non-Linux. #[cfg(not(target_os = "linux"))] -pub fn device_supports_sample_rate(_device_id: &str, _sample_rate: u32) -> Option { None } +pub fn device_supports_sample_rate(_device_id: &str, _sample_rate: u32) -> Option { + None +} /// Stub: no ALSA rate enumeration on non-Linux. #[cfg(not(target_os = "linux"))] -pub fn get_device_supported_rates(_device_id: &str) -> Option> { None } +pub fn get_device_supported_rates(_device_id: &str) -> Option> { + None +} diff --git a/crates/qbz-audio/src/loudness_analyzer.rs b/crates/qbz-audio/src/loudness_analyzer.rs index 33451c09d..fdbad3d90 100644 --- a/crates/qbz-audio/src/loudness_analyzer.rs +++ b/crates/qbz-audio/src/loudness_analyzer.rs @@ -18,6 +18,7 @@ use ebur128::{EbuR128, Mode}; use super::analyzer_tap::AnalyzerMessage; use super::loudness::db_to_linear; use super::loudness_cache::LoudnessCache; +use super::seek_waveform::{SeekWaveformAccumulator, SeekWaveformCache}; /// Maximum gain boost in dB (conservative clipping prevention) const MAX_GAIN_DB: f32 = 6.0; @@ -44,6 +45,11 @@ impl LoudnessAnalyzer { fn run(rx: Receiver, cache: Arc) { let mut state: Option = None; + // Default-off really is idle: do not touch the waveform database + // until the preference is enabled for an audible track. + let mut waveform_cache = None; + let mut waveform: Option = None; + let mut current_track = None; loop { let msg = match rx.recv() { @@ -55,50 +61,76 @@ impl LoudnessAnalyzer { }; match msg { - AnalyzerMessage::NewTrack { - track_id, - sample_rate, - channels, - target_lufs, - gain_atomic, - } => { - log::info!( - "[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)", - track_id, - sample_rate, - channels, - target_lufs - ); - - // Check cache first - if let Some(cached) = cache.get(track_id) { - let gain = compute_gain_capped(cached.gain_db); - log::info!( - "[LoudnessAnalyzer] Cache hit for track {}: {:.2} dB (source: {}), gain {:.4}", - track_id, cached.gain_db, cached.source, gain - ); - - // Set gain immediately via the atomic - gain_atomic.store(gain.to_bits(), Ordering::Relaxed); - - // Create state marked as cached — still accept samples for refinement - let mut s = - AnalyzerState::new(track_id, sample_rate, channels, target_lufs); - s.gain_atomic = Some(gain_atomic); - s.initial_done = true; - state = Some(s); - continue; - } - - // No cache — start fresh analysis - let mut s = AnalyzerState::new(track_id, sample_rate, channels, target_lufs); - s.gain_atomic = Some(gain_atomic); - state = Some(s); + AnalyzerMessage::NewTrack(track) => { + state = match (track.target_lufs, track.gain_atomic.clone()) { + (Some(target_lufs), Some(gain_atomic)) => { + log::info!( + "[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)", + track.track_id, + track.sample_rate, + track.channels, + target_lufs + ); + let mut analyzer = AnalyzerState::new( + track.track_id, + track.sample_rate, + track.channels, + target_lufs, + ); + if let Some(cached) = cache.get(track.track_id) { + let gain = compute_gain_capped(cached.gain_db); + gain_atomic.store(gain.to_bits(), Ordering::Relaxed); + analyzer.initial_done = true; + log::info!( + "[LoudnessAnalyzer] Cache hit for track {}: {:.2} dB (source: {}), gain {:.4}", + track.track_id, cached.gain_db, cached.source, gain + ); + } + analyzer.gain_atomic = Some(gain_atomic); + Some(analyzer) + } + _ => None, + }; + waveform = if super::seek_waveform::seek_waveform_enabled() { + ensure_waveform_cache(&mut waveform_cache); + Some(SeekWaveformAccumulator::begin( + track.track_id, + track.sample_rate, + track.channels, + track.duration_secs, + waveform_cache.as_ref(), + )) + } else { + None + }; + current_track = Some(track); } - AnalyzerMessage::Samples(samples) => { + AnalyzerMessage::Samples { + start_frame, + samples, + } => { if let Some(ref mut s) = state { s.feed_samples(&samples, &cache); } + if super::seek_waveform::seek_waveform_enabled() { + ensure_waveform_cache(&mut waveform_cache); + if waveform.is_none() { + waveform = current_track.as_ref().map(|track| { + SeekWaveformAccumulator::begin( + track.track_id, + track.sample_rate, + track.channels, + track.duration_secs, + waveform_cache.as_ref(), + ) + }); + } + if let Some(ref mut accumulator) = waveform { + accumulator.feed(start_frame, &samples, waveform_cache.as_ref()); + } + } else { + waveform = None; + } } AnalyzerMessage::Reset => { if let Some(ref mut s) = state { @@ -115,6 +147,14 @@ impl LoudnessAnalyzer { } } +fn ensure_waveform_cache(cache: &mut Option) { + if cache.is_none() { + *cache = SeekWaveformCache::open() + .map_err(|error| log::warn!("[SeekWaveform] {error}")) + .ok(); + } +} + struct AnalyzerState { track_id: u64, target_lufs: f32, diff --git a/crates/qbz-audio/src/loudness_cache.rs b/crates/qbz-audio/src/loudness_cache.rs index bcc91d772..1e31ab8a0 100644 --- a/crates/qbz-audio/src/loudness_cache.rs +++ b/crates/qbz-audio/src/loudness_cache.rs @@ -47,6 +47,17 @@ impl LoudnessCache { ) .map_err(|e| format!("Failed to create loudness table: {}", e))?; + // Identity beyond the Qobuz id (2026-08-28): an ISRC or a content + // fingerprint, so a local/Plex copy of the same recording can reuse + // the analysis. TABLE ONLY — the analyser and every audio path are + // untouched; `set`/`get` keep their track_id contract. Additive, + // idempotent (the duplicate-column error is the "already there" case). + let _ = conn.execute_batch("ALTER TABLE track_loudness ADD COLUMN content_key TEXT;"); + let _ = conn.execute_batch( + "CREATE INDEX IF NOT EXISTS idx_track_loudness_content_key + ON track_loudness(content_key) WHERE content_key IS NOT NULL;", + ); + log::info!("[LoudnessCache] Opened at {}", db_path.display()); Ok(Self { @@ -71,6 +82,39 @@ impl LoudnessCache { .ok() } + /// Attach a cross-source content key (ISRC or fingerprint) to an + /// existing row. No-op when the track has no analysis yet. + pub fn set_content_key(&self, track_id: u64, content_key: &str) { + if content_key.trim().is_empty() { + return; + } + if let Ok(conn) = self.conn.lock() { + let _ = conn.execute( + "UPDATE track_loudness SET content_key = ?1 WHERE track_id = ?2", + params![content_key.trim(), track_id as i64], + ); + } + } + + /// Look up cached loudness by content key (any source's copy of the + /// same recording). Newest analysis wins. + pub fn get_by_content_key(&self, content_key: &str) -> Option { + let conn = self.conn.lock().ok()?; + conn.query_row( + "SELECT gain_db, peak, source FROM track_loudness + WHERE content_key = ?1 ORDER BY created_at DESC LIMIT 1", + params![content_key.trim()], + |row| { + Ok(CachedLoudness { + gain_db: row.get::<_, f64>(0)? as f32, + peak: row.get::<_, f64>(1)? as f32, + source: row.get(2)?, + }) + }, + ) + .ok() + } + /// Store or update loudness data for a track. pub fn set(&self, track_id: u64, gain_db: f32, peak: f32, source: &str) { if let Ok(conn) = self.conn.lock() { diff --git a/crates/qbz-audio/src/output_sinks.rs b/crates/qbz-audio/src/output_sinks.rs index fcf7cac43..7229d10c0 100644 --- a/crates/qbz-audio/src/output_sinks.rs +++ b/crates/qbz-audio/src/output_sinks.rs @@ -64,10 +64,7 @@ pub fn list_output_sinks() -> Result, String> { .default_output_device() .and_then(|d| cpal_device_name(&d)); - log::debug!( - "[qbz-audio] CPAL default device: {:?}", - default_device_name - ); + log::debug!("[qbz-audio] CPAL default device: {:?}", default_device_name); let sinks: Vec = host .output_devices() diff --git a/crates/qbz-audio/src/pipewire_backend.rs b/crates/qbz-audio/src/pipewire_backend.rs index 17d2ac8ba..005b86e3f 100644 --- a/crates/qbz-audio/src/pipewire_backend.rs +++ b/crates/qbz-audio/src/pipewire_backend.rs @@ -152,9 +152,30 @@ impl PipeWireBackend { } /// Query the current PipeWire graph sample rate via pw-metadata. + /// The rate the PipeWire graph is ACTUALLY running at. + /// + /// `clock.force-rate` is the key this backend writes and the one that + /// drives the graph; `clock.rate` is the graph's NOMINAL default and does + /// not move when a rate is forced. Verifying the former by reading the + /// latter is how this check spent releases reporting "Audio may play at + /// wrong speed" over perfectly correct output — measured on a Fosi ZH3: + /// `clock.force-rate` = 96000 and `clock.rate` = 48000 at the same instant, + /// with the DAC's own display reading 96 kHz. The retry did not help + /// because the delay was never the problem. + /// + /// Falls back to `clock.rate` when nothing is forced (`force-rate` then + /// reads 0 or is absent), which is the honest answer in that case. fn get_pipewire_current_rate() -> Option { + Self::read_pw_setting("clock.force-rate") + .filter(|rate| *rate > 0) + .or_else(|| Self::read_pw_setting("clock.rate")) + } + + /// One `pw-metadata` settings read, parsed. Split out so the two keys above + /// cannot drift apart. + fn read_pw_setting(key: &str) -> Option { let output = Command::new("pw-metadata") - .args(["-n", "settings", "0", "clock.rate"]) + .args(["-n", "settings", "0", key]) .output() .ok()?; if !output.status.success() { @@ -162,8 +183,12 @@ impl PipeWireBackend { } let stdout = String::from_utf8_lossy(&output.stdout); // pw-metadata output: "Found "settings" metadata 0\nupdate: id:0 key:'clock.rate' value:'96000' type:''" + // EXACT key match: `clock.rate` is a SUBSTRING of `clock.force-rate`, so + // a `contains(key)` would happily read the forced rate while asked for + // the nominal one. pw-metadata prints `key:''`. + let needle = format!("key:'{key}'"); for line in stdout.lines() { - if line.contains("clock.rate") && line.contains("value:") { + if line.contains(&needle) && line.contains("value:") { // Extract value between single quotes after "value:" if let Some(start) = line.find("value:'") { let after = &line[start + 7..]; @@ -354,7 +379,12 @@ impl PipeWireBackend { for (idx, dev) in devices.iter().enumerate() { log::info!( " [{}] {} (id: {}, bus: {:?}, hw: {}, default: {})", - idx, dev.name, dev.id, dev.device_bus, dev.is_hardware, dev.is_default + idx, + dev.name, + dev.id, + dev.device_bus, + dev.is_hardware, + dev.is_default ); } Some(devices) @@ -925,10 +955,7 @@ impl AudioBackend for PipeWireBackend { } } } else { - log::info!( - "[PipeWire Backend] Rate verified: {}Hz", - actual_rate - ); + log::info!("[PipeWire Backend] Rate verified: {}Hz", actual_rate); } } } @@ -947,7 +974,10 @@ impl AudioBackend for PipeWireBackend { // Subprocess probes are time-bounded so a wedged daemon can never // stall stream init on the audio thread. if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { - if std::path::Path::new(&runtime_dir).join("pipewire-0").exists() { + if std::path::Path::new(&runtime_dir) + .join("pipewire-0") + .exists() + { return true; } } @@ -1021,7 +1051,11 @@ mod pw_dump_tests { fn parses_sinks_only_with_node_names_bus_and_default() { let devs = parse_pw_dump_sinks(FIXTURE); // The Audio/Source must be excluded. - assert_eq!(devs.len(), 2, "should parse exactly the two Audio/Sink nodes"); + assert_eq!( + devs.len(), + 2, + "should parse exactly the two Audio/Sink nodes" + ); let usb = devs .iter() @@ -1031,7 +1065,10 @@ mod pw_dump_tests { assert_eq!(usb.device_bus.as_deref(), Some("usb")); // read from node props assert!(usb.is_hardware); assert!(usb.is_default, "usb sink is the default per Metadata"); - assert!(usb.max_sample_rate.is_none(), "rate comes from the capability probe, not pw-dump"); + assert!( + usb.max_sample_rate.is_none(), + "rate comes from the capability probe, not pw-dump" + ); let pci = devs .iter() diff --git a/crates/qbz-audio/src/seek_waveform.rs b/crates/qbz-audio/src/seek_waveform.rs new file mode 100644 index 000000000..79c4787df --- /dev/null +++ b/crates/qbz-audio/src/seek_waveform.rs @@ -0,0 +1,402 @@ +//! Progressive, track-length waveform analysis for seek bars. +//! +//! The analyzer consumes absolute-frame chunks from `AnalyzerTap`, so dropped +//! batches and seeks leave holes instead of shifting every later bin. The UI +//! snapshot is process-global because there is one audible player; consumers +//! only clone 512 bytes when its revision changes. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use rusqlite::{params, Connection}; + +pub const SEEK_WAVEFORM_BINS: usize = 512; +const SAMPLES_PER_BIN_LIMIT: u64 = 4096; +const PUBLISH_INTERVAL: Duration = Duration::from_millis(100); + +static ENABLED: AtomicBool = AtomicBool::new(false); +static REVISION: AtomicU64 = AtomicU64::new(0); +static SNAPSHOT: OnceLock> = OnceLock::new(); +static TRACK_KEYS: OnceLock>> = OnceLock::new(); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SeekWaveformSnapshot { + pub revision: u64, + pub track_id: u64, + pub bins: [u8; SEEK_WAVEFORM_BINS], + pub analyzed_bins: u16, + pub complete: bool, +} + +impl Default for SeekWaveformSnapshot { + fn default() -> Self { + Self { + revision: 0, + track_id: 0, + bins: [0; SEEK_WAVEFORM_BINS], + analyzed_bins: 0, + complete: false, + } + } +} + +pub fn set_seek_waveform_enabled(enabled: bool) { + ENABLED.store(enabled, Ordering::Relaxed); +} + +pub fn seek_waveform_enabled() -> bool { + ENABLED.load(Ordering::Relaxed) +} + +/// Register a source-stable cache key for the next source carrying `track_id`. +/// Local files use a content fingerprint; catalog/server sources may keep the +/// namespaced id supplied by their frontend. +pub fn register_seek_waveform_key(track_id: u64, key: String) { + if key.is_empty() { + return; + } + TRACK_KEYS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .unwrap() + .insert(track_id, key); +} + +/// Stable, path-independent fingerprint for local/fully-buffered media. +/// Two independent FNV-1a lanes keep the cache key compact while avoiding an +/// extra cryptography dependency in the protected playback stack. +pub fn seek_waveform_content_key(bytes: &[u8]) -> String { + let mut a = 0xcbf29ce484222325u64; + let mut b = 0x84222325cbf29ce4u64; + for &byte in bytes { + a ^= byte as u64; + a = a.wrapping_mul(0x100000001b3); + b ^= (byte as u64).rotate_left(1); + b = b.wrapping_mul(0x100000001b3).rotate_left(5); + } + format!("content:{a:016x}{b:016x}:{}", bytes.len()) +} + +fn cache_key(track_id: u64) -> String { + TRACK_KEYS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .unwrap() + .remove(&track_id) + .unwrap_or_else(|| format!("track:{track_id}")) +} + +pub fn seek_waveform_snapshot() -> SeekWaveformSnapshot { + SNAPSHOT + .get_or_init(|| Mutex::new(SeekWaveformSnapshot::default())) + .lock() + .unwrap() + .clone() +} + +fn publish(mut snapshot: SeekWaveformSnapshot) { + snapshot.revision = REVISION.fetch_add(1, Ordering::Relaxed).wrapping_add(1); + *SNAPSHOT + .get_or_init(|| Mutex::new(SeekWaveformSnapshot::default())) + .lock() + .unwrap() = snapshot; +} + +pub(crate) struct SeekWaveformCache { + conn: Connection, +} + +impl SeekWaveformCache { + pub(crate) fn open() -> Result { + let data_dir = dirs::data_dir() + .ok_or_else(|| "Could not determine data directory".to_string())? + .join("qbz"); + std::fs::create_dir_all(&data_dir) + .map_err(|error| format!("Failed to create data directory: {error}"))?; + let path = data_dir.join("seek_waveform_cache.db"); + let conn = Connection::open(&path) + .map_err(|error| format!("Failed to open seek waveform cache: {error}"))?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + CREATE TABLE IF NOT EXISTS seek_waveforms ( + cache_key TEXT PRIMARY KEY, + bins BLOB NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + );", + ) + .map_err(|error| format!("Failed to initialize seek waveform cache: {error}"))?; + log::info!("[SeekWaveform] Cache opened at {}", path.display()); + Ok(Self { conn }) + } + + fn get(&self, key: &str) -> Option<[u8; SEEK_WAVEFORM_BINS]> { + let bytes: Vec = self + .conn + .query_row( + "SELECT bins FROM seek_waveforms WHERE cache_key=?1", + params![key], + |row| row.get(0), + ) + .ok()?; + bytes.try_into().ok() + } + + fn set(&self, key: &str, bins: &[u8; SEEK_WAVEFORM_BINS]) { + if let Err(error) = self.conn.execute( + "INSERT INTO seek_waveforms(cache_key,bins,created_at) + VALUES(?1,?2,strftime('%s','now')) + ON CONFLICT(cache_key) DO UPDATE SET + bins=excluded.bins, created_at=excluded.created_at", + params![key, bins.as_slice()], + ) { + log::warn!("[SeekWaveform] Cache write failed for {key}: {error}"); + } + } +} + +pub(crate) struct SeekWaveformAccumulator { + track_id: u64, + key: String, + channels: u16, + total_frames: u64, + sum_squares: [f64; SEEK_WAVEFORM_BINS], + counts: [u32; SEEK_WAVEFORM_BINS], + analyzed_bins: u16, + complete: bool, + cached: bool, + last_publish: Instant, +} + +impl SeekWaveformAccumulator { + pub(crate) fn begin( + track_id: u64, + sample_rate: u32, + channels: u16, + duration_secs: u64, + cache: Option<&SeekWaveformCache>, + ) -> Self { + let key = cache_key(track_id); + if let Some(bins) = cache.and_then(|cache| cache.get(&key)) { + publish(SeekWaveformSnapshot { + track_id, + bins, + analyzed_bins: SEEK_WAVEFORM_BINS as u16, + complete: true, + ..SeekWaveformSnapshot::default() + }); + return Self { + track_id, + key, + channels, + total_frames: duration_secs.saturating_mul(sample_rate as u64), + sum_squares: [0.0; SEEK_WAVEFORM_BINS], + counts: [0; SEEK_WAVEFORM_BINS], + analyzed_bins: SEEK_WAVEFORM_BINS as u16, + complete: true, + cached: true, + last_publish: Instant::now(), + }; + } + + publish(SeekWaveformSnapshot { + track_id, + ..SeekWaveformSnapshot::default() + }); + Self { + track_id, + key, + channels: channels.max(1), + total_frames: duration_secs.saturating_mul(sample_rate as u64), + sum_squares: [0.0; SEEK_WAVEFORM_BINS], + counts: [0; SEEK_WAVEFORM_BINS], + analyzed_bins: 0, + complete: false, + cached: false, + last_publish: Instant::now(), + } + } + + pub(crate) fn feed( + &mut self, + start_frame: u64, + samples: &[f32], + cache: Option<&SeekWaveformCache>, + ) { + if self.complete || self.cached || self.total_frames == 0 || samples.is_empty() { + return; + } + let channels = self.channels as usize; + let frames = samples.len() / channels; + if frames == 0 { + return; + } + let frames_per_bin = self.total_frames.div_ceil(SEEK_WAVEFORM_BINS as u64); + let stride = frames_per_bin.div_ceil(SAMPLES_PER_BIN_LIMIT).max(1); + let mut furthest = start_frame; + + for frame_offset in 0..frames { + let absolute = start_frame.saturating_add(frame_offset as u64); + if absolute >= self.total_frames || absolute % stride != 0 { + continue; + } + let bin = ((absolute as u128 * SEEK_WAVEFORM_BINS as u128) / self.total_frames as u128) + as usize; + let base = frame_offset * channels; + let frame_square = samples[base..base + channels] + .iter() + .copied() + .map(|sample| (sample as f64) * (sample as f64)) + .sum::() + / channels as f64; + self.sum_squares[bin] += frame_square; + if self.counts[bin] == 0 { + self.analyzed_bins = self.analyzed_bins.saturating_add(1); + } + self.counts[bin] = self.counts[bin].saturating_add(1); + furthest = absolute; + } + + if self.analyzed_bins as usize == SEEK_WAVEFORM_BINS { + self.finish(cache); + } else if furthest.saturating_add(frames_per_bin.max(frames as u64)) >= self.total_frames { + // A resumed track, seek, or dropped channel batch can reach EOF + // with holes. Publish those known bins, but never mark or cache a + // partial document as the full waveform. + self.publish_progressive(); + } else if self.last_publish.elapsed() >= PUBLISH_INTERVAL { + self.publish_progressive(); + self.last_publish = Instant::now(); + } + } + + fn rms_bins(&self, normalize_complete: bool) -> [f32; SEEK_WAVEFORM_BINS] { + let mut rms = [0.0f32; SEEK_WAVEFORM_BINS]; + let mut peak = 0.0f32; + for (index, out) in rms.iter_mut().enumerate() { + if self.counts[index] > 0 { + *out = (self.sum_squares[index] / self.counts[index] as f64).sqrt() as f32; + peak = peak.max(*out); + } + } + let reference = if normalize_complete && peak > f32::EPSILON { + peak + } else { + // A stable progressive reference prevents the already-painted + // shape from breathing when a later, louder passage arrives. + 0.25 + }; + for value in &mut rms { + *value = (*value / reference).clamp(0.0, 1.0); + } + rms + } + + fn quantized(&self, normalize_complete: bool) -> [u8; SEEK_WAVEFORM_BINS] { + self.rms_bins(normalize_complete) + .map(|value| (value * 255.0).round() as u8) + } + + fn publish_progressive(&self) { + publish(SeekWaveformSnapshot { + track_id: self.track_id, + bins: self.quantized(false), + analyzed_bins: self.analyzed_bins, + complete: false, + ..SeekWaveformSnapshot::default() + }); + } + + fn finish(&mut self, cache: Option<&SeekWaveformCache>) { + if self.complete { + return; + } + self.complete = true; + let bins = self.quantized(true); + if let Some(cache) = cache { + cache.set(&self.key, &bins); + } + publish(SeekWaveformSnapshot { + track_id: self.track_id, + bins, + analyzed_bins: self.analyzed_bins, + complete: true, + ..SeekWaveformSnapshot::default() + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn absolute_frames_leave_dropped_chunk_holes_instead_of_shifting_bins() { + let mut acc = SeekWaveformAccumulator::begin(7, 512, 1, 1, None); + acc.feed(0, &[0.5; 128], None); + acc.feed(384, &[0.5; 128], None); + assert!(acc.counts[..128].iter().all(|count| *count > 0)); + assert!(acc.counts[128..384].iter().all(|count| *count == 0)); + assert!(acc.counts[384..].iter().all(|count| *count > 0)); + } + + #[test] + fn stereo_channels_contribute_to_frame_rms() { + let mut acc = SeekWaveformAccumulator::begin(8, 512, 2, 1, None); + let mut samples = Vec::with_capacity(1024); + for _ in 0..512 { + samples.extend_from_slice(&[0.5, 0.5]); + } + acc.feed(0, &samples, None); + assert!(acc + .sum_squares + .iter() + .all(|value| (*value - 0.25).abs() < 1e-9)); + assert!(acc.complete); + } + + #[test] + fn stereo_energy_does_not_cancel_when_channels_are_out_of_phase() { + let mut acc = SeekWaveformAccumulator::begin(10, 512, 2, 1, None); + let mut samples = Vec::with_capacity(1024); + for _ in 0..512 { + samples.extend_from_slice(&[0.5, -0.5]); + } + acc.feed(0, &samples, None); + assert!(acc + .sum_squares + .iter() + .all(|value| (*value - 0.25).abs() < 1e-9)); + } + + #[test] + fn resumed_tail_is_not_cached_as_a_complete_waveform() { + let mut acc = SeekWaveformAccumulator::begin(11, 512, 1, 1, None); + acc.feed(256, &[0.5; 256], None); + assert!(!acc.complete); + assert_eq!(acc.analyzed_bins, 256); + } + + #[test] + fn progressive_reference_does_not_rescale_earlier_bins() { + let mut acc = SeekWaveformAccumulator::begin(9, 512, 1, 1, None); + acc.feed(0, &[0.125; 128], None); + let before = acc.quantized(false)[0]; + acc.feed(128, &[0.9; 128], None); + assert_eq!(acc.quantized(false)[0], before); + } + + #[test] + fn content_key_is_path_independent_and_content_sensitive() { + assert_eq!( + seek_waveform_content_key(b"same audio bytes"), + seek_waveform_content_key(b"same audio bytes") + ); + assert_ne!( + seek_waveform_content_key(b"same audio bytes"), + seek_waveform_content_key(b"different audio bytes") + ); + } +} diff --git a/crates/qbz-audio/src/settings.rs b/crates/qbz-audio/src/settings.rs index d2193fdc6..7d9b8bf0d 100644 --- a/crates/qbz-audio/src/settings.rs +++ b/crates/qbz-audio/src/settings.rs @@ -5,6 +5,7 @@ //! NOTE: Tauri command wrappers remain in qbz-nix. This module contains only //! the core types and persistence logic. +use crate::alsa_hardware_volume::{stable_alsa_route_key, AlsaMixerControlId, StableAlsaRouteKey}; use crate::{AlsaPlugin, AudioBackendType}; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; @@ -21,6 +22,10 @@ pub struct AudioSettings { pub backend_type: Option, // None = auto-detect pub alsa_plugin: Option, // Only used when backend is ALSA pub alsa_hardware_volume: bool, // Use ALSA mixer for volume (only with hw: devices) + /// Exact mixer identity per physical card + PCM route. The empty map is + /// the backward-compatible value for legacy settings databases. + #[serde(default)] + pub alsa_hardware_volume_controls: HashMap, /// When true, uncached tracks start playing via streaming instead of waiting for full download pub stream_first_track: bool, /// Initial buffer size in seconds before starting streaming playback (1-10, default 3) @@ -86,6 +91,19 @@ fn default_dsd_mode() -> String { "convert".to_string() } +impl AudioSettings { + /// Exact control selected for the currently configured physical PCM route. + /// A bare `alsa_hardware_volume=true` from an older database intentionally + /// returns `None` until the shared probe resolves and persists a choice. + pub fn selected_alsa_hardware_volume_control(&self) -> Option { + if !self.alsa_hardware_volume { + return None; + } + let route = stable_alsa_route_key(self.output_device.as_deref()?).ok()?; + self.alsa_hardware_volume_controls.get(&route).cloned() + } +} + impl Default for AudioSettings { fn default() -> Self { Self { @@ -106,14 +124,15 @@ impl Default for AudioSettings { // fixes, and "System" is the app-like default audiophiles override. backend_type: Some(AudioBackendType::default()), alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect) - alsa_hardware_volume: false, // Disabled by default (maximum compatibility) - stream_first_track: true, // On by default (opt-out) - stream_buffer_seconds: 2, // 2 seconds initial buffer + alsa_hardware_volume: false, // Disabled by default (maximum compatibility) + alsa_hardware_volume_controls: HashMap::new(), + stream_first_track: true, // On by default (opt-out) + stream_buffer_seconds: 2, // 2 seconds initial buffer streaming_only: false, // Disabled by default (cache tracks for instant replay) limit_quality_to_device: false, // Opt-in. Off since 1.1.9 (#45); wired to the read-only probe in #638 fix 3 - device_max_sample_rate: None, // Set when device is selected + device_max_sample_rate: None, // Set when device is selected device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit) - normalization_enabled: false, // Off by default — preserves bit-perfect pipeline + normalization_enabled: false, // Off by default — preserves bit-perfect pipeline normalization_target_lufs: -14.0, // Spotify/YouTube standard gapless_enabled: true, // On by default — works for same-format tracks on all backends pw_force_bitperfect: false, // Off by default — experimental PipeWire feature @@ -122,7 +141,7 @@ impl Default for AudioSettings { skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups allow_quality_fallback: false, // Off by default — fail rather than silently downgrade reserve_dac_while_running: false, // Off by default — opt-in DAC reservation (Lifetime B) - dsd_mode: default_dsd_mode(), // "convert" — safe on every DAC + dsd_mode: default_dsd_mode(), // "convert" — safe on every DAC } } } @@ -153,6 +172,7 @@ impl AudioSettingsStore { backend_type TEXT, alsa_plugin TEXT, alsa_hardware_volume INTEGER NOT NULL DEFAULT 0, + alsa_hardware_volume_controls TEXT NOT NULL DEFAULT '{}', stream_first_track INTEGER NOT NULL DEFAULT 1, stream_buffer_seconds INTEGER NOT NULL DEFAULT 2 );", @@ -169,6 +189,10 @@ impl AudioSettingsStore { "ALTER TABLE audio_settings ADD COLUMN alsa_hardware_volume INTEGER DEFAULT 0", [], ); + let _ = conn.execute( + "ALTER TABLE audio_settings ADD COLUMN alsa_hardware_volume_controls TEXT DEFAULT '{}'", + [], + ); let _ = conn.execute( "ALTER TABLE audio_settings ADD COLUMN stream_first_track INTEGER DEFAULT 1", [], @@ -298,7 +322,7 @@ impl AudioSettingsStore { pub fn get_settings(&self) -> Result { self.conn .query_row( - "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running, dsd_mode FROM audio_settings WHERE id = 1", + "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running, dsd_mode, alsa_hardware_volume_controls FROM audio_settings WHERE id = 1", [], |row| { // Parse backend_type from JSON string @@ -317,6 +341,14 @@ impl AudioSettingsStore { .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); + let alsa_hardware_volume_controls: HashMap< + StableAlsaRouteKey, + AlsaMixerControlId, + > = row + .get::<_, Option>(23)? + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + Ok(AudioSettings { output_device: row.get(0)?, exclusive_mode: row.get::<_, i64>(1)? != 0, @@ -325,6 +357,7 @@ impl AudioSettingsStore { backend_type, alsa_plugin, alsa_hardware_volume: row.get::<_, Option>(6)?.unwrap_or(0) != 0, + alsa_hardware_volume_controls, stream_first_track: row.get::<_, Option>(7)?.unwrap_or(0) != 0, stream_buffer_seconds: row.get::<_, Option>(8)?.unwrap_or(3) as u8, streaming_only: row.get::<_, Option>(9)?.unwrap_or(0) != 0, @@ -453,6 +486,84 @@ impl AudioSettingsStore { Ok(()) } + /// Persist an exact control for one stable route without changing whether + /// hardware volume is enabled. Used by the explicit "Change control" flow. + pub fn set_alsa_hardware_volume_control( + &self, + route: StableAlsaRouteKey, + control: AlsaMixerControlId, + ) -> Result<(), String> { + let mut controls = self.get_settings()?.alsa_hardware_volume_controls; + controls.insert(route, control); + let json = serde_json::to_string(&controls) + .map_err(|error| format!("Failed to serialize ALSA mixer controls: {error}"))?; + self.conn + .execute( + "UPDATE audio_settings SET alsa_hardware_volume_controls = ?1 WHERE id = 1", + params![json], + ) + .map_err(|error| format!("Failed to set ALSA mixer control: {error}"))?; + Ok(()) + } + + /// Replace the route-scoped exact-control map (settings bundle import). + /// This stores identities only; it never probes or writes a mixer. The + /// normal startup/reload reconciliation validates the active route before + /// hardware volume can be used. + pub fn set_alsa_hardware_volume_controls( + &self, + controls: HashMap, + ) -> Result<(), String> { + let json = serde_json::to_string(&controls) + .map_err(|error| format!("Failed to serialize ALSA mixer controls: {error}"))?; + self.conn + .execute( + "UPDATE audio_settings SET alsa_hardware_volume_controls = ?1 WHERE id = 1", + params![json], + ) + .map_err(|error| format!("Failed to replace ALSA mixer controls: {error}"))?; + Ok(()) + } + + /// Remove the exact control for one route. If that route is currently in + /// use, callers must disable hardware volume first so no enabled setting + /// can remain without an identity. + pub fn clear_alsa_hardware_volume_control( + &self, + route: &StableAlsaRouteKey, + ) -> Result<(), String> { + let mut controls = self.get_settings()?.alsa_hardware_volume_controls; + controls.remove(route); + let json = serde_json::to_string(&controls) + .map_err(|error| format!("Failed to serialize ALSA mixer controls: {error}"))?; + self.conn + .execute( + "UPDATE audio_settings SET alsa_hardware_volume_controls = ?1 WHERE id = 1", + params![json], + ) + .map_err(|error| format!("Failed to clear ALSA mixer control: {error}"))?; + Ok(()) + } + + /// Atomically persist the exact selection and enable the preference. + pub fn enable_alsa_hardware_volume( + &self, + route: StableAlsaRouteKey, + control: AlsaMixerControlId, + ) -> Result<(), String> { + let mut controls = self.get_settings()?.alsa_hardware_volume_controls; + controls.insert(route, control); + let json = serde_json::to_string(&controls) + .map_err(|error| format!("Failed to serialize ALSA mixer controls: {error}"))?; + self.conn + .execute( + "UPDATE audio_settings SET alsa_hardware_volume_controls = ?1, alsa_hardware_volume = 1 WHERE id = 1", + params![json], + ) + .map_err(|error| format!("Failed to enable ALSA hardware volume: {error}"))?; + Ok(()) + } + pub fn set_stream_first_track(&self, enabled: bool) -> Result<(), String> { self.conn .execute( @@ -701,6 +812,8 @@ impl AudioSettingsStore { // Serialize per-device limits (empty on reset) let limits_json = serde_json::to_string(&defaults.device_sample_rate_limits) .map_err(|e| format!("Failed to serialize device sample rate limits: {}", e))?; + let mixer_controls_json = serde_json::to_string(&defaults.alsa_hardware_volume_controls) + .map_err(|e| format!("Failed to serialize ALSA mixer controls: {e}"))?; self.conn .execute( @@ -725,7 +838,8 @@ impl AudioSettingsStore { sync_audio_on_startup = ?18, skip_sink_switch = ?19, allow_quality_fallback = ?20, - reserve_dac_while_running = ?21 + reserve_dac_while_running = ?21, + alsa_hardware_volume_controls = ?22 WHERE id = 1", params![ defaults.output_device, @@ -749,6 +863,7 @@ impl AudioSettingsStore { defaults.skip_sink_switch as i64, defaults.allow_quality_fallback as i64, defaults.reserve_dac_while_running as i64, + mixer_controls_json, ], ) .map_err(|e| format!("Failed to reset audio settings: {}", e))?; @@ -909,11 +1024,12 @@ mod tests { fn stream_buffer_seconds_clamps_to_valid_range() { let (dir, store) = fresh_store("stream-buffer-clamp"); - store - .set_stream_buffer_seconds(0) - .expect("set low buffer"); + store.set_stream_buffer_seconds(0).expect("set low buffer"); assert_eq!( - store.get_settings().expect("get settings").stream_buffer_seconds, + store + .get_settings() + .expect("get settings") + .stream_buffer_seconds, 1 ); @@ -921,7 +1037,10 @@ mod tests { .set_stream_buffer_seconds(99) .expect("set high buffer"); assert_eq!( - store.get_settings().expect("get settings").stream_buffer_seconds, + store + .get_settings() + .expect("get settings") + .stream_buffer_seconds, 10 ); let _ = std::fs::remove_dir_all(dir); @@ -963,6 +1082,7 @@ mod tests { assert_eq!(settings.quality_fallback_behavior, "always_skip"); assert_eq!(settings.output_device, None); assert!(!settings.dac_passthrough); + assert!(settings.alsa_hardware_volume_controls.is_empty()); let _ = std::fs::remove_dir_all(dir); } @@ -971,6 +1091,9 @@ mod tests { let dir = unique_test_dir("persist-new-fields"); { let store = AudioSettingsStore::new_at(&dir).expect("open store"); + store + .set_output_device(Some("hw:CARD=TestDAC,DEV=0")) + .expect("set output device"); store .set_sync_audio_on_startup(true) .expect("set sync flag"); @@ -991,6 +1114,10 @@ mod tests { let reopened = AudioSettingsStore::new_at(&dir).expect("reopen store"); let settings = reopened.get_settings().expect("get settings"); + assert_eq!( + settings.output_device.as_deref(), + Some("hw:CARD=TestDAC,DEV=0") + ); assert!(settings.sync_audio_on_startup); assert_eq!(settings.quality_fallback_behavior, "always_fallback"); assert!(settings.reserve_dac_while_running); @@ -1028,5 +1155,88 @@ mod tests { serde_json::from_str(legacy).expect("legacy JSON should deserialize"); assert!(!settings.reserve_dac_while_running); + assert!(settings.alsa_hardware_volume_controls.is_empty()); + } + + #[test] + fn exact_alsa_mixer_controls_round_trip_by_route() { + let (dir, store) = fresh_store("alsa-mixer-controls"); + let route_a: StableAlsaRouteKey = + serde_json::from_str("\"alsa-route-v1|card=by-id:usb-D50-00|pcm=front|DEV=0\"") + .expect("route A"); + let route_b: StableAlsaRouteKey = + serde_json::from_str("\"alsa-route-v1|card=by-id:usb-D50-00|pcm=iec958|DEV=1\"") + .expect("route B"); + let control_a = AlsaMixerControlId { + name: "D50 III".to_string(), + index: 0, + }; + let control_b = AlsaMixerControlId { + name: "Digital".to_string(), + index: 1, + }; + + store + .set_alsa_hardware_volume_control(route_a.clone(), control_a.clone()) + .expect("set route A"); + store + .set_alsa_hardware_volume_control(route_b.clone(), control_b.clone()) + .expect("set route B"); + + let settings = store.get_settings().expect("read settings"); + assert_eq!( + settings.alsa_hardware_volume_controls.get(&route_a), + Some(&control_a) + ); + assert_eq!( + settings.alsa_hardware_volume_controls.get(&route_b), + Some(&control_b) + ); + assert_ne!(route_a, route_b, "PCM DEV is part of the persisted key"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn database_without_mixer_control_map_migrates_to_empty() { + let dir = unique_test_dir("legacy-alsa-mixer-column"); + { + let store = AudioSettingsStore::new_at(&dir).expect("create current store"); + store + .conn + .execute( + "ALTER TABLE audio_settings DROP COLUMN alsa_hardware_volume_controls", + [], + ) + .expect("simulate pre-map database"); + } + + let migrated = AudioSettingsStore::new_at(&dir).expect("migrate old store"); + assert!(migrated + .get_settings() + .expect("read migrated settings") + .alsa_hardware_volume_controls + .is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn reset_clears_route_scoped_alsa_mixer_controls() { + let (dir, store) = fresh_store("reset-alsa-mixer-controls"); + let route: StableAlsaRouteKey = + serde_json::from_str("\"alsa-route-v1|card=card:D50|pcm=hw|DEV=0\"").expect("route"); + store + .enable_alsa_hardware_volume( + route, + AlsaMixerControlId { + name: "D50 III".to_string(), + index: 0, + }, + ) + .expect("enable exact control"); + + let reset = store.reset_all().expect("reset"); + assert!(!reset.alsa_hardware_volume); + assert!(reset.alsa_hardware_volume_controls.is_empty()); + let _ = std::fs::remove_dir_all(dir); } } diff --git a/crates/qbz-audio/src/visualizer/mod.rs b/crates/qbz-audio/src/visualizer/mod.rs index c7992d703..abcca68c3 100644 --- a/crates/qbz-audio/src/visualizer/mod.rs +++ b/crates/qbz-audio/src/visualizer/mod.rs @@ -12,13 +12,15 @@ mod processor; mod ring_buffer; +mod scope; mod tapped_source; pub use processor::{spawn_visualizer_thread, VizFrame, VizSink}; pub use ring_buffer::RingBuffer; +pub use scope::{GONIOMETER_BIT, OSCILLOSCOPE_BIT}; pub use tapped_source::TappedSource; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::sync::Arc; /// Number of frequency bins to send to frontend @@ -31,6 +33,14 @@ pub const FFT_SIZE: usize = 4096; /// Target frames per second for visualization updates pub const TARGET_FPS: u64 = 30; +/// History retained for output-playhead alignment. +/// +/// ALSA Direct currently allows up to 500 ms of queued PCM at high sample +/// rates. 1,048,576 interleaved samples cover that delay plus the FFT window +/// for stereo PCM through 768 kHz, while keeping the audio-thread write path +/// allocation-free. +const RING_HISTORY_SAMPLES: usize = 1_048_576; + /// Shared state for visualization that can be passed to the audio thread #[derive(Clone)] pub struct VisualizerTap { @@ -47,16 +57,25 @@ pub struct VisualizerTap { pub paused: Arc, /// Current sample rate pub sample_rate: Arc, + /// Requested real-time scope producers. Frontends update this off the + /// audio thread; zero keeps the added DSP completely idle. + pub scope_mask: Arc, + /// Number of interleaved samples queued ahead of the audible playhead. + /// Zero for callback-paced/shared backends; ALSA Direct updates it from + /// `snd_pcm_delay` without changing the PCM stream. + playback_lag_samples: Arc, } impl VisualizerTap { /// Create a new tap pub fn new() -> Self { Self { - ring_buffer: Arc::new(RingBuffer::new(FFT_SIZE * 2)), + ring_buffer: Arc::new(RingBuffer::new(RING_HISTORY_SAMPLES)), enabled: Arc::new(AtomicBool::new(false)), paused: Arc::new(AtomicBool::new(false)), sample_rate: Arc::new(AtomicU32::new(44100)), + scope_mask: Arc::new(AtomicU32::new(0)), + playback_lag_samples: Arc::new(AtomicUsize::new(0)), } } @@ -97,6 +116,34 @@ impl VisualizerTap { pub fn set_sample_rate(&self, rate: u32) { self.sample_rate.store(rate, Ordering::Relaxed); } + + /// Select the scope DSP that the visible frontend surfaces consume. + pub fn set_scope_mask(&self, mask: u32) { + self.scope_mask.store(mask, Ordering::Relaxed); + } + + /// Align snapshots with a direct backend's audible playhead. + /// + /// `queued_frames` comes from the output API, so convert it to the + /// interleaved-sample units used by [`RingBuffer`]. Saturation keeps a + /// malformed device report from wrapping; the ring clamps to retained + /// history when the snapshot is taken. + pub fn set_output_delay_frames(&self, queued_frames: u64, channels: u16) { + let samples = queued_frames.saturating_mul(u64::from(channels)); + self.playback_lag_samples.store( + usize::try_from(samples).unwrap_or(usize::MAX), + Ordering::Relaxed, + ); + } + + /// Return callback-paced/shared backends to the newest captured window. + pub fn clear_output_delay(&self) { + self.playback_lag_samples.store(0, Ordering::Relaxed); + } + + pub(crate) fn output_delay_samples(&self) -> usize { + self.playback_lag_samples.load(Ordering::Relaxed) + } } impl Default for VisualizerTap { diff --git a/crates/qbz-audio/src/visualizer/processor.rs b/crates/qbz-audio/src/visualizer/processor.rs index b214589d3..160a9c7a4 100644 --- a/crates/qbz-audio/src/visualizer/processor.rs +++ b/crates/qbz-audio/src/visualizer/processor.rs @@ -22,6 +22,7 @@ use spectrum_analyzer::{samples_fft_to_spectrum, FrequencyLimit}; use crate::SpectralAnalyzer; +use super::scope::{ScopeProcessor, GONIOMETER_BIT, OSCILLOSCOPE_BIT}; use super::{VisualizerTap, FFT_SIZE, NUM_BARS, TARGET_FPS}; /// Number of energy bands for the Energy Bands visualizer @@ -56,6 +57,13 @@ pub enum VizFrame { Energy5([f32; 5]), /// A single transient intensity, submitted only on detection (`viz:transient`). Transient1(f32), + /// 256 packed mid/side points plus the stereo correlation coefficient. + Goniometer { + points: Box<[f32; 512]>, + correlation: f32, + }, + /// 512 pitch-locked mono samples. + Oscilloscope(Box<[f32; 512]>), } /// Frontend-agnostic consumer of visualization frames. Implemented by the Tauri @@ -113,6 +121,7 @@ fn run_fft_loop(tap: VisualizerTap, sink: Arc) { SPECTRAL_UPDATE_RATE_HZ, SPECTRAL_SMOOTHING, ); + let mut scopes = ScopeProcessor::default(); // Transient detection state let mut prev_rms = 0.0f32; @@ -144,7 +153,25 @@ fn run_fft_loop(tap: VisualizerTap, sink: Arc) { let sample_rate = tap.sample_rate.load(Ordering::Relaxed); // Get samples from ring buffer - tap.ring_buffer.snapshot(&mut samples); + tap.ring_buffer + .snapshot_behind(&mut samples, tap.output_delay_samples()); + + // The scope pipeline is explicitly requested by the visible + // frontend destination. Its FIR and pitch FFT remain completely + // idle for the existing bars/waveform/energy modes. + let scope_mask = tap.scope_mask.load(Ordering::Relaxed); + if scope_mask & GONIOMETER_BIT != 0 { + let (points, correlation) = scopes.goniometer(&samples, sample_rate); + sink.submit(VizFrame::Goniometer { + points, + correlation, + }); + } + if scope_mask & OSCILLOSCOPE_BIT != 0 { + sink.submit(VizFrame::Oscilloscope( + scopes.oscilloscope(&samples, sample_rate), + )); + } // Compact, progressive spectrogram bands for the Spectral Ribbon, // gated on the analyzer's own update cadence. diff --git a/crates/qbz-audio/src/visualizer/ring_buffer.rs b/crates/qbz-audio/src/visualizer/ring_buffer.rs index d1ce12210..51b34c167 100644 --- a/crates/qbz-audio/src/visualizer/ring_buffer.rs +++ b/crates/qbz-audio/src/visualizer/ring_buffer.rs @@ -63,18 +63,40 @@ impl RingBuffer { /// Copies the last `dest.len()` samples into the provided slice. /// The samples are ordered from oldest to newest. pub fn snapshot(&self, dest: &mut [f32]) { + self.snapshot_behind(dest, 0); + } + + /// Take a snapshot ending `lag_samples` behind the producer head. + /// + /// Direct output backends can queue a substantial amount of PCM ahead of + /// the hardware playhead. The visualizer still taps the exact source + /// samples, but asks for a historical window ending at the audible + /// position instead of the newest queued sample. A request older than the + /// retained ring is clamped to the oldest complete window; before enough + /// samples exist, the missing prefix remains silent. + pub fn snapshot_behind(&self, dest: &mut [f32], lag_samples: usize) { + dest.fill(0.0); + if dest.is_empty() || self.size == 0 { + return; + } + let write_pos = self.write_pos.load(Ordering::Relaxed); - let len = dest.len().min(self.size); + let window_len = dest.len().min(self.size); + let lag_samples = lag_samples + .min(self.size.saturating_sub(window_len)) + .min(write_pos); + let end_pos = write_pos - lag_samples; + let available = end_pos.min(window_len); + let dest_start = dest.len() - available; + let source_start = end_pos - available; // Safety: We're the only reader, and the writer only increments write_pos unsafe { let buffer = &*self.buffer.get(); - for i in 0..len { - // Calculate the index for the i-th oldest sample - // We want samples from (write_pos - len) to (write_pos - 1) - let idx = (write_pos + self.size - len + i) % self.size; - dest[i] = buffer[idx]; + for i in 0..available { + let idx = (source_start + i) % self.size; + dest[dest_start + i] = buffer[idx]; } } } @@ -123,4 +145,42 @@ mod tests { // Should get the last 4 samples: 6, 7, 8, 9 assert_eq!(snapshot, [6.0, 7.0, 8.0, 9.0]); } + + #[test] + fn snapshot_behind_reads_a_historical_window() { + let buffer = RingBuffer::new(16); + for i in 0..10 { + buffer.push(i as f32); + } + + let mut snapshot = [0.0f32; 4]; + buffer.snapshot_behind(&mut snapshot, 3); + + assert_eq!(snapshot, [3.0, 4.0, 5.0, 6.0]); + } + + #[test] + fn snapshot_behind_zero_fills_history_not_yet_captured() { + let buffer = RingBuffer::new(16); + buffer.push(1.0); + buffer.push(2.0); + + let mut snapshot = [9.0f32; 4]; + buffer.snapshot_behind(&mut snapshot, 1); + + assert_eq!(snapshot, [0.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn snapshot_behind_never_reads_past_retained_history() { + let buffer = RingBuffer::new(8); + for i in 0..12 { + buffer.push(i as f32); + } + + let mut snapshot = [0.0f32; 4]; + buffer.snapshot_behind(&mut snapshot, usize::MAX); + + assert_eq!(snapshot, [4.0, 5.0, 6.0, 7.0]); + } } diff --git a/crates/qbz-audio/src/visualizer/scope.rs b/crates/qbz-audio/src/visualizer/scope.rs new file mode 100644 index 000000000..a3b31834d --- /dev/null +++ b/crates/qbz-audio/src/visualizer/scope.rs @@ -0,0 +1,375 @@ +//! Real-time scope DSP fed by the visualizer ring-buffer snapshot. +//! +//! The processor is downstream of the passive audio tap. It never changes the +//! playback stream and runs only when a frontend requests one of the scope bits. + +use spectrum_analyzer::scaling::divide_by_N_sqrt; +use spectrum_analyzer::windows::hann_window; +use spectrum_analyzer::{samples_fft_to_spectrum, FrequencyLimit}; + +pub const GONIOMETER_BIT: u32 = 0b01; +pub const OSCILLOSCOPE_BIT: u32 = 0b10; +pub const GONIOMETER_POINTS: usize = 256; +pub const OSCILLOSCOPE_POINTS: usize = 512; + +const INV_SQRT_2: f32 = std::f32::consts::FRAC_1_SQRT_2; +const PITCH_MIN_HZ: f32 = 40.0; +const PITCH_MAX_HZ: f32 = 1_000.0; +const FIR_TAPS: usize = 257; + +#[derive(Clone, Copy, Default)] +struct Biquad { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + z1: f32, + z2: f32, +} + +impl Biquad { + fn lowpass(sample_rate: u32, cutoff_hz: f32) -> Self { + let sample_rate = sample_rate.max(1) as f32; + let cutoff = cutoff_hz.clamp(1.0, sample_rate * 0.49); + let omega = std::f32::consts::TAU * cutoff / sample_rate; + let sin = omega.sin(); + let cos = omega.cos(); + let alpha = sin * INV_SQRT_2; + let a0 = 1.0 + alpha; + Self { + b0: ((1.0 - cos) * 0.5) / a0, + b1: (1.0 - cos) / a0, + b2: ((1.0 - cos) * 0.5) / a0, + a1: (-2.0 * cos) / a0, + a2: (1.0 - alpha) / a0, + z1: 0.0, + z2: 0.0, + } + } + + #[inline] + fn process(&mut self, sample: f32) -> f32 { + let out = self.b0 * sample + self.z1; + self.z1 = self.b1 * sample - self.a1 * out + self.z2; + self.z2 = self.b2 * sample - self.a2 * out; + out + } +} + +pub struct ScopeProcessor { + raw: Vec, + conditioned: Vec, + trigger: Vec, + draw: Vec, + fir: Vec, + pitch_hz: f32, + fir_center_hz: f32, + fir_sample_rate: u32, +} + +impl Default for ScopeProcessor { + fn default() -> Self { + Self { + raw: Vec::new(), + conditioned: Vec::new(), + trigger: Vec::new(), + draw: Vec::new(), + fir: Vec::new(), + pitch_hz: 0.0, + fir_center_hz: 0.0, + fir_sample_rate: 0, + } + } +} + +impl ScopeProcessor { + /// Mid/side points after two cascaded 8 kHz Butterworth low-pass stages, + /// plus the correlation of the same full-rate stereo window. + pub fn goniometer(&mut self, interleaved: &[f32], sample_rate: u32) -> (Box<[f32; 512]>, f32) { + let frames = interleaved.len() / 2; + let mut points = Box::new([0.0; GONIOMETER_POINTS * 2]); + if frames == 0 { + return (points, 0.0); + } + + let mut ll1 = Biquad::lowpass(sample_rate, 8_000.0); + let mut ll2 = Biquad::lowpass(sample_rate, 8_000.0); + let mut rr1 = Biquad::lowpass(sample_rate, 8_000.0); + let mut rr2 = Biquad::lowpass(sample_rate, 8_000.0); + let step = (frames / GONIOMETER_POINTS).max(1); + let first = frames.saturating_sub(step * GONIOMETER_POINTS); + let mut next = first; + let mut point = 0usize; + let mut sum_lr = 0.0f64; + let mut sum_l2 = 0.0f64; + let mut sum_r2 = 0.0f64; + + for frame in 0..frames { + let left = interleaved[frame * 2]; + let right = interleaved[frame * 2 + 1]; + sum_lr += (left * right) as f64; + sum_l2 += (left * left) as f64; + sum_r2 += (right * right) as f64; + let left = ll2.process(ll1.process(left)); + let right = rr2.process(rr1.process(right)); + if frame >= next && point < GONIOMETER_POINTS { + points[point * 2] = ((right - left) * INV_SQRT_2).clamp(-1.0, 1.0); + points[point * 2 + 1] = ((right + left) * INV_SQRT_2).clamp(-1.0, 1.0); + point += 1; + next = next.saturating_add(step); + } + } + + let denom = (sum_l2 * sum_r2).sqrt(); + let correlation = if denom > f64::EPSILON { + (sum_lr / denom).clamp(-1.0, 1.0) as f32 + } else { + 0.0 + }; + (points, correlation) + } + + /// Pitch-locked mono oscilloscope. Pitch estimation and drawing use + /// separate conditioned buffers; the narrow FIR is only redesigned after + /// a material pitch change. + pub fn oscilloscope(&mut self, interleaved: &[f32], sample_rate: u32) -> Box<[f32; 512]> { + let frames = interleaved.len() / 2; + let mut output = Box::new([0.0; OSCILLOSCOPE_POINTS]); + if frames < 64 { + return output; + } + self.resize(frames); + for frame in 0..frames { + self.raw[frame] = (interleaved[frame * 2] + interleaved[frame * 2 + 1]) * 0.5; + } + + self.condition_for_pitch(sample_rate); + let target_pitch = estimate_pitch(&self.conditioned, sample_rate); + if let Some(target) = target_pitch { + self.pitch_hz = if self.pitch_hz <= 0.0 + || (target / self.pitch_hz).max(self.pitch_hz / target) > 1.8 + { + target + } else { + self.pitch_hz * 0.82 + target * 0.18 + }; + } + let pitch = self.pitch_hz.clamp(PITCH_MIN_HZ, PITCH_MAX_HZ); + if self.fir.is_empty() + || self.fir_sample_rate != sample_rate + || relative_change(pitch, self.fir_center_hz) > 0.05 + { + self.fir = design_bandpass(pitch, sample_rate, FIR_TAPS); + self.fir_center_hz = pitch; + self.fir_sample_rate = sample_rate; + } + + convolve(&self.conditioned, &self.fir, &mut self.trigger); + lowpass_draw(&self.raw, sample_rate, &mut self.draw); + + let period = (sample_rate.max(1) as f32 / pitch).clamp(2.0, frames as f32); + let span = (period * 2.0).clamp(96.0, (frames.saturating_sub(4)) as f32); + let required = span.ceil() as usize + 2; + let latest = frames.saturating_sub(required).max(1); + let trigger_rms = rms(&self.trigger); + let quarter = (period * 0.25).round().max(1.0) as usize; + let threshold = (trigger_rms * 0.18).max(0.0005); + let trigger = rising_trigger(&self.trigger, latest, quarter, threshold) + .unwrap_or_else(|| frames.saturating_sub(required) as f32); + let gain = (0.22 / rms(&self.draw).max(0.015)).clamp(0.5, 6.0); + + for (index, sample) in output.iter_mut().enumerate() { + let position = trigger + span * index as f32 / (OSCILLOSCOPE_POINTS - 1) as f32; + *sample = (interpolate(&self.draw, position) * gain).clamp(-1.0, 1.0); + } + output + } + + fn resize(&mut self, frames: usize) { + self.raw.resize(frames, 0.0); + self.conditioned.resize(frames, 0.0); + self.trigger.resize(frames, 0.0); + self.draw.resize(frames, 0.0); + } + + fn condition_for_pitch(&mut self, sample_rate: u32) { + let rate = sample_rate.max(1) as f32; + let low_alpha = (-std::f32::consts::TAU * 400.0 / rate).exp(); + let mut shelf_low = 0.0f32; + let mut lp1 = Biquad::lowpass(sample_rate, 18_000.0); + let mut lp2 = Biquad::lowpass(sample_rate, 18_000.0); + for (input, output) in self.raw.iter().zip(self.conditioned.iter_mut()) { + shelf_low = shelf_low * low_alpha + *input * (1.0 - low_alpha); + let high = *input - shelf_low; + *output = lp2.process(lp1.process(shelf_low + high * 0.707_106_77)); + } + } +} + +fn estimate_pitch(samples: &[f32], sample_rate: u32) -> Option { + if samples.len() < 64 || rms(samples) < 0.002 { + return None; + } + let windowed = hann_window(samples); + let spectrum = samples_fft_to_spectrum( + &windowed, + sample_rate.max(1), + FrequencyLimit::Range(PITCH_MIN_HZ, PITCH_MAX_HZ), + Some(÷_by_N_sqrt), + ) + .ok()?; + spectrum + .data() + .iter() + .max_by(|(fa, ma), (fb, mb)| { + let a = ma.val() / fa.val().max(PITCH_MIN_HZ).sqrt(); + let b = mb.val() / fb.val().max(PITCH_MIN_HZ).sqrt(); + a.total_cmp(&b) + }) + .map(|(frequency, _)| frequency.val()) +} + +fn relative_change(a: f32, b: f32) -> f32 { + if a <= 0.0 || b <= 0.0 { + return f32::INFINITY; + } + (a - b).abs() / b +} + +fn design_bandpass(center_hz: f32, sample_rate: u32, taps: usize) -> Vec { + let rate = sample_rate.max(1) as f32; + let half_width = (center_hz * 0.05).max(rate / taps as f32); + let low = (center_hz - half_width).max(PITCH_MIN_HZ * 0.5) / rate; + let high = (center_hz + half_width).min(rate * 0.48) / rate; + let middle = (taps - 1) as f32 * 0.5; + let mut kernel = Vec::with_capacity(taps); + for index in 0..taps { + let m = index as f32 - middle; + let ideal = if m.abs() < f32::EPSILON { + 2.0 * (high - low) + } else { + ((std::f32::consts::TAU * high * m).sin() - (std::f32::consts::TAU * low * m).sin()) + / (std::f32::consts::PI * m) + }; + let window = 0.5 - 0.5 * (std::f32::consts::TAU * index as f32 / (taps - 1) as f32).cos(); + kernel.push(ideal * window); + } + let omega = std::f32::consts::TAU * center_hz / rate; + let (real, imag) = kernel + .iter() + .enumerate() + .fold((0.0, 0.0), |(real, imag), (i, tap)| { + let phase = omega * i as f32; + (real + tap * phase.cos(), imag - tap * phase.sin()) + }); + let gain = (real * real + imag * imag).sqrt().max(1.0e-6); + for tap in &mut kernel { + *tap /= gain; + } + kernel +} + +fn convolve(input: &[f32], kernel: &[f32], output: &mut [f32]) { + for (index, out) in output.iter_mut().enumerate() { + let count = kernel.len().min(index + 1); + let mut sum = 0.0; + for tap in 0..count { + sum += input[index - tap] * kernel[tap]; + } + *out = sum; + } +} + +fn lowpass_draw(input: &[f32], sample_rate: u32, output: &mut [f32]) { + let mut filter = Biquad::lowpass(sample_rate, 12_000.0); + for (input, output) in input.iter().zip(output.iter_mut()) { + *output = filter.process(*input); + } +} + +fn rising_trigger(signal: &[f32], latest: usize, quarter: usize, threshold: f32) -> Option { + for index in (1..=latest.min(signal.len().saturating_sub(2))).rev() { + let before = signal[index - 1]; + let after = signal[index]; + let validation = index.saturating_add(quarter).min(signal.len() - 1); + if before <= 0.0 && after > 0.0 && signal[validation].abs() >= threshold { + let fraction = (-before / (after - before).max(f32::EPSILON)).clamp(0.0, 1.0); + return Some(index as f32 - 1.0 + fraction); + } + } + None +} + +fn interpolate(samples: &[f32], position: f32) -> f32 { + let base = position.floor().max(0.0) as usize; + let next = (base + 1).min(samples.len().saturating_sub(1)); + let fraction = position - base as f32; + samples.get(base).copied().unwrap_or(0.0) * (1.0 - fraction) + + samples.get(next).copied().unwrap_or(0.0) * fraction +} + +fn rms(samples: &[f32]) -> f32 { + if samples.is_empty() { + return 0.0; + } + (samples.iter().map(|sample| sample * sample).sum::() / samples.len() as f32).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stereo_sine(sample_rate: u32, frequency: f32, phase: f32, inverted: bool) -> Vec { + let mut samples = Vec::with_capacity(4096); + for frame in 0..2048 { + let value = (std::f32::consts::TAU * frequency * frame as f32 / sample_rate as f32 + + phase) + .sin() + * 0.5; + samples.push(value); + samples.push(if inverted { -value } else { value }); + } + samples + } + + #[test] + fn goniometer_maps_mono_vertical_and_reports_positive_correlation() { + let mut scope = ScopeProcessor::default(); + let (points, correlation) = + scope.goniometer(&stereo_sine(48_000, 440.0, 0.0, false), 48_000); + let max_side = points + .iter() + .step_by(2) + .fold(0.0f32, |peak, value| peak.max(value.abs())); + assert!(max_side < 0.001); + assert!(correlation > 0.999); + } + + #[test] + fn goniometer_maps_inverted_stereo_horizontal_and_reports_negative_correlation() { + let mut scope = ScopeProcessor::default(); + let (points, correlation) = + scope.goniometer(&stereo_sine(48_000, 440.0, 0.0, true), 48_000); + let max_mid = points + .iter() + .skip(1) + .step_by(2) + .fold(0.0f32, |peak, value| peak.max(value.abs())); + assert!(max_mid < 0.001); + assert!(correlation < -0.999); + } + + #[test] + fn oscilloscope_locks_a_tone_to_a_rising_crossing() { + let mut scope = ScopeProcessor::default(); + let output = scope.oscilloscope(&stereo_sine(48_000, 440.0, 1.7, false), 48_000); + assert!( + scope.pitch_hz > 400.0 && scope.pitch_hz < 500.0, + "pitch {}", + scope.pitch_hz + ); + assert!(output.iter().all(|value| value.is_finite())); + assert!(output.iter().any(|value| value.abs() > 0.1)); + } +} diff --git a/crates/qbz-audio/src/wasapi_backend.rs b/crates/qbz-audio/src/wasapi_backend.rs new file mode 100644 index 000000000..04e841368 --- /dev/null +++ b/crates/qbz-audio/src/wasapi_backend.rs @@ -0,0 +1,374 @@ +//! WASAPI endpoint capabilities: what rates a device accepts in EXCLUSIVE +//! mode, and being told when the device list changes. +//! +//! The Windows counterpart of `alsa_backend::get_device_supported_rates`. It +//! exists because "what can this DAC actually do" cannot be inferred: the +//! spike measured a Cambridge Audio USB DAC accepting 44100, 48000, 88200, +//! 96000 and 192000 while REFUSING 176400 -- the 44.1 family stops at 88.2 +//! while the 48 family reaches 192, which no rule about "higher rates imply +//! lower ones" predicts. The owner confirmed it from the hardware, and a +//! SECOND endpoint on the same machine does accept 176400, so the difference +//! is the device rather than the driver stack. +//! +//! ## Exclusive mode is the question, and it is a different question +//! +//! `IsFormatSupported(SHARED)` answers about the mixer, which resamples and +//! therefore says yes to almost everything. Only the EXCLUSIVE answer says +//! what reaches the converter untouched, which is the only answer bit-perfect +//! playback cares about. +//! +//! ## "I could not ask" is not "it supports nothing" +//! +//! A busy endpoint, a failed activation and a device that genuinely accepts no +//! rate all produce an empty list, and collapsing them was a real defect in the +//! first draft: the empty answer was cached as authoritative, and because a +//! stream CLOSING raises no device notification, it would have outlived the +//! condition that caused it. [`Caps`] keeps them apart, and only `Known` is +//! ever stored. + +#![cfg(windows)] + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use crate::wasapi_direct::LADDER; + +/// The rates worth asking about: the two families QBZ streams and their +/// multiples, plus the DSD-over-PCM carriers. 352800 and 384000 are asked +/// because a DAC that takes them changes what a quality cap can offer. +const SWEEP: [u32; 8] = [ + 44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 352_800, 384_000, +]; + +/// What a probe learned, which is not always a capability. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Caps { + /// The device answered. An empty vector HERE is a real "nothing". + Known(Vec), + /// The device could not be asked -- busy, gone, or COM refused. Never + /// cached: nothing about it is a property of the hardware. + Unavailable, +} + +/// Bumped by every device notification. A cached answer is only good while +/// this has not moved. +/// +/// A COUNTER rather than per-endpoint removal, and that is the point: the +/// notifications arrive on a thread the Windows audio system owns, and +/// Microsoft's rule there is DO NOT BLOCK. `fetch_add` cannot block, cannot +/// fail and cannot lose an invalidation, where taking the cache's mutex would +/// block a thread that was told not to, and a `try_lock` that skipped on +/// contention would silently keep a stale answer. +/// +/// Invalidating EVERYTHING on any event is also the honest granularity: a +/// device arriving or leaving changes exclusive-mode contention for endpoints +/// other than itself, so "only that one changed" was never true. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Endpoint id -> (the generation it was measured in, the rates it accepted). +fn cache() -> &'static Mutex)>> { + static CACHE: OnceLock)>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Serialises the probing itself, so two callers that miss together do the +/// work once instead of twice. +/// +/// MEASURED, not theoretical: startup enumerates from two independent places +/// -- the device-capability pass and the DAC-capability pass -- and the log +/// showed every endpoint swept exactly twice, both racing past the empty +/// cache. Holding this across the sweep costs the second caller about a +/// millisecond per endpoint and saves it the same again. +/// +/// SEPARATE from the cache mutex on purpose. The cache lock is taken for a +/// `get` and an `insert` and released immediately; this one is held across COM +/// calls, and merging them would make every reader wait behind a probe. The +/// hotplug callbacks touch NEITHER -- they only bump an atomic -- so nothing +/// the audio system owns can ever wait on either lock. +fn sweep_gate() -> &'static Mutex<()> { + static GATE: OnceLock> = OnceLock::new(); + GATE.get_or_init(|| Mutex::new(())) +} + +/// The cache key: the endpoint, and the QUESTION the stored answer answers. +/// +/// The value is "some rung of the ladder accepts this rate", so naming one +/// rung would claim a precision it does not have. `LADDER.len()` is part of it +/// because growing the ladder changes the answer for the same device, and an +/// entry written under a shorter ladder must not survive that. +pub(crate) fn rate_sweep_key(endpoint_id: &str) -> String { + format!("{endpoint_id}|any-of-{}-rungs", LADDER.len()) +} + +/// COM is initialised at most ONCE per thread, and deliberately never undone. +/// +/// `initialize_mta` should be balanced by a `CoUninitialize`, but the threads +/// that reach here are pool workers that get reused: uninitialising on the way +/// out would tear the apartment down under whatever COM work the same thread +/// runs next, and doing it per sweep would re-enter the apartment on every +/// call. One initialisation per worker for the life of the process is the +/// bounded cost, and it is the trade any COM-using code on a pool makes. +/// +/// `RPC_E_CHANGED_MODE` -- this thread is already an STA -- is not an error to +/// report: `IsFormatSupported` is happy either way. +fn ensure_com() { + thread_local! { + static DONE: std::cell::Cell = const { std::cell::Cell::new(false) }; + } + DONE.with(|d| { + if !d.get() { + let _ = wasapi::initialize_mta().ok(); + d.set(true); + } + }); +} + +/// The rates `endpoint_id` accepts in EXCLUSIVE mode. +/// +/// `None` = the endpoint could not be asked. Callers must NOT read that as +/// "supports nothing" -- see [`Caps`] -- and in particular must not use it to +/// justify sending a device a format it has not agreed to. +pub fn supported_rates(endpoint_id: &str) -> Option> { + let key = rate_sweep_key(endpoint_id); + // Read BEFORE probing, and store against THIS value rather than the + // current one. If a device event lands while the sweep runs, the result is + // already stale and the next caller re-measures instead of trusting it. + let generation = GENERATION.load(Ordering::SeqCst); + + // The lock is never held across the sweep below: that is milliseconds of + // COM calls, and nothing else may be made to wait on it. + if let Ok(guard) = cache().lock() { + if let Some((gen, hit)) = guard.get(&key) { + if *gen == generation { + return Some(hit.clone()); + } + } + } + + // Take the gate, then look again: the caller that held it may have been + // measuring this very endpoint, in which case its answer is now in the + // cache and there is nothing left to do. + let _gate = sweep_gate().lock(); + if let Ok(guard) = cache().lock() { + if let Some((gen, hit)) = guard.get(&key) { + if *gen == generation { + return Some(hit.clone()); + } + } + } + + match sweep(endpoint_id) { + Caps::Known(rates) => { + if let Ok(mut guard) = cache().lock() { + guard.insert(key, (generation, rates.clone())); + } + Some(rates) + } + // NOT cached. A busy or absent endpoint says nothing about the + // hardware, and a stream closing raises no notification to clear it. + Caps::Unavailable => None, + } +} + +/// The highest rate the endpoint accepts, or `None` if it could not be asked +/// or accepted nothing. +pub fn max_supported_rate(endpoint_id: &str) -> Option { + supported_rates(endpoint_id)?.into_iter().max() +} + +/// Drop everything we believe about every endpoint. +/// +/// Lock-free by construction, because the hotplug callbacks call it from a +/// thread the audio system owns and are required not to block there. +pub fn invalidate_all() { + GENERATION.fetch_add(1, Ordering::SeqCst); +} + +/// Ask the device about every rate in `SWEEP`, at every rung of the format +/// ladder, and keep a rate the moment ANY rung accepts it. +/// +/// Walking the ladder rather than one rung matters: the spike's DAC accepts +/// ONLY 24-in-32 and refuses packed 24, 32-bit, 16-bit and float, so a sweep +/// pinned to a single rung would have reported that it supports nothing. +fn sweep(endpoint_id: &str) -> Caps { + use wasapi::{DeviceEnumerator, SampleType, ShareMode, WaveFormat}; + + ensure_com(); + + let Ok(enumerator) = DeviceEnumerator::new() else { + log::warn!("[wasapi-caps] no device enumerator; capabilities unknown"); + return Caps::Unavailable; + }; + let Ok(device) = enumerator.get_device(endpoint_id) else { + log::warn!("[wasapi-caps] endpoint {endpoint_id} unavailable; capabilities unknown"); + return Caps::Unavailable; + }; + // ONE client for every question. `IsFormatSupported` may be called at any + // time after activation and neither initialises nor reserves the endpoint, + // so a client per probe was forty activations buying nothing. Only an + // INITIALISED exclusive client reserves a device, and this one never is. + let Ok(client) = device.get_iaudioclient() else { + log::warn!("[wasapi-caps] cannot activate {endpoint_id}; capabilities unknown"); + return Caps::Unavailable; + }; + + let mut rates = Vec::new(); + for rate in SWEEP { + let accepted = LADDER.iter().any(|rung| { + // The same shape `wasapi_direct`'s open path builds, and it must + // stay the same shape: a rung probed one way and opened another is + // a device that says yes and then refuses. + let ty = if rung.is_float() { + SampleType::Float + } else { + SampleType::Int + }; + let format = WaveFormat::new( + rung.container_bits() as usize, + rung.valid_bits() as usize, + &ty, + rate as usize, + 2, + None, + ); + // `is_supported`, NOT `is_supported_exclusive_with_quirks`. That + // helper retries as a plain WAVEFORMATEX and with alternative + // channel masks, and using it HERE ALONE would be a mistake: this + // sweep must predict what the OPEN path does, and that path asks + // the plain question. A rate accepted only through a quirk the + // opener never tries is a rate that appears in the UI and then + // fails to open -- worse than under-reporting. + // + // Teaching the opener those quirks is worth doing for DACs that + // need them, and it has to happen in BOTH places in one change, or + // the two disagree again in the other direction. + // + // In exclusive mode `ppClosestMatch` is null, so the answer is an + // exact yes or an error: there is no S_FALSE "closest match" to + // weigh, which is shared-mode behaviour. + client.is_supported(&format, &ShareMode::Exclusive).is_ok() + }); + if accepted { + rates.push(rate); + } + } + + log::info!("[wasapi-caps] {endpoint_id} accepts {rates:?} in exclusive mode"); + Caps::Known(rates) +} + +// --------------------------------------------------------------------------- +// Hotplug +// --------------------------------------------------------------------------- + +/// Whether the watch thread is running. The REGISTRATION itself cannot live in +/// a static: it holds COM interface pointers and is therefore not `Send`. +static WATCH_STARTED: AtomicBool = AtomicBool::new(false); + +/// Start listening for endpoints arriving, leaving or changing state. +/// +/// Idempotent, and it RE-ARMS: if the audio service is unavailable when this +/// first runs, the flag is cleared so a later call can retry. A transient +/// failure must not disable the watch for the life of the process, which the +/// first draft did by resetting only when the thread failed to spawn. +/// +/// SCOPE, stated plainly: this keeps the CAPABILITY CACHE honest. It does not +/// refresh an already-rendered device list -- a newly attached DAC appears +/// when something enumerates again, not the instant it is plugged in. Making +/// the settings model live needs a bridge signal and belongs with that model. +pub fn start_hotplug_watch() { + if WATCH_STARTED.swap(true, Ordering::SeqCst) { + return; + } + + // A DEDICATED THREAD that owns the registration and then parks forever. + // + // Not a matter of style: `DeviceEventRegistration` holds COM interface + // pointers, so it is not `Send` and cannot be stored in a static. Keeping + // it on the stack of a thread that never returns is what keeps it alive, + // and dropping it is exactly what unregisters the callbacks -- so the + // thread outliving everything IS the lifetime management. It also gives + // the registration its own apartment rather than borrowing whichever + // thread happened to call first. + let spawned = std::thread::Builder::new() + .name("qbz-wasapi-hotplug".to_string()) + .spawn(|| { + use wasapi::{DeviceEnumerator, DeviceEventCallbacks}; + + ensure_com(); + let Ok(enumerator) = DeviceEnumerator::new() else { + log::warn!("[wasapi-caps] hotplug watch unavailable; will retry on next request"); + WATCH_STARTED.store(false, Ordering::SeqCst); + return; + }; + + // EVERY callback below runs on a thread the Windows audio system + // owns, where the documented rule is that they return IMMEDIATELY. + // So each does one lock-free atomic increment and NOTHING else -- + // no mutex, and no logging either: a log macro can allocate, take + // its own lock and perform synchronous output, and "it is only a + // log line" is exactly how that rule gets broken. + let mut callbacks = DeviceEventCallbacks::new(); + callbacks.set_device_removed_callback(|_id| invalidate_all()); + callbacks.set_device_added_callback(|_id| invalidate_all()); + callbacks.set_device_state_callback(|_id, _state| invalidate_all()); + // Reconfiguring a device in Windows' own Sound settings changes + // what it accepts without adding or removing anything. The first + // draft's comment promised this and installed nothing. + callbacks.set_property_value_callback(|_id, _key| invalidate_all()); + + let _registration = match enumerator.register_notification_callback(callbacks) { + Ok(reg) => reg, + Err(e) => { + log::warn!("[wasapi-caps] could not register the hotplug watch: {e}"); + WATCH_STARTED.store(false, Ordering::SeqCst); + return; + } + }; + log::info!("[wasapi-caps] hotplug watch registered"); + + // Park. `_registration` must not be dropped, and there is nothing + // for this thread to do: notifications arrive on WASAPI's own + // thread, not this one. + loop { + std::thread::park(); + } + }); + if let Err(e) = spawned { + WATCH_STARTED.store(false, Ordering::SeqCst); + log::warn!("[wasapi-caps] could not spawn the hotplug watch thread: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_cache_key_says_what_the_answer_means() { + // The stored value is "some rung accepts this rate", so the key must + // not name one rung -- and it must change if the ladder grows, or an + // entry written under a shorter ladder would outlive its question. + let key = rate_sweep_key("{0.0.0.x}"); + assert!(key.starts_with("{0.0.0.x}|")); + assert!(key.contains(&LADDER.len().to_string())); + assert_ne!(rate_sweep_key("{0.0.0.x}"), rate_sweep_key("{0.0.0.y}")); + } + + #[test] + fn unavailable_is_not_an_empty_capability() { + // The distinction the first draft collapsed: a busy endpoint and one + // that genuinely accepts nothing are different answers, and only the + // second is a fact about the hardware. + assert_ne!(Caps::Unavailable, Caps::Known(Vec::new())); + assert_eq!(Caps::Known(vec![44_100]), Caps::Known(vec![44_100])); + } + + #[test] + fn a_device_event_expires_every_cached_answer() { + let before = GENERATION.load(Ordering::SeqCst); + invalidate_all(); + assert_ne!(GENERATION.load(Ordering::SeqCst), before); + } +} diff --git a/crates/qbz-audio/src/wasapi_direct.rs b/crates/qbz-audio/src/wasapi_direct.rs new file mode 100644 index 000000000..f3fe766e1 --- /dev/null +++ b/crates/qbz-audio/src/wasapi_direct.rs @@ -0,0 +1,1245 @@ +//! WASAPI exclusive-mode output (Windows) — the bit-perfect path. +//! +//! Shape: `alsa_direct.rs` on the OUTSIDE, so `PlaybackEngine` can clone the +//! arm it already has for ALSA. NOT on the inside: WASAPI's COM objects are +//! `!Send`, so exactly one render thread owns every COM handle and this struct +//! is a channel plus atomics. +//! +//! Everything above the `#[cfg(windows)]` block is pure and compiles on every +//! host, which is the point — the format ladder, the PCM packing and the +//! period arithmetic are where the platform-specific mistakes live, and they +//! are testable on the machine that runs CI as well as on the one that ships. +//! +//! # The one that bites +//! +//! Windows 24-in-32 is **LEFT-aligned**: the 24 valid bits sit in the HIGH +//! bytes of the 32-bit container. ALSA's `S24_LE` is right-aligned. Porting +//! the ALSA packing verbatim shifts every sample down 8 bits and plays 48 dB +//! quiet, which sounds like a volume bug rather than a format bug — so +//! `s24_in_32_is_left_aligned_with_zero_low_byte` exists to fail loudly. + +use crate::backend::BitPerfectMode; + +/// A format rung, in the order the ladder tries them. +/// +/// `S24Packed` first: it is the shape a DAC that wants 24-bit really wants, +/// and some USB Audio Class devices accept only it. `F32` last and separate — +/// see [`Rung::bit_perfect_mode`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Rung { + /// 3 bytes per sample, no padding. ALSA's `S24_3LE`. + S24Packed, + /// 32-bit container, 32 valid bits. + S32, + /// 32-bit container, 24 valid bits, LEFT-aligned (Windows' own shape). + S24In32, + S16, + /// 32-bit IEEE float. A COMPATIBILITY rung: the device mixes rather than + /// takes the samples as given, so it is never bit-perfect. + F32, +} + +/// The order `WasapiDirectStream::new` probes. Measured on the owner's +/// Cambridge Audio USB Audio 2.0 (research/05-spike-results.md): only +/// `S24In32` is accepted, at 44.1/48/88.2/96/192 kHz — so on that DAC the +/// ladder is one rung deep. Other devices differ, which is why it stays a +/// ladder. +pub const LADDER: [Rung; 5] = [ + Rung::S24Packed, + Rung::S32, + Rung::S24In32, + Rung::S16, + Rung::F32, +]; + +impl Rung { + pub fn container_bits(self) -> u16 { + match self { + Rung::S24Packed => 24, + Rung::S16 => 16, + _ => 32, + } + } + + pub fn valid_bits(self) -> u16 { + match self { + Rung::S24Packed | Rung::S24In32 => 24, + Rung::S16 => 16, + _ => 32, + } + } + + pub fn is_float(self) -> bool { + matches!(self, Rung::F32) + } + + /// Bytes per FRAME, i.e. per sample across every channel. + pub fn block_align(self, channels: u16) -> u16 { + self.container_bits() / 8 * channels + } + + /// The float rung means the device took floats and will convert them, so + /// the samples that reach the DAC are not the ones we produced. + pub fn bit_perfect_mode(self) -> BitPerfectMode { + if self.is_float() { + BitPerfectMode::Disabled + } else { + BitPerfectMode::DirectHardware + } + } +} + +/// f32 samples to the wire bytes of one rung, appended to `out`. +/// +/// Scaling matches `alsa_direct.rs` so the two backends cannot disagree about +/// what full scale is. The `S24In32` arm is the one that differs, and it +/// differs on purpose: `v << 8`, low byte zero. +pub fn pack_f32(samples: &[f32], rung: &Rung, out: &mut Vec) { + match rung { + Rung::S16 => { + for &s in samples { + let v = (s.clamp(-1.0, 1.0) * 32767.0).round() as i16; + out.extend_from_slice(&v.to_le_bytes()); + } + } + Rung::S24Packed => { + for &s in samples { + let v = (s.clamp(-1.0, 1.0) * 8_388_607.0).round() as i32; + out.extend_from_slice(&v.to_le_bytes()[..3]); + } + } + Rung::S24In32 => { + for &s in samples { + let v = (s.clamp(-1.0, 1.0) * 8_388_607.0).round() as i32; + // LEFT-aligned: the valid 24 bits occupy the HIGH bytes. + out.extend_from_slice(&(v << 8).to_le_bytes()); + } + } + Rung::S32 => { + for &s in samples { + let v = (s.clamp(-1.0, 1.0) as f64 * i32::MAX as f64).round() as i32; + out.extend_from_slice(&v.to_le_bytes()); + } + } + Rung::F32 => { + for &s in samples { + out.extend_from_slice(&s.to_le_bytes()); + } + } + } +} + +/// 100-ns units to frames, rounded to nearest. +pub fn hns_to_frames(hns: i64, rate: u32) -> u32 { + ((hns as f64 * rate as f64 / 10_000_000.0) + 0.5) as u32 +} + +/// Frames to 100-ns units, rounded to nearest. +pub fn frames_to_hns(frames: u32, rate: u32) -> i64 { + ((frames as f64 * 10_000_000.0 / rate as f64) + 0.5) as i64 +} + +/// Round a period up so `frames * block_align` is a whole number of 128-byte +/// blocks. +/// +/// This is pre-alignment: `AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED` is what an +/// unaligned period earns, and recovering from it costs a `GetBufferSize`, a +/// fresh `IAudioClient` and a second `Initialize`. Asking for an aligned +/// period in the first place means the error usually never fires — measured on +/// the owner's DAC, it did not fire at all. Keep the retry anyway: Intel HDA +/// devices are the documented case that still needs it. +pub fn aligned_period_hns(requested_hns: i64, rate: u32, block_align: u16) -> i64 { + let frames = hns_to_frames(requested_hns, rate).max(1); + let bytes = frames as u64 * block_align as u64; + let aligned_bytes = bytes.div_ceil(128) * 128; + let aligned_frames = (aligned_bytes / block_align as u64) as u32; + frames_to_hns(aligned_frames, rate) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The -48 dB trap. Windows puts the 24 valid bits in the HIGH bytes; + /// porting ALSA's right-aligned S24_LE here plays quiet, not broken, which + /// is the kind of defect that survives a listening test. + #[test] + fn s24_in_32_is_left_aligned_with_zero_low_byte() { + let mut out = Vec::new(); + pack_f32(&[1.0, -1.0, 0.0], &Rung::S24In32, &mut out); + let w: Vec = out + .chunks(4) + .map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])) + .collect(); + assert_eq!(w[0], 8_388_607 << 8); + assert_eq!(w[1], -8_388_607 << 8); + assert_eq!(w[2], 0); + assert!( + out.chunks(4).all(|b| b[0] == 0), + "the low byte of every container must be zero" + ); + } + + #[test] + fn s24_packed_matches_alsa_s24_3le_byte_order() { + let mut out = Vec::new(); + pack_f32(&[1.0], &Rung::S24Packed, &mut out); + assert_eq!(out, vec![0xFF, 0xFF, 0x7F]); // little-endian 0x7FFFFF + } + + #[test] + fn s32_and_s16_scale_like_alsa_direct() { + let mut out = Vec::new(); + pack_f32(&[1.0], &Rung::S32, &mut out); + assert_eq!( + i32::from_le_bytes([out[0], out[1], out[2], out[3]]), + i32::MAX + ); + out.clear(); + pack_f32(&[-1.0], &Rung::S16, &mut out); + assert_eq!(i16::from_le_bytes([out[0], out[1]]), -32767); + } + + #[test] + fn packing_is_clamped_not_wrapped() { + // A sample above full scale must saturate. Wrapping would invert the + // waveform's peaks, which is audible as a click, not as clipping. + let mut out = Vec::new(); + pack_f32(&[2.0, -2.0], &Rung::S16, &mut out); + assert_eq!(i16::from_le_bytes([out[0], out[1]]), 32767); + assert_eq!(i16::from_le_bytes([out[2], out[3]]), -32767); + } + + #[test] + fn ladder_order_and_geometry() { + assert_eq!( + LADDER, + [ + Rung::S24Packed, + Rung::S32, + Rung::S24In32, + Rung::S16, + Rung::F32 + ] + ); + assert_eq!(Rung::S24Packed.block_align(2), 6); + assert_eq!(Rung::S24In32.block_align(2), 8); + assert_eq!(Rung::S16.block_align(2), 4); + assert_eq!(Rung::S24In32.valid_bits(), 24); + assert_eq!(Rung::S24In32.container_bits(), 32); + assert_eq!(Rung::S24Packed.valid_bits(), 24); + assert_eq!(Rung::S24Packed.container_bits(), 24); + } + + #[test] + fn float_rung_is_not_bit_perfect() { + assert_eq!(Rung::F32.bit_perfect_mode(), BitPerfectMode::Disabled); + assert_eq!( + Rung::S24Packed.bit_perfect_mode(), + BitPerfectMode::DirectHardware + ); + assert_eq!( + Rung::S24In32.bit_perfect_mode(), + BitPerfectMode::DirectHardware + ); + } + + #[test] + fn aligned_period_is_a_multiple_of_128_bytes() { + // 10 ms at 44.1k stereo 24-packed: 441 frames * 6 B = 2646 B, which is + // not a whole number of 128-byte blocks. + let hns = aligned_period_hns(100_000, 44100, 6); + let frames = hns_to_frames(hns, 44100); + assert_eq!(frames as u64 * 6 % 128, 0); + assert!( + (frames as i64 - 441).abs() <= 22, + "stay near the requested period, got {frames} frames" + ); + } + + #[test] + fn aligned_period_leaves_an_already_aligned_one_alone() { + // 3 ms at 192k stereo 24-in-32: 576 frames * 8 B = 4608 B = 36 blocks. + // This is the owner's DAC's minimum period, measured; it must not move. + let hns = aligned_period_hns(30_000, 192_000, 8); + assert_eq!(hns_to_frames(hns, 192_000), 576); + } + + #[test] + fn period_conversions_round_trip() { + for (rate, frames) in [(44100u32, 441u32), (192_000, 576), (96_000, 960)] { + let hns = frames_to_hns(frames, rate); + assert_eq!(hns_to_frames(hns, rate), frames, "rate {rate}"); + } + } +} + +// =========================================================================== +// Public handle +// =========================================================================== + +/// How the render thread is woken. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WasapiTiming { + /// The device signals an event each period. Lower latency, and what the + /// spike measured as glitch-free on the owner's DAC at the minimum period. + Events, + /// The thread sleeps and polls the padding. More devices tolerate it, and + /// it avoids the stuttering some USB-class drivers show in event mode. + Polling, +} + +/// Everything the opened stream settled on. Logged once, and read by the +/// backend so the UI can say what actually happened. +#[derive(Debug, Clone)] +pub struct WasapiOpenInfo { + pub endpoint_name: String, + pub rate: u32, + pub channels: u16, + pub rung: Rung, + pub period_hns: i64, + pub buffer_frames: u32, + /// True when AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED fired and the client had to + /// be rebuilt at the device's own aligned size. + pub realigned: bool, + pub timing: WasapiTiming, +} + +/// A live exclusive-mode stream. +/// +/// The COM objects live on one render thread; this is the feeder side. Every +/// method here is a channel send or an atomic read, which is what makes the +/// handle `Send + Sync` even though `IAudioClient` is not. +pub struct WasapiDirectStream { + #[cfg(windows)] + inner: imp::Inner, + #[cfg(not(windows))] + _unused: (), +} + +// =========================================================================== +// Windows +// =========================================================================== + +#[cfg(windows)] +mod imp { + use super::{aligned_period_hns, Rung, WasapiOpenInfo, WasapiTiming, LADDER}; + use std::collections::VecDeque; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::sync::mpsc::{ + channel, sync_channel, Receiver, RecvTimeoutError, Sender, SyncSender, TryRecvError, + }; + use std::sync::Arc; + use std::thread::JoinHandle; + use std::time::{Duration, Instant}; + use wasapi::{ + initialize_mta, AudioClient, BufferFlags, DeviceEnumerator, Direction, SampleType, + ShareMode, StreamMode, WaveFormat, + }; + + /// Out-of-band commands. PCM travels on its own channel so a pause never + /// waits behind eight queued buffers. + pub(super) enum Ctl { + Pause, + Resume, + /// Carries the reply channel the caller blocks on. `drain()` must not + /// return until the queue really has played out, because the player's + /// writer thread treats its return as "the source is finished" - and + /// the ALSA drain it shares that thread with does block. + /// `true` = the queue really played out; `false` = a Stop threw it + /// away. The caller must be able to tell those apart. + Drain(Sender), + /// Halt and discard, then WAIT for the next track -- ALSA's + /// "prepare for next playback". The render thread survives it, + /// because the player reuses one stream across track changes. + /// Carries the acknowledgement channel. `stop()` MUST NOT return + /// before the halt-and-purge has run: the player sends Stop and then + /// immediately queues the next track's PCM on the other channel, so a + /// fire-and-forget Stop lets the purge below eat audio belonging to + /// the track that is starting. Same failure as the original bug, one + /// track later and intermittent. + Stop(Sender<()>), + /// Real teardown: leave the loop and let the thread end. Only `Drop` + /// sends this. Keeping it separate from `Stop` is the whole point -- + /// when `Stop` also meant "exit", `Drop` joined a thread that had + /// already gone, and the day `Stop` stopped meaning that, `Drop` + /// joined a thread that never would. + Shutdown, + } + + pub(super) struct Inner { + pcm: SyncSender>, + ctl: Sender, + dead: Arc, + underruns: Arc, + timeouts: Arc, + info: WasapiOpenInfo, + join: Option>, + } + + /// Depth of the PCM queue, in periods. Small on purpose: this IS the + /// back-pressure, the same role `snd_pcm_writei` blocking plays for ALSA. + /// Too deep and a seek keeps playing stale audio; too shallow and any + /// scheduling hiccup underruns. + const PCM_QUEUE_PERIODS: usize = 8; + + /// A drain cannot wait forever on a device that stopped consuming. + const DRAIN_DEADLINE: Duration = Duration::from_secs(10); + /// A Stop is acknowledged from the top of the render loop, so the wait is + /// at most one event timeout plus the halt itself. Generous by 10x, and + /// bounded so a dead device cannot wedge a track change. + const STOP_DEADLINE: Duration = Duration::from_secs(2); + + fn wave_format(rung: Rung, rate: u32, channels: u16) -> WaveFormat { + let ty = if rung.is_float() { + SampleType::Float + } else { + SampleType::Int + }; + WaveFormat::new( + rung.container_bits() as usize, + rung.valid_bits() as usize, + &ty, + rate as usize, + channels as usize, + None, + ) + } + + /// Raise the render thread to the "Pro Audio" MMCSS class. + /// + /// Best effort: a failure costs scheduling headroom, not correctness, and + /// the spike measured zero timeouts for ten minutes WITHOUT it, so this is + /// margin rather than a requirement. + fn join_pro_audio() { + use windows::core::PCWSTR; + use windows::Win32::System::Threading::AvSetMmThreadCharacteristicsW; + let name: Vec = "Pro Audio" + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut index: u32 = 0; + // SAFETY: `name` is NUL-terminated and outlives the call; `index` is a + // valid out-slot. The returned handle is intentionally not revoked - + // the thread keeps the class for its whole life and dies with it. + let ok = unsafe { AvSetMmThreadCharacteristicsW(PCWSTR(name.as_ptr()), &mut index) }; + match ok { + Ok(_) => log::debug!("[WASAPI Direct] render thread joined MMCSS Pro Audio"), + Err(e) => log::warn!("[WASAPI Direct] MMCSS Pro Audio unavailable ({e}); continuing"), + } + } + + /// What the render thread reports back once it has (or has not) opened. + type OpenReply = Result; + + /// Open the endpoint, walking the ladder. Runs ON the render thread, + /// because every object it returns is a COM object and those are `!Send`. + fn open_exclusive( + endpoint_id: &str, + rate: u32, + channels: u16, + timing: WasapiTiming, + ) -> Result<(AudioClient, WasapiOpenInfo, Option), String> { + let enumerator = DeviceEnumerator::new().map_err(|e| format!("DeviceEnumerator: {e}"))?; + let collection = enumerator + .get_device_collection(&Direction::Render) + .map_err(|e| format!("device collection: {e}"))?; + let count = collection + .get_nbr_devices() + .map_err(|e| format!("device count: {e}"))?; + + // By ENDPOINT ID, never by name: Windows hands several endpoints the + // same friendly name (measured on this box: three called "Altavoces", + // one of them the DAC). + let mut found = None; + for i in 0..count { + let d = collection + .get_device_at_index(i) + .map_err(|e| format!("device {i}: {e}"))?; + if d.get_id().map(|id| id == endpoint_id).unwrap_or(false) { + found = Some(d); + break; + } + } + let device = found.ok_or_else(|| format!("endpoint '{endpoint_id}' not found"))?; + let endpoint_name = device + .get_friendlyname() + .unwrap_or_else(|_| "".to_string()); + + // --- the ladder ------------------------------------------------- + let mut chosen: Option<(Rung, WaveFormat)> = None; + for rung in LADDER { + let wf = wave_format(rung, rate, channels); + let client = device + .get_iaudioclient() + .map_err(|e| format!("IAudioClient: {e}"))?; + match client.is_supported(&wf, &ShareMode::Exclusive) { + Ok(None) => { + chosen = Some((rung, wf)); + break; + } + // The docs promise exclusive mode never answers S_FALSE. If a + // driver does it anyway, say so loudly and treat it as a no: + // a "closest match" is by definition not the format we asked + // for, and accepting it would silently stop being bit-perfect. + Ok(Some(_)) => log::error!( + "[WASAPI Direct] {endpoint_name}: {rung:?} @ {rate} answered S_FALSE in \ + EXCLUSIVE mode, which the documentation says cannot happen; treating as \ + unsupported" + ), + Err(_) => {} + } + } + let (rung, wf) = chosen.ok_or_else(|| { + format!("{endpoint_name} accepts no exclusive format at {rate} Hz, {channels} ch") + })?; + let block_align = rung.block_align(channels); + + // --- period ------------------------------------------------------ + let probe = device + .get_iaudioclient() + .map_err(|e| format!("IAudioClient: {e}"))?; + let (_default_hns, min_hns) = probe + .get_device_period() + .map_err(|e| format!("device period: {e}"))?; + drop(probe); + let want_hns = aligned_period_hns(min_hns, rate, block_align); + + let mode = |hns: i64| match timing { + WasapiTiming::Events => StreamMode::EventsExclusive { period_hns: hns }, + WasapiTiming::Polling => StreamMode::PollingExclusive { + buffer_duration_hns: hns, + period_hns: hns, + }, + }; + + // A fresh client per attempt: an IAudioClient that failed Initialize + // cannot be re-initialised, which is the whole reason the documented + // NOT_ALIGNED recovery says to Activate a new one. + let mut client = device + .get_iaudioclient() + .map_err(|e| format!("IAudioClient: {e}"))?; + let mut realigned = false; + let mut period_hns = want_hns; + if let Err(first) = client.initialize_client(&wf, &Direction::Render, &mode(want_hns)) { + let msg = first.to_string(); + // AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED (0x88890019). Pre-alignment + // usually prevents it - it did not fire once on the owner's DAC - + // but Intel HDA parts still need the recovery. + if !msg.contains("88890019") { + return Err(format!("Initialize(EXCLUSIVE) failed: {msg}")); + } + let frames = client + .get_buffer_size() + .map_err(|e| format!("GetBufferSize after NOT_ALIGNED: {e}"))?; + period_hns = super::frames_to_hns(frames, rate); + realigned = true; + client = device + .get_iaudioclient() + .map_err(|e| format!("IAudioClient (realign): {e}"))?; + client + .initialize_client(&wf, &Direction::Render, &mode(period_hns)) + .map_err(|e| format!("Initialize(EXCLUSIVE) after realign: {e}"))?; + } + + let handle = match timing { + WasapiTiming::Events => Some( + client + .set_get_eventhandle() + .map_err(|e| format!("event handle: {e}"))?, + ), + WasapiTiming::Polling => None, + }; + let buffer_frames = client + .get_buffer_size() + .map_err(|e| format!("GetBufferSize: {e}"))?; + + let info = WasapiOpenInfo { + endpoint_name, + rate, + channels, + rung, + period_hns, + buffer_frames, + realigned, + timing, + }; + Ok((client, info, handle)) + } + + /// The render thread. Owns every COM object for the life of the stream. + fn render_thread( + endpoint_id: String, + rate: u32, + channels: u16, + timing: WasapiTiming, + reply: Sender, + pcm: Receiver>, + ctl: Receiver, + dead: Arc, + underruns: Arc, + timeouts: Arc, + ) { + if let Err(e) = initialize_mta().ok() { + let _ = reply.send(Err(format!("CoInitializeEx(MTA): {e}"))); + return; + } + let (client, info, handle) = match open_exclusive(&endpoint_id, rate, channels, timing) { + Ok(v) => v, + Err(e) => { + let _ = reply.send(Err(e)); + return; + } + }; + let block_align = info.rung.block_align(info.channels) as usize; + let render = match client.get_audiorenderclient() { + Ok(r) => r, + Err(e) => { + let _ = reply.send(Err(format!("IAudioRenderClient: {e}"))); + return; + } + }; + + join_pro_audio(); + + // The one line every later debugging session starts from. + log::info!( + "[WASAPI Direct] Initialized EXCLUSIVE: {} @ {} Hz, {} ch, {:?} ({}/{} bits), \ + period={} hns, buffer={} frames, realigned={}, timing={:?}", + info.endpoint_name, + info.rate, + info.channels, + info.rung, + info.rung.valid_bits(), + info.rung.container_bits(), + info.period_hns, + info.buffer_frames, + info.realigned, + info.timing, + ); + let period_ms = (info.period_hns / 10_000).max(1) as u32; + // Half a period of slack for the feeder. The device is holding a + // whole period, so this can never make the stream late. + let slack_us = ((info.period_hns / 10) / 2).max(200) as u64; + let event_timeout_ms = period_ms.saturating_mul(8).max(200); + let _ = reply.send(Ok(info.clone())); + + let mut carry: VecDeque = VecDeque::with_capacity(block_align * 4096); + + // Do NOT start until real audio is in hand. The reply above is what + // lets the caller write at all, so starting before it lands guarantees + // the first periods are empty - measured as exactly 2 underruns every + // run, which is a fault this code invented rather than one it found. + // Playback now begins on audio, and the device buffer is pre-rolled + // from it. + let first_deadline = Instant::now() + Duration::from_secs(5); + while carry.is_empty() && Instant::now() < first_deadline { + match ctl.try_recv() { + Ok(Ctl::Shutdown) | Err(TryRecvError::Disconnected) => return, + // Nothing has started yet, so a Stop has nothing to halt; + // discard whatever arrived and keep waiting for real audio. + Ok(Ctl::Stop(ack)) => { + carry.clear(); + let _ = ack.send(()); + } + _ => {} + } + if let Ok(chunk) = pcm.recv_timeout(Duration::from_millis(20)) { + carry.extend(chunk); + } + } + if let Ok(n) = client.get_available_space_in_frames() { + let need = n as usize * block_align; + let take = need.min(carry.len()); + let mut buf: Vec = carry.drain(..take).collect(); + buf.resize(need, 0); + let _ = render.write_to_device(n as usize, &buf, None); + } + if let Err(e) = client.start_stream() { + log::error!("[WASAPI Direct] Start failed: {e}"); + dead.store(true, Ordering::SeqCst); + return; + } + let mut paused = false; + // Halted by Ctl::Stop and waiting for the next track's first chunk. + // NOT a teardown: see the Ctl::Stop arm below. + let mut stopped = false; + let mut draining: Option<(Instant, Sender)> = None; + // Starvation is counted as an EVENT, not per period: one real underrun + // otherwise reports as hundreds while the queue stays empty. + let mut starved = false; + + loop { + // Control first: a pause must not wait behind queued PCM. + match ctl.try_recv() { + Ok(Ctl::Pause) => { + if !paused { + let _ = client.stop_stream(); + paused = true; + } + } + Ok(Ctl::Resume) => { + if paused { + paused = false; + // While `stopped` the client is Reset and unprimed, + // and the re-arm block below owns the restart: it + // pre-rolls BEFORE Start. Starting here as well would + // play a buffer of silence and then, on the second + // Start, fail with AUDCLNT_E_NOT_STOPPED. + if !stopped { + let _ = client.start_stream(); + } + } + } + Ok(Ctl::Drain(done)) => { + if stopped { + // A halted, Reset stream holds nothing and plays + // nothing, so it has drained by definition. Parking + // this until the NEXT track arrives would block the + // caller for the whole drain deadline. + let _ = done.send(true); + } else { + draining = Some((Instant::now() + DRAIN_DEADLINE, done)); + } + } + Ok(Ctl::Stop(ack)) => { + // ALSA's contract, which this mirrors deliberately: + // "Stop PCM immediately (prepare for next playback)" -- + // DROP to halt and discard, then prepare, and the stream + // OBJECT survives because the player reuses it for the next + // track. Breaking here instead made the stream single-use: + // the player calls engine.stop() on every track change, so + // the render thread died before the first track's audio had + // even finished buffering and every later write answered + // "render thread is gone". Same shape as the drain defect, + // second instance -- a control that means "get ready" was + // read as "we are done". + // The results are NOT ignored. If the halt half fails the + // stream is not in a state a later Start can trust, and + // reporting it as stopped-and-ready would be the same kind + // of lie this whole fix exists to remove. + let halted = client + .stop_stream() + .map_err(|e| format!("stop_stream: {e}")) + .and_then(|()| { + client + .reset_stream() + .map_err(|e| format!("reset_stream: {e}")) + }); + carry.clear(); + // Discard what the previous track had already handed over, + // the way DROP discards queued frames. Safe here and ONLY + // here because `stop()` blocks on the ack below: nothing + // written after stop() returns can be in flight yet. + while pcm.try_recv().is_ok() {} + // A drain waiting on this stream will never see its audio + // play out -- it was just thrown away. Say so. + if let Some((_, done)) = draining.take() { + let _ = done.send(false); + } + starved = false; + paused = false; + stopped = true; + // Acknowledge before any bail-out, so a caller blocked in + // stop() is never stranded by a failure. + let _ = ack.send(()); + if let Err(e) = halted { + log::error!("[WASAPI Direct] halt failed ({e}); stream is dead"); + dead.store(true, Ordering::SeqCst); + break; + } + } + Ok(Ctl::Shutdown) | Err(TryRecvError::Disconnected) => break, + Err(TryRecvError::Empty) => {} + } + + // Re-arm: hold the device open, idle, until the next track's first + // chunk arrives, then pre-roll and start again exactly as the + // initial start did. Keeping the client open is the point -- the + // exclusive lock stays ours across the track change. + if stopped && !paused { + match pcm.recv_timeout(Duration::from_millis(20)) { + Ok(chunk) => carry.extend(chunk), + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => break, + } + // Pre-roll BEFORE Start, and only claim the stream is running + // if the pre-roll actually happened: a Start over an unprimed + // exclusive buffer plays a period of silence and then starves. + let primed = client + .get_available_space_in_frames() + .map_err(|e| format!("get_available_space_in_frames: {e}")) + .and_then(|n| { + let need = n as usize * block_align; + let take = need.min(carry.len()); + let mut buf: Vec = carry.drain(..take).collect(); + buf.resize(need, 0); + render + .write_to_device(n as usize, &buf, None) + .map_err(|e| format!("write_to_device: {e}")) + }); + if let Err(e) = primed { + log::error!("[WASAPI Direct] Re-arm prefill failed ({e}); stream is dead"); + dead.store(true, Ordering::SeqCst); + break; + } + if let Err(e) = client.start_stream() { + log::error!("[WASAPI Direct] Restart after stop failed: {e}"); + dead.store(true, Ordering::SeqCst); + break; + } + stopped = false; + } + + if paused { + std::thread::sleep(Duration::from_millis(period_ms as u64)); + continue; + } + + // Wait for the device to want more. + match timing { + WasapiTiming::Events => { + if let Some(h) = handle.as_ref() { + // GENEROUS on purpose. This deadline exists to stop the + // thread hanging on a device that died, not to detect + // glitches - the device's own buffer is what runs dry, + // and that is not observable from here. + // + // Measured: at a 3 ms period, a 2x deadline expired on + // 10% of wakeups (337 in 10 s) on a stream that was + // otherwise clean, because Windows scheduling simply is + // not that punctual. The spike waited 1000 ms and saw + // none in ten minutes. Treating those expiries as + // glitches invented a fault that was not there. + if h.wait_for_event(event_timeout_ms).is_err() { + timeouts.fetch_add(1, Ordering::Relaxed); + continue; + } + } + } + WasapiTiming::Polling => { + std::thread::sleep(Duration::from_millis((period_ms / 2).max(1) as u64)); + } + } + + let n = match client.get_available_space_in_frames() { + Ok(n) => n as usize, + Err(e) => { + log::warn!("[WASAPI Direct] device gone ({e}); stream is dead"); + dead.store(true, Ordering::SeqCst); + break; + } + }; + if n == 0 { + continue; + } + let need = n * block_align; + + // Pull what has arrived, and be willing to WAIT a little for the + // rest. The device still holds a full period of audio at this + // point, so a short wait costs nothing; declaring an underrun the + // moment the feeder happens to be mid-pack costs a false glitch + // count on every jittery period. Measured: try_recv alone reported + // 339 "underruns" in 10 s on a stream that never actually starved, + // while the same hardware ran 10 minutes clean in the spike. + let pull_deadline = Instant::now() + Duration::from_micros(slack_us); + while carry.len() < need { + match pcm.try_recv() { + Ok(chunk) => { + carry.extend(chunk); + continue; + } + Err(TryRecvError::Disconnected) => break, + Err(TryRecvError::Empty) => {} + } + let now = Instant::now(); + if now >= pull_deadline { + break; + } + match pcm.recv_timeout(pull_deadline - now) { + Ok(chunk) => carry.extend(chunk), + Err(_) => break, + } + } + + if carry.is_empty() { + // Nothing at all: the documented cheap path is a silent buffer. + let mut flags = BufferFlags::none(); + flags.silent = true; + let filler = vec![0u8; need]; + if render.write_to_device(n, &filler, Some(flags)).is_err() { + dead.store(true, Ordering::SeqCst); + break; + } + // Trailing silence during a DRAIN is the drain working, not a + // fault. Counting it reported 2 underruns on every clean run. + if draining.is_none() && !starved { + underruns.fetch_add(1, Ordering::Relaxed); + starved = true; + } + // The queue is empty, so a drain in flight is COMPLETE. Answer + // the caller and carry on: the stream stays open for the next + // source, which is what makes gapless work. Ending the thread + // here - as this used to - left a later append writing into a + // dead render thread. + if draining + .as_ref() + .map(|(d, _)| Instant::now() >= *d) + .unwrap_or(false) + || draining.is_some() + { + if let Some((_, done)) = draining.take() { + let _ = done.send(true); + } + } + continue; + } + + let mut buf = Vec::with_capacity(need); + let take = need.min(carry.len()); + buf.extend(carry.drain(..take)); + if buf.len() < need { + // Partial period. Zero-padding keeps the samples we DO have + // instead of dropping them, which a silent buffer would. + buf.resize(need, 0); + // Counted EVERY time, unlike the fully-empty case: a partial + // period is a discrete glitch that just happened, whereas an + // empty queue is one starvation that lasts. Same reasoning as + // above for why a drain's last partial period is not a fault. + if draining.is_none() { + underruns.fetch_add(1, Ordering::Relaxed); + } + } + if render.write_to_device(n, &buf, None).is_err() { + dead.store(true, Ordering::SeqCst); + break; + } + starved = false; + } + + let _ = client.stop_stream(); + let _ = client.reset_stream(); + let n = underruns.load(Ordering::Relaxed); + let t = timeouts.load(Ordering::Relaxed); + if n > 0 || t > 0 { + log::warn!("[WASAPI Direct] stream closed with {n} underrun(s), {t} event timeout(s)"); + } else { + log::info!("[WASAPI Direct] stream closed clean"); + } + } + + impl Inner { + pub(super) fn new( + endpoint_id: &str, + rate: u32, + channels: u16, + timing: WasapiTiming, + ) -> Result { + let (reply_tx, reply_rx) = channel::(); + let (pcm_tx, pcm_rx) = sync_channel::>(PCM_QUEUE_PERIODS); + let (ctl_tx, ctl_rx) = channel::(); + let dead = Arc::new(AtomicBool::new(false)); + let underruns = Arc::new(AtomicU64::new(0)); + + let id = endpoint_id.to_string(); + let dead_t = Arc::clone(&dead); + let under_t = Arc::clone(&underruns); + let timeouts = Arc::new(AtomicU64::new(0)); + let time_t = Arc::clone(&timeouts); + let join = std::thread::Builder::new() + .name("qbz-wasapi-render".to_string()) + .spawn(move || { + render_thread( + id, rate, channels, timing, reply_tx, pcm_rx, ctl_rx, dead_t, under_t, + time_t, + ) + }) + .map_err(|e| format!("spawn render thread: {e}"))?; + + // The thread answers once it has opened or given up. No timeout: + // every call it makes before replying is a bounded COM call, and a + // deadline here would race a slow-but-fine driver. + let info = match reply_rx.recv() { + Ok(Ok(info)) => info, + Ok(Err(e)) => { + let _ = join.join(); + return Err(e); + } + Err(_) => { + let _ = join.join(); + return Err("render thread died before reporting".to_string()); + } + }; + + Ok(Inner { + pcm: pcm_tx, + ctl: ctl_tx, + dead, + underruns, + timeouts, + info, + join: Some(join), + }) + } + + fn check_alive(&self) -> Result<(), String> { + if self.dead.load(Ordering::SeqCst) { + // The player's reaction is to reinitialise, which is why this + // has to be an error and not a silent no-op. + return Err("device invalidated".to_string()); + } + Ok(()) + } + + pub(super) fn write_bytes(&self, bytes: Vec) -> Result<(), String> { + self.check_alive()?; + // BLOCKS when the queue is full. That is the point: it is the + // back-pressure that snd_pcm_writei gives the ALSA path for free. + self.pcm + .send(bytes) + .map_err(|_| "render thread is gone".to_string()) + } + + pub(super) fn info(&self) -> &WasapiOpenInfo { + &self.info + } + + pub(super) fn underruns(&self) -> u64 { + self.underruns.load(Ordering::Relaxed) + } + + pub(super) fn timeouts(&self) -> u64 { + self.timeouts.load(Ordering::Relaxed) + } + + pub(super) fn drain_blocking(&self) -> Result<(), String> { + self.check_alive()?; + let (tx, rx) = channel::(); + self.send_ctl(Ctl::Drain(tx))?; + match rx.recv_timeout(DRAIN_DEADLINE + Duration::from_secs(2)) { + Ok(true) => Ok(()), + Ok(false) => { + // A Stop threw the queue away while this drain waited. The + // audio did NOT play out, and the caller's writer thread + // treats a successful drain as "the source finished", so + // saying Ok here would report a track as played that was + // discarded mid-flight. + Err("drain cancelled: the stream was stopped".to_string()) + } + Err(_) => { + // Not an error to the caller: a stuck drain must not wedge + // playback, and the stream is still usable. + log::warn!("[WASAPI Direct] drain did not complete within its deadline"); + Ok(()) + } + } + } + + /// Halt, discard, and leave the stream ready for the next track -- + /// BLOCKING until the render thread confirms it has done so. + /// + /// The blocking is the contract, not politeness. The player sends this + /// on a track change and then immediately queues the next track's PCM; + /// if this returned early, the render thread's purge would run after + /// that audio had been handed over and would eat it. + pub(super) fn stop_blocking(&self) -> Result<(), String> { + self.check_alive()?; + let (tx, rx) = channel::<()>(); + self.send_ctl(Ctl::Stop(tx))?; + match rx.recv_timeout(STOP_DEADLINE) { + Ok(()) => Ok(()), + Err(_) => Err("stop was not acknowledged by the render thread".to_string()), + } + } + + pub(super) fn send_ctl(&self, c: Ctl) -> Result<(), String> { + self.ctl + .send(c) + .map_err(|_| "render thread is gone".to_string()) + } + } + + impl Drop for Inner { + fn drop(&mut self) { + // MUST be Shutdown, not Stop. `Stop` means "prepare for the next + // track" and the thread deliberately survives it -- sending it here + // parked the thread waiting for audio that would never come and + // hung `join()` forever, at process exit, every time. + let _ = self.ctl.send(Ctl::Shutdown); + if let Some(join) = self.join.take() { + // Joining without a deadline is safe because every wait inside + // the loop is bounded: the event wait by `event_timeout_ms`, + // the re-arm wait by 20 ms, both looping back to this check. + let _ = join.join(); + } + } + } +} + +// =========================================================================== +// The public API, one body per platform +// =========================================================================== + +#[cfg(windows)] +impl WasapiDirectStream { + /// Open `endpoint_id` in EXCLUSIVE mode at `sample_rate`. + /// + /// `endpoint_id` is the WASAPI endpoint id (`{0.0.0...}.{guid}`), never a + /// friendly name: Windows gives several endpoints the same name. + pub fn new( + endpoint_id: &str, + sample_rate: u32, + channels: u16, + timing: WasapiTiming, + ) -> Result { + Ok(Self { + inner: imp::Inner::new(endpoint_id, sample_rate, channels, timing)?, + }) + } + + pub fn open_info(&self) -> &WasapiOpenInfo { + self.inner.info() + } + + /// Periods the feeder could not fill in time. Zero is the pass condition. + pub fn underruns(&self) -> u64 { + self.inner.underruns() + } + + /// Periods where the device's own event never arrived. A different fault + /// from an underrun, and counted separately so they stop hiding each other. + pub fn event_timeouts(&self) -> u64 { + self.inner.timeouts() + } + + pub fn write_f32(&self, samples: &[f32]) -> Result<(), String> { + let rung = self.inner.info().rung; + let mut out = Vec::with_capacity(samples.len() * rung.container_bits() as usize / 8); + pack_f32(samples, &rung, &mut out); + self.inner.write_bytes(out) + } + + pub fn write(&self, samples: &[i16]) -> Result<(), String> { + let f: Vec = samples.iter().map(|&s| s as f32 / 32768.0).collect(); + self.write_f32(&f) + } + + pub fn pause(&self) -> Result<(), String> { + self.inner.send_ctl(imp::Ctl::Pause) + } + + pub fn resume(&self) -> Result<(), String> { + self.inner.send_ctl(imp::Ctl::Resume) + } + + /// Play out what is queued, then return - BLOCKING, like the ALSA drain + /// this shares a writer thread with. Returning early would tell the + /// player's writer thread the source had finished while it was still + /// playing. + /// + /// Bounded: a device that stopped consuming must not hang the writer, so a + /// drain that overruns its deadline returns anyway and says so. + pub fn drain(&self) -> Result<(), String> { + self.inner.drain_blocking() + } + + pub fn stop(&self) -> Result<(), String> { + self.inner.stop_blocking() + } + + pub fn sample_rate(&self) -> u32 { + self.inner.info().rate + } + + pub fn channels(&self) -> u16 { + self.inner.info().channels + } + + pub fn bit_perfect_mode(&self) -> BitPerfectMode { + self.inner.info().rung.bit_perfect_mode() + } +} + +/// Off Windows every constructor refuses, and the teardown calls succeed so a +/// caller written against the Windows arm still compiles and unwinds cleanly. +/// Mirrors `alsa_direct.rs`'s non-Linux stub. +#[cfg(not(windows))] +impl WasapiDirectStream { + const ONLY: &'static str = "WASAPI is only available on Windows"; + + pub fn new( + _endpoint_id: &str, + _sample_rate: u32, + _channels: u16, + _timing: WasapiTiming, + ) -> Result { + Err(Self::ONLY.to_string()) + } + + pub fn underruns(&self) -> u64 { + 0 + } + + pub fn event_timeouts(&self) -> u64 { + 0 + } + + pub fn write_f32(&self, _samples: &[f32]) -> Result<(), String> { + Err(Self::ONLY.to_string()) + } + + pub fn write(&self, _samples: &[i16]) -> Result<(), String> { + Err(Self::ONLY.to_string()) + } + + pub fn pause(&self) -> Result<(), String> { + Ok(()) + } + + pub fn resume(&self) -> Result<(), String> { + Ok(()) + } + + pub fn drain(&self) -> Result<(), String> { + Ok(()) + } + + pub fn stop(&self) -> Result<(), String> { + Ok(()) + } + + pub fn sample_rate(&self) -> u32 { + 44100 + } + + pub fn channels(&self) -> u16 { + 2 + } + + pub fn bit_perfect_mode(&self) -> BitPerfectMode { + BitPerfectMode::Disabled + } +} + +/// Same forwarding as the ALSA side. Both platforms' constructors exist on +/// every host (one of them refusing), so this impl is unconditional too. +impl crate::backend::DirectSink for WasapiDirectStream { + fn write_f32(&self, samples: &[f32]) -> Result<(), String> { + WasapiDirectStream::write_f32(self, samples) + } + fn drain(&self) -> Result<(), String> { + WasapiDirectStream::drain(self) + } + fn stop(&self) -> Result<(), String> { + WasapiDirectStream::stop(self) + } + fn sample_rate(&self) -> u32 { + WasapiDirectStream::sample_rate(self) + } + fn channels(&self) -> u16 { + WasapiDirectStream::channels(self) + } + fn log_label(&self) -> &'static str { + "WASAPI Direct Engine" + } +} diff --git a/crates/qbz-cache/Cargo.toml b/crates/qbz-cache/Cargo.toml index b8584512f..df8584d09 100644 --- a/crates/qbz-cache/Cargo.toml +++ b/crates/qbz-cache/Cargo.toml @@ -17,5 +17,5 @@ dirs = "6" filetime = "0.2" # Image cache index + URL hashing -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } md-5 = "0.10" diff --git a/crates/qbz-cache/src/audio_cache.rs b/crates/qbz-cache/src/audio_cache.rs index 1992f7086..cf03b8520 100644 --- a/crates/qbz-cache/src/audio_cache.rs +++ b/crates/qbz-cache/src/audio_cache.rs @@ -251,6 +251,19 @@ impl AudioCache { } } + /// Drop every L1 in-memory entry WITHOUT touching the L2 disk cache or + /// the fetching/failed bookkeeping. The memory-pressure watchdog's + /// relief valve: RAM is the scarce resource under pressure, the disk + /// files cost nothing to keep and save a re-download on recovery. + pub fn evict_all_memory(&self) { + let mut state = self.state.lock().unwrap(); + let dropped = state.tracks.len(); + state.tracks.clear(); + state.access_order.clear(); + state.current_size = 0; + log::info!("L1 memory cache evicted ({dropped} tracks dropped, L2 disk cache kept)"); + } + /// Get cache statistics pub fn stats(&self) -> CacheStats { let state = self.state.lock().unwrap(); diff --git a/crates/qbz-cache/src/playback_cache.rs b/crates/qbz-cache/src/playback_cache.rs index 3516dce24..00a4149ab 100644 --- a/crates/qbz-cache/src/playback_cache.rs +++ b/crates/qbz-cache/src/playback_cache.rs @@ -244,6 +244,83 @@ impl PlaybackCache { } } + /// Insert a track whose bytes are streamed in by `fill` rather than + /// passed as an already-materialized slice. Used by the low-memory + /// oversized-track path, where a second full in-RAM copy of the track + /// just to hand `insert` a `&[u8]` is exactly what we're avoiding — + /// `fill` typically chunked-copies straight out of the playback buffer. + /// + /// `size_hint` is the expected byte count: it drives the too-large + /// rejection and pre-write eviction; the entry records the actual bytes + /// written. + pub fn insert_from(&self, track_id: u64, size_hint: u64, fill: F) + where + F: FnOnce(&mut fs::File) -> std::io::Result, + { + // Don't cache if larger than max size + if size_hint > self.max_size_bytes { + log::debug!( + "Track {} too large for playback cache ({} MB > {} MB)", + track_id, + size_hint / (1024 * 1024), + self.max_size_bytes / (1024 * 1024) + ); + return; + } + + // Evict old entries if needed + self.evict_if_needed(size_hint); + + let path = self.track_path(track_id); + + match fs::File::create(&path) { + Ok(mut file) => match fill(&mut file) { + Ok(written) => { + let size = written as u64; + let mut state = self.state.lock().unwrap(); + + // Remove old entry if exists + if let Some(old) = state.entries.remove(&track_id) { + state.current_size = state.current_size.saturating_sub(old.size_bytes); + } + + state.entries.insert( + track_id, + CacheEntry { + track_id, + size_bytes: size, + last_accessed: SystemTime::now(), + }, + ); + state.current_size += size; + + log::info!( + "Saved track {} to playback cache ({} KB). Total: {} MB / {} MB", + track_id, + size / 1024, + state.current_size / (1024 * 1024), + self.max_size_bytes / (1024 * 1024) + ); + } + Err(e) => { + log::warn!( + "Failed to stream playback cache file for track {}: {}", + track_id, + e + ); + let _ = fs::remove_file(&path); + } + }, + Err(e) => { + log::warn!( + "Failed to create playback cache file for track {}: {}", + track_id, + e + ); + } + } + } + /// Evict oldest entries to make room for new data fn evict_if_needed(&self, needed_bytes: u64) { let mut state = self.state.lock().unwrap(); diff --git a/crates/qbz-cast/Cargo.toml b/crates/qbz-cast/Cargo.toml index 0c1331e43..1d9ed8a35 100644 --- a/crates/qbz-cast/Cargo.toml +++ b/crates/qbz-cast/Cargo.toml @@ -8,14 +8,8 @@ description = "Casting support for QBZ - Chromecast, DLNA" # Chromecast rust_cast = "0.21" -# TLS provider selection. rust_cast uses rustls; in the multi-crate binary both -# the aws-lc-rs and ring providers end up enabled (reqwest pulls ring), so -# rustls can't auto-pick one and panics on connect. We install aws-lc-rs -# explicitly before connecting (see `ensure_crypto_provider`). -rustls = { version = "0.23", features = ["aws_lc_rs"] } - # mDNS discovery -mdns-sd = "0.11" +mdns-sd = "0.21" # Local HTTP server for media streaming tiny_http = "0.12" diff --git a/crates/qbz-cast/src/chromecast/device.rs b/crates/qbz-cast/src/chromecast/device.rs index e0906a456..a2f32c64a 100644 --- a/crates/qbz-cast/src/chromecast/device.rs +++ b/crates/qbz-cast/src/chromecast/device.rs @@ -91,7 +91,14 @@ impl CastDeviceConnection { /// Disconnect from device pub fn disconnect(&mut self) -> Result<(), CastError> { + // CLOSE the media receiver app, not just our transport to it. A bare + // transport disconnect leaves the Default Media Receiver running on + // the device with our last item on its screen (and, if it was + // playing, still playing) — the "remote queue never clears" symptom. + // Tearing the app down is what the official senders do on "stop + // casting". if let Some(session) = &self.session { + let _ = self.device.receiver.stop_app(session.session_id.as_str()); let _ = self .device .connection @@ -229,7 +236,10 @@ impl CastDeviceConnection { /// Get current media position for seekbar updates pub fn get_media_position(&mut self) -> Result { - let session = self.session.as_ref().ok_or(CastError::NotConnected)?; + // No media app launched yet: the receiver is connected and healthy, + // there is simply nothing to read. Callers must not read this as + // "device gone" (the Qt poll did, and dropped every idle connect). + let session = self.session.as_ref().ok_or(CastError::NoMediaSession)?; let destination = &session.transport_id; let status = self @@ -282,8 +292,15 @@ impl CastDeviceConnection { self.launch_media_app().map(|_| ()) } + /// Transport commands NEVER launch the receiver app: `play`/`pause`/ + /// `stop` on a receiver with nothing loaded used to launch the Default + /// Media Receiver just to ask it for a media session it could not have, + /// which then sat there IDLE — and an IDLE report is what the poll reads + /// as "track ended". Only `load_media` launches the app. fn ensure_media_session(&mut self) -> Result<(String, i32), CastError> { - self.ensure_session()?; + if self.session.is_none() { + return Err(CastError::NoMediaSession); + } let destination = self .session diff --git a/crates/qbz-cast/src/chromecast/discovery.rs b/crates/qbz-cast/src/chromecast/discovery.rs index 63111e458..136535c2e 100644 --- a/crates/qbz-cast/src/chromecast/discovery.rs +++ b/crates/qbz-cast/src/chromecast/discovery.rs @@ -94,7 +94,11 @@ impl DeviceDiscovery { .unwrap_or("Unknown") .to_string(); - let ip = pick_ip(info.get_addresses()) + // mdns-sd 0.21 scopes addresses by interface (ScopedIp); + // the pick only ever needed the bare IpAddr. + let addrs: std::collections::HashSet = + info.get_addresses().iter().map(|a| a.to_ip_addr()).collect(); + let ip = pick_ip(&addrs) .unwrap_or_else(|| "127.0.0.1".to_string()); let device = DiscoveredDevice { diff --git a/crates/qbz-cast/src/chromecast/thread.rs b/crates/qbz-cast/src/chromecast/thread.rs index ecfc30fde..1fc516580 100644 --- a/crates/qbz-cast/src/chromecast/thread.rs +++ b/crates/qbz-cast/src/chromecast/thread.rs @@ -3,7 +3,9 @@ //! Since rust_cast uses Rc (not Arc), it cannot be shared across threads. //! This module provides a thread-safe wrapper using channels. +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::Duration; @@ -53,10 +55,28 @@ pub enum CastCommand { Shutdown, } -/// Thread-safe handle to communicate with the Chromecast thread -pub struct ChromecastHandle { +const TEARDOWN_REPLY_TIMEOUT: Duration = Duration::from_millis(750); + +struct ChromecastThreadLifetime { sender: Sender, _thread: JoinHandle<()>, + valid: AtomicBool, + command_gate: Mutex<()>, +} + +impl Drop for ChromecastThreadLifetime { + fn drop(&mut self) { + self.valid.store(false, Ordering::Release); + let _ = self.sender.send(CastCommand::Shutdown); + } +} + +/// Thread-safe handle to communicate with the Chromecast thread. Clones share +/// one validity fence; a teardown timeout invalidates every clone before the +/// blocked worker is detached from its async caller. +#[derive(Clone)] +pub struct ChromecastHandle { + lifetime: Arc, } impl ChromecastHandle { @@ -68,24 +88,51 @@ impl ChromecastHandle { }); Self { - sender, - _thread: thread, + lifetime: Arc::new(ChromecastThreadLifetime { + sender, + _thread: thread, + valid: AtomicBool::new(true), + command_gate: Mutex::new(()), + }), + } + } + + fn send_command(&self, command: CastCommand) -> Result<(), CastError> { + let _gate = self + .lifetime + .command_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !self.lifetime.valid.load(Ordering::Acquire) { + return Err(CastError::NotConnected); + } + self.lifetime + .sender + .send(command) + .map_err(|_| CastError::Connection("Thread communication error".to_string())) + } + + /// Fence all clones and request worker shutdown. A command already inside + /// rust-cast may finish later, but no handle can enqueue another command. + pub fn invalidate(&self) { + let _gate = self + .lifetime + .command_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if self.lifetime.valid.swap(false, Ordering::AcqRel) { + let _ = self.lifetime.sender.send(CastCommand::Shutdown); } } /// Connect to a Chromecast device pub fn connect(&self, ip: String, port: u16) -> Result<(), CastError> { - // rust_cast opens a rustls TLS channel; make sure a CryptoProvider is - // installed first (the worker thread would otherwise panic). - crate::ensure_crypto_provider(); let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::Connect { - ip, - port, - reply: reply_tx, - }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; + self.send_command(CastCommand::Connect { + ip, + port, + reply: reply_tx, + })?; reply_rx .recv() .map_err(|_| CastError::Connection("Thread response error".to_string()))? @@ -93,21 +140,33 @@ impl ChromecastHandle { /// Disconnect from the current device pub fn disconnect(&self) -> Result<(), CastError> { - let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::Disconnect { reply: reply_tx }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; - reply_rx - .recv() - .map_err(|_| CastError::Connection("Thread response error".to_string()))? + let reply_rx = { + let _gate = self + .lifetime + .command_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !self.lifetime.valid.swap(false, Ordering::AcqRel) { + return Ok(()); + } + let (reply_tx, reply_rx) = mpsc::channel(); + self.lifetime + .sender + .send(CastCommand::Disconnect { reply: reply_tx }) + .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; + // Disconnect permanently fences this handle. Queue shutdown now + // so the worker exits after the disconnect even when the caller + // retains an invalid clone. + let _ = self.lifetime.sender.send(CastCommand::Shutdown); + reply_rx + }; + recv_teardown_reply(reply_rx, TEARDOWN_REPLY_TIMEOUT, "chromecast-disconnect") } /// Get device status pub fn get_status(&self) -> Result { let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::GetStatus { reply: reply_tx }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; + self.send_command(CastCommand::GetStatus { reply: reply_tx })?; reply_rx .recv() .map_err(|_| CastError::Connection("Thread response error".to_string()))? @@ -121,14 +180,12 @@ impl ChromecastHandle { metadata: MediaMetadata, ) -> Result<(), CastError> { let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::LoadMedia { - url, - content_type, - metadata, - reply: reply_tx, - }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; + self.send_command(CastCommand::LoadMedia { + url, + content_type, + metadata, + reply: reply_tx, + })?; reply_rx .recv() .map_err(|_| CastError::Connection("Thread response error".to_string()))? @@ -137,9 +194,7 @@ impl ChromecastHandle { /// Play pub fn play(&self) -> Result<(), CastError> { let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::Play { reply: reply_tx }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; + self.send_command(CastCommand::Play { reply: reply_tx })?; reply_rx .recv() .map_err(|_| CastError::Connection("Thread response error".to_string()))? @@ -148,9 +203,7 @@ impl ChromecastHandle { /// Pause pub fn pause(&self) -> Result<(), CastError> { let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::Pause { reply: reply_tx }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; + self.send_command(CastCommand::Pause { reply: reply_tx })?; reply_rx .recv() .map_err(|_| CastError::Connection("Thread response error".to_string()))? @@ -159,23 +212,24 @@ impl ChromecastHandle { /// Stop pub fn stop(&self) -> Result<(), CastError> { let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::Stop { reply: reply_tx }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; - reply_rx - .recv() - .map_err(|_| CastError::Connection("Thread response error".to_string()))? + self.send_command(CastCommand::Stop { reply: reply_tx })?; + let result = recv_teardown_reply(reply_rx, TEARDOWN_REPLY_TIMEOUT, "chromecast-stop"); + if matches!( + &result, + Err(CastError::Timeout(_) | CastError::Connection(_)) + ) { + self.invalidate(); + } + result } /// Set volume pub fn set_volume(&self, volume: f32) -> Result<(), CastError> { let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::SetVolume { - volume, - reply: reply_tx, - }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; + self.send_command(CastCommand::SetVolume { + volume, + reply: reply_tx, + })?; reply_rx .recv() .map_err(|_| CastError::Connection("Thread response error".to_string()))? @@ -184,12 +238,10 @@ impl ChromecastHandle { /// Seek pub fn seek(&self, position_secs: f64) -> Result<(), CastError> { let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::Seek { - position_secs, - reply: reply_tx, - }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; + self.send_command(CastCommand::Seek { + position_secs, + reply: reply_tx, + })?; reply_rx .recv() .map_err(|_| CastError::Connection("Thread response error".to_string()))? @@ -198,9 +250,7 @@ impl ChromecastHandle { /// Get media position for seekbar updates pub fn get_media_position(&self) -> Result { let (reply_tx, reply_rx) = mpsc::channel(); - self.sender - .send(CastCommand::GetMediaPosition { reply: reply_tx }) - .map_err(|_| CastError::Connection("Thread communication error".to_string()))?; + self.send_command(CastCommand::GetMediaPosition { reply: reply_tx })?; reply_rx .recv() .map_err(|_| CastError::Connection("Thread response error".to_string()))? @@ -213,9 +263,67 @@ impl Default for ChromecastHandle { } } -impl Drop for ChromecastHandle { - fn drop(&mut self) { - let _ = self.sender.send(CastCommand::Shutdown); +fn recv_teardown_reply( + reply: Receiver>, + timeout: Duration, + operation: &'static str, +) -> Result { + match reply.recv_timeout(timeout) { + Ok(result) => result, + Err(RecvTimeoutError::Timeout) => Err(CastError::Timeout(operation.to_string())), + Err(RecvTimeoutError::Disconnected) => Err(CastError::Connection( + "Chromecast worker response unavailable".to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn teardown_reply_preserves_worker_result() { + let (sender, receiver) = mpsc::channel(); + sender.send(Ok::<_, CastError>(7_u8)).unwrap(); + + assert_eq!( + recv_teardown_reply(receiver, Duration::ZERO, "test-operation").unwrap(), + 7 + ); + } + + #[test] + fn teardown_reply_timeout_is_typed_and_sanitized() { + let (_sender, receiver) = mpsc::channel::>(); + + let result = recv_teardown_reply(receiver, Duration::ZERO, "test-operation"); + assert!(matches!( + result, + Err(CastError::Timeout(operation)) if operation == "test-operation" + )); + } + + #[test] + fn teardown_reply_disconnect_is_typed_and_sanitized() { + let (sender, receiver) = mpsc::channel::>(); + drop(sender); + + let result = recv_teardown_reply(receiver, Duration::ZERO, "test-operation"); + assert!(matches!( + result, + Err(CastError::Connection(message)) + if message == "Chromecast worker response unavailable" + )); + } + + #[test] + fn invalidation_fences_every_clone() { + let handle = ChromecastHandle::new(); + let clone = handle.clone(); + + handle.invalidate(); + + assert!(matches!(clone.play(), Err(CastError::NotConnected))); } } diff --git a/crates/qbz-cast/src/dlna/device.rs b/crates/qbz-cast/src/dlna/device.rs index 6c363f21f..886ac3cfa 100644 --- a/crates/qbz-cast/src/dlna/device.rs +++ b/crates/qbz-cast/src/dlna/device.rs @@ -49,20 +49,23 @@ pub struct DlnaConnection { // CurrentURI when a track ends, which would otherwise make a bare Play // (the manual play button, or a late auto-advance) fail permanently. last_set_uri_payload: Option, + // Last 0-100 volume actually sent, so a drag that quantizes to the same + // step does not repeat the SOAP call. + last_volume_sent: Option, // Set by `load_media`, cleared by `play`. When true the URI was just set, so // `play` skips its idle pre-check (the content is already current) and avoids // a redundant SetAVTransportURI. A bare play (manual button / resume) leaves // it false so the pre-check runs. uri_freshly_set: bool, is_playing: bool, + // Consecutive GetTransportInfo failures while GetPositionInfo still + // answers; only the first and every 60th are logged (the poll is 1 Hz). + transport_query_failures: std::sync::atomic::AtomicU32, } impl DlnaConnection { /// Connect to a DLNA device and discover service URLs pub async fn connect(device: DiscoveredDlnaDevice) -> Result { - // Defensive: the device-description fetch may go over TLS in some - // setups; ensure a rustls CryptoProvider is installed (idempotent). - crate::ensure_crypto_provider(); let device_url: Uri = device .url .parse() @@ -109,8 +112,10 @@ impl DlnaConnection { rendering_control_service, current_uri: None, last_set_uri_payload: None, + last_volume_sent: None, uri_freshly_set: false, is_playing: false, + transport_query_failures: std::sync::atomic::AtomicU32::new(0), }) } @@ -459,6 +464,9 @@ impl DlnaConnection { // DLNA volume is typically 0-100 let dlna_volume = ((volume.clamp(0.0, 1.0) * 100.0) as u32).min(100); + if self.last_volume_sent == Some(dlna_volume) { + return Ok(()); + } let payload = format!( "0Master{}", @@ -466,6 +474,7 @@ impl DlnaConnection { ); Self::run_action(rc_service, &self.device_url, "SetVolume", &payload, 10).await?; + self.last_volume_sent = Some(dlna_volume); log::info!("DLNA: Set volume to {}", dlna_volume); Ok(()) @@ -543,8 +552,11 @@ impl DlnaConnection { .map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))? .map_err(|e| DlnaError::Playback(e.to_string()))?; - // Get transport state - let transport_response = tokio::time::timeout( + // Get transport state. A renderer that answers GetPositionInfo but + // faults on GetTransportInfo (#733: a KEF LSX that also faults on Stop + // and Seek) is alive, so this degrades to `UNKNOWN` instead of failing + // the whole read — the caller keeps the position and decides on it. + let transport_response = match tokio::time::timeout( std::time::Duration::from_secs(5), av_service.action( &self.device_url, @@ -553,8 +565,21 @@ impl DlnaConnection { ), ) .await - .map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))? - .map_err(|e| DlnaError::Playback(e.to_string()))?; + { + Ok(Ok(response)) => { + self.transport_query_failures + .store(0, std::sync::atomic::Ordering::Relaxed); + Some(response) + } + Ok(Err(e)) => { + self.note_transport_query_failure(&e.to_string()); + None + } + Err(_) => { + self.note_transport_query_failure("timed out"); + None + } + }; // Parse RelTime (position) - format: "HH:MM:SS" or "H:MM:SS" let rel_time = position_response @@ -570,11 +595,19 @@ impl DlnaConnection { .unwrap_or("0:00:00"); let duration_secs = parse_time_string(track_duration); - // Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.) - let transport_state = transport_response - .get("CurrentTransportState") - .map(|s| s.to_string()) - .unwrap_or_else(|| "UNKNOWN".to_string()); + // Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.). When + // the query itself faulted, fall back to the last transport command + // QBZ sent: the position keeps flowing and the end-stall rule in the + // poll takes over if it freezes at the end. + let transport_state = match transport_response + .as_ref() + .and_then(|r| r.get("CurrentTransportState")) + { + Some(state) => state.to_string(), + None if transport_response.is_some() => "UNKNOWN".to_string(), + None if self.is_playing => "PLAYING".to_string(), + None => "PAUSED_PLAYBACK".to_string(), + }; Ok(DlnaPositionInfo { position_secs, @@ -582,6 +615,18 @@ impl DlnaConnection { transport_state, }) } + + fn note_transport_query_failure(&self, reason: &str) { + let n = self + .transport_query_failures + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + if n == 1 || n % 60 == 0 { + log::warn!( + "DLNA: GetTransportInfo failed ({n} in a row, position still answers): {reason}" + ); + } + } } /// Parse time string "HH:MM:SS" or "H:MM:SS" to seconds diff --git a/crates/qbz-cast/src/dlna/discovery.rs b/crates/qbz-cast/src/dlna/discovery.rs index a950a3a01..0c797a3d5 100644 --- a/crates/qbz-cast/src/dlna/discovery.rs +++ b/crates/qbz-cast/src/dlna/discovery.rs @@ -68,43 +68,62 @@ impl DlnaDiscovery { if let Ok(stream) = discover { let mut stream = std::pin::pin!(stream); - while let Ok(Some(device)) = stream.try_next().await { - let id = device.udn().to_string(); - let name = device.friendly_name().to_string(); - let manufacturer = device.manufacturer().to_string(); - let model = device.model_name().to_string(); - let url = device.url().to_string(); - let ip = device.url().host().unwrap_or("unknown").to_string(); + // Error-tolerant loop: a single device whose description + // fetch/parse fails must NOT kill the whole window (the + // old `while let Ok(...)` broke on the first Err and + // silently dropped every other responder, forever — the + // #667 class of silent failure). Log and continue. + loop { + match stream.try_next().await { + Ok(Some(device)) => { + let id = device.udn().to_string(); + let name = device.friendly_name().to_string(); + let manufacturer = device.manufacturer().to_string(); + let model = device.model_name().to_string(); + let url = device.url().to_string(); + let ip = device.url().host().unwrap_or("unknown").to_string(); - let mut has_av_transport = false; - let mut has_rendering_control = false; + let mut has_av_transport = false; + let mut has_rendering_control = false; - for service in device.services_iter() { - // Version-agnostic match: a renderer advertising only - // AVTransport:2/:3 (not :1) must still count as capable. - let service_type = service.service_type().to_string(); - if service_type.contains(":service:AVTransport:") { - has_av_transport = true; + for service in device.services_iter() { + // Version-agnostic match: a renderer advertising only + // AVTransport:2/:3 (not :1) must still count as capable. + let service_type = service.service_type().to_string(); + if service_type.contains(":service:AVTransport:") { + has_av_transport = true; + } + if service_type.contains(":service:RenderingControl:") { + has_rendering_control = true; + } + } + + log::info!( + "[Cast] DLNA discovered: '{name}' ({manufacturer} {model}) at {ip} avt={has_av_transport} rc={has_rendering_control}" + ); + + let discovered = DiscoveredDlnaDevice { + id: id.clone(), + name, + manufacturer, + model, + ip, + url, + has_av_transport, + has_rendering_control, + }; + + if let Ok(mut state) = state.lock() { + state.devices.insert(id, discovered); + } } - if service_type.contains(":service:RenderingControl:") { - has_rendering_control = true; + Ok(None) => break, // window elapsed + Err(e) => { + log::warn!( + "[Cast] DLNA discovery: skipping a device (description fetch/parse failed): {e}" + ); } } - - let discovered = DiscoveredDlnaDevice { - id: id.clone(), - name, - manufacturer, - model, - ip, - url, - has_av_transport, - has_rendering_control, - }; - - if let Ok(mut state) = state.lock() { - state.devices.insert(id, discovered); - } } } diff --git a/crates/qbz-cast/src/errors.rs b/crates/qbz-cast/src/errors.rs index eaa4c58b2..64a837176 100644 --- a/crates/qbz-cast/src/errors.rs +++ b/crates/qbz-cast/src/errors.rs @@ -23,6 +23,12 @@ pub enum CastError { #[error("Not connected to a cast device")] NotConnected, + /// Connected to the receiver, but no media app / media session has been + /// launched yet (nothing loaded since connect). Distinct from + /// `NotConnected` on purpose: an idle receiver is NOT a lost one. + #[error("Connected, but no media session yet")] + NoMediaSession, + #[error("Device not found: {0}")] DeviceNotFound(String), diff --git a/crates/qbz-cast/src/lib.rs b/crates/qbz-cast/src/lib.rs index d81106c5c..93108b008 100644 --- a/crates/qbz-cast/src/lib.rs +++ b/crates/qbz-cast/src/lib.rs @@ -22,7 +22,7 @@ pub mod media_server; pub use errors::{CastError, DlnaError}; // Re-export media server -pub use media_server::MediaServer; +pub use media_server::{MediaServer, RangeSource}; // Re-export Chromecast types pub use chromecast::{ @@ -38,25 +38,3 @@ pub use dlna::{ /// Cast device type alias for backwards compatibility pub type CastDevice = CastDeviceConnection; -/// Install the rustls process-level `CryptoProvider` (aws-lc-rs) exactly once. -/// -/// `rust_cast` opens a TLS control channel to the Chromecast via rustls. In the -/// full QBZ binary BOTH the `aws-lc-rs` and `ring` providers are enabled (reqwest -/// pulls `ring`, rust_cast pulls `aws-lc-rs`), so rustls can't auto-select one -/// and panics with "Could not automatically determine the process-level -/// CryptoProvider". Installing one explicitly before any TLS use fixes it. -/// -/// Idempotent (a second call is a no-op); harmless if some other component -/// already installed a default (`install_default` then returns Err, ignored). -pub fn ensure_crypto_provider() { - use std::sync::Once; - static INIT: Once = Once::new(); - INIT.call_once(|| { - if rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .is_err() - { - log::debug!("[cast] rustls CryptoProvider already installed"); - } - }); -} diff --git a/crates/qbz-cast/src/media_server.rs b/crates/qbz-cast/src/media_server.rs index 059e24a66..8253fb7d0 100644 --- a/crates/qbz-cast/src/media_server.rs +++ b/crates/qbz-cast/src/media_server.rs @@ -53,6 +53,20 @@ enum MediaSource { // that is still being served (#550). Data(std::sync::Arc>), File(PathBuf), + /// Bytes that are still ARRIVING while they are served: a Qobuz track + /// being downloaded, or a Plex/Jellyfin/Subsonic stream proxied from its + /// server. The total size is known up front (advertised as + /// Content-Length); reads past the buffered edge block until the bytes + /// exist. This is what turns "download 100 MB, then play" into "play + /// after the first segment". + Reader(Arc), +} + +/// A range-addressable byte source whose bytes may still be arriving. +/// `open` returns a reader positioned at `start` that yields at most `len` +/// bytes and BLOCKS (never errors) while a requested byte is not there yet. +pub trait RangeSource: Send + Sync { + fn open(&self, start: u64, len: u64) -> std::io::Result>; } /// Simple HTTP server for audio streaming to cast devices @@ -112,14 +126,25 @@ impl MediaServer { while !shutdown_clone.load(Ordering::SeqCst) { match server.recv_timeout(Duration::from_millis(250)) { Ok(Some(request)) => { - let response = handle_request( - request.method(), - request.url(), - &request, - &entries_clone, - &token_clone, - ); - let _ = request.respond(response); + // ONE THREAD PER REQUEST. A media body is written at + // the renderer's pace (a 100 MB track takes the whole + // song to drain, and a progressive source blocks on + // bytes that have not arrived), so serving inline + // stalled every other request — the HEAD probe strict + // renderers send before Play, a second Range request + // for a seek — for the duration of the track. + let entries = entries_clone.clone(); + let token = token_clone.clone(); + thread::spawn(move || { + let response = handle_request( + request.method(), + request.url(), + &request, + &entries, + &token, + ); + let _ = request.respond(response); + }); } Ok(None) => {} Err(e) => { @@ -169,10 +194,20 @@ impl MediaServer { /// Register audio data to serve (returns path like "/audio//123") pub fn register_audio(&mut self, id: u64, data: Vec, content_type: &str) -> String { + self.register_audio_shared(id, Arc::new(data), content_type) + } + + /// Same, for bytes another consumer keeps alive as well (no copy). + pub fn register_audio_shared( + &mut self, + id: u64, + data: Arc>, + content_type: &str, + ) -> String { let entry = MediaEntry { content_type: content_type.to_string(), size: data.len() as u64, - source: MediaSource::Data(std::sync::Arc::new(data)), + source: MediaSource::Data(data), }; if let Ok(mut entries) = self.entries.lock() { @@ -188,6 +223,29 @@ impl MediaServer { self.audio_path(id) } + /// Register a range-addressable source whose bytes may still be arriving + /// (see [`RangeSource`]). `size` is the FINAL byte length — it is what the + /// renderer is told in Content-Length, so it must be exact. + pub fn register_reader( + &mut self, + id: u64, + size: u64, + content_type: &str, + source: Arc, + ) -> String { + let entry = MediaEntry { + content_type: content_type.to_string(), + size, + source: MediaSource::Reader(source), + }; + if let Ok(mut entries) = self.entries.lock() { + // Same eviction rule as register_audio (#550). + entries.retain(|k, _| *k == id); + entries.insert(id, entry); + } + self.audio_path(id) + } + /// Register a local file to serve, inferring the content type from the /// extension via [`content_type_for_path`]. pub fn register_file(&mut self, id: u64, file_path: &str) -> Result { @@ -444,6 +502,13 @@ fn open_range( content_range, )) } + MediaSource::Reader(source) => { + let end = end.min(entry.size.saturating_sub(1)); + let start = start.min(end); + let length = end - start + 1; + let reader = source.open(start, length)?; + Ok((reader, length as usize, status_code, content_range)) + } } } @@ -645,6 +710,43 @@ mod tests { assert!(red.contains("***/9")); } + struct SliceSource(Vec); + impl RangeSource for SliceSource { + fn open(&self, start: u64, len: u64) -> std::io::Result> { + let start = start as usize; + let end = (start + len as usize).min(self.0.len()); + Ok(Box::new(std::io::Cursor::new(self.0[start..end].to_vec()))) + } + } + + #[test] + fn open_range_serves_a_range_source_with_exact_lengths() { + let data: Vec = (0..=255u8).collect(); + let entry = MediaEntry { + content_type: "audio/flac".into(), + size: data.len() as u64, + source: MediaSource::Reader(Arc::new(SliceSource(data.clone()))), + }; + // Whole resource. + let (mut r, len, code, cr) = open_range(&entry, None).unwrap(); + let mut got = Vec::new(); + r.read_to_end(&mut got).unwrap(); + assert_eq!((len, code.0, cr), (256, 200, None)); + assert_eq!(got, data); + // A middle range, then one that overshoots the end (clamped). + let (mut r, len, code, cr) = open_range(&entry, Some((10, 19))).unwrap(); + let mut got = Vec::new(); + r.read_to_end(&mut got).unwrap(); + assert_eq!((len, code.0), (10, 206)); + assert_eq!(cr.as_deref(), Some("bytes 10-19/256")); + assert_eq!(got, data[10..=19]); + let (mut r, len, _, _) = open_range(&entry, Some((250, 999))).unwrap(); + let mut got = Vec::new(); + r.read_to_end(&mut got).unwrap(); + assert_eq!(len, 6); + assert_eq!(got, data[250..]); + } + #[test] fn open_range_streams_file_and_ranges() { // A File entry serves the requested range straight from disk with the diff --git a/crates/qbz-core/Cargo.toml b/crates/qbz-core/Cargo.toml index f087a3274..9a046dd01 100644 --- a/crates/qbz-core/Cargo.toml +++ b/crates/qbz-core/Cargo.toml @@ -30,6 +30,13 @@ anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +[target.'cfg(windows)'.dependencies] +# W14: GlobalMemoryStatusEx stands in for /proc/meminfo. 0.61 is already in +# the workspace lock (qbz-local-catalog, qbzd), so this pulls nothing new. +windows-sys = { version = "0.61", features = [ + "Win32_System_SystemInformation", +] } + [features] default = [] # Tier 2 features (to be added later) diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 215bbed1b..000b456c9 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -6,6 +6,15 @@ use std::sync::Arc; use tokio::sync::RwLock; +use qbz_integrations::musicbrainz::cache::{MusicBrainzCache, QobuzArtistMatch}; +use qbz_integrations::musicbrainz::genre::{extract_affinity_seeds, genre_summary, is_broad_genre}; +use qbz_integrations::musicbrainz::location::compute_affinity_score; +use qbz_integrations::musicbrainz::{ + location, AffinitySeeds, AlbumAppearance, ArtistMetadata, ArtistRelationships, + DiscoveryArtist, DiscoveryResponse, LocationCandidate, LocationDiscoveryResponse, + MusicBrainzClient, MusicianAppearances, MusicianConfidence, Period, RelatedArtist, + ResolvedArtist, ResolvedMusician, Tag, +}; use qbz_models::{ ArtistStoryResponse, AssetOrigin, ExternalStreamAsset, StreamQualityInfo, @@ -17,16 +26,9 @@ use qbz_models::{ RepeatMode, SearchAllResults, SearchResultsPage, StreamUrl, Track, TrackToAnalyse, TracksContainer, UserSession, }; -use qbz_integrations::musicbrainz::cache::MusicBrainzCache; -use qbz_integrations::musicbrainz::genre::{extract_affinity_seeds, genre_summary, is_broad_genre}; -use qbz_integrations::musicbrainz::location::compute_affinity_score; -use qbz_integrations::musicbrainz::{ - location, AffinitySeeds, AlbumAppearance, ArtistMetadata, ArtistRelationships, - DiscoveryArtist, DiscoveryResponse, LocationCandidate, LocationDiscoveryResponse, - MusicBrainzClient, MusicianAppearances, MusicianConfidence, Period, RelatedArtist, - ResolvedArtist, ResolvedMusician, Tag, +use qbz_player::{ + PlaybackState, Player, QueueAuthoritySnapshot as PlayerQueueAuthoritySnapshot, QueueManager, }; -use qbz_player::{PlaybackState, Player, QueueManager}; use qbz_qobuz::QobuzClient; use crate::error::CoreError; @@ -264,13 +266,26 @@ pub struct QbzCore { initialized: Arc>, /// D8 guard: true when the current queue was built from an OFFLINE-ONLY /// local playlist — such a queue must never be pushed to the Qobuz - /// Connect cloud. Cleared by every queue REPLACEMENT (`set_queue` / - /// `set_queue_with_order` / `clear_queue`); append-style ops preserve it. - /// Set explicitly by the frontend's local-playlist play path right after - /// its `set_queue`. + /// Connect cloud. Ordinary queue replacement (`set_queue` / + /// `set_queue_with_order` / `clear_queue`) clears it; append-style ops + /// preserve it. An offline local-playlist replacement sets it atomically + /// through `set_queue_with_offline_only` before publishing QueueUpdated. queue_offline_only: Arc, } +/// Exact in-process playback authority owned by [`QbzCore`]. +/// +/// The player snapshot preserves the queue mechanics while `offline_only` +/// preserves the cloud-publication policy coupled to that queue. Keeping both +/// opaque prevents a delegation handoff from restoring an offline playlist as +/// if it were eligible for QConnect synchronization. +#[must_use = "an authority snapshot must be retained until it is restored or deliberately discarded"] +#[derive(Clone)] +pub struct QueueAuthoritySnapshot { + queue: PlayerQueueAuthoritySnapshot, + offline_only: bool, +} + impl QbzCore { /// Create a new QbzCore instance with the given frontend adapter and player /// @@ -291,17 +306,19 @@ impl QbzCore { } /// Mark (or unmark) the current queue as built from an OFFLINE-ONLY local - /// playlist (D8). Call right after the `set_queue` that loaded it. - pub fn set_queue_offline_only(&self, on: bool) { + /// playlist (D8). Queue replacement code should prefer + /// [`Self::set_queue_with_offline_only`] so the flag is visible before the + /// `QueueUpdated` event can trigger cloud synchronization. + fn set_queue_offline_only(&self, on: bool) { self.queue_offline_only - .store(on, std::sync::atomic::Ordering::Relaxed); + .store(on, std::sync::atomic::Ordering::Release); } /// True when the current queue originates from an offline-only local /// playlist — QConnect must skip its cloud queue push. pub fn queue_is_offline_only(&self) -> bool { self.queue_offline_only - .load(std::sync::atomic::Ordering::Relaxed) + .load(std::sync::atomic::Ordering::Acquire) } /// Install a MusicBrainz cache. The frontend owns the data path @@ -484,6 +501,78 @@ impl QbzCore { queue.get_all_tracks() } + /// Capture the complete queue authority state atomically for a temporary + /// renderer handoff. The returned payload is opaque and in-process only. + pub async fn capture_authority_snapshot(&self) -> QueueAuthoritySnapshot { + let queue = self.queue.read().await; + QueueAuthoritySnapshot { + queue: queue.capture_authority_snapshot(), + offline_only: self.queue_is_offline_only(), + } + } + + /// Restore a previously captured queue authority state in one atomic, + /// side-effect-free replacement. The authority coordinator publishes the + /// restored projection only after sealing its origin and generation. + pub async fn restore_authority_snapshot(&self, snapshot: QueueAuthoritySnapshot) { + let queue = self.queue.write().await; + self.set_queue_offline_only(snapshot.offline_only); + queue.restore_authority_snapshot(snapshot.queue); + } + + /// Commit an authority stamp and restore its exact queue as one synchronous + /// visibility boundary. The outer Tokio lock is acquired with `try_write` + /// because authority coordinators call this from their non-async commit + /// gate. If either the queue is busy or `commit` rejects, ownership of the + /// opaque snapshot is returned unchanged and no queue state is mutated. + pub fn try_commit_authority_snapshot( + &self, + snapshot: QueueAuthoritySnapshot, + commit: F, + ) -> Result + where + F: FnOnce() -> bool, + { + let QueueAuthoritySnapshot { + queue: queue_snapshot, + offline_only, + } = snapshot; + let Ok(queue) = self.queue.try_write() else { + return Err(QueueAuthoritySnapshot { + queue: queue_snapshot, + offline_only, + }); + }; + if !commit() { + return Err(QueueAuthoritySnapshot { + queue: queue_snapshot, + offline_only, + }); + } + self.set_queue_offline_only(offline_only); + queue.restore_authority_snapshot(queue_snapshot); + Ok(queue.get_state()) + } + + /// Capture queue rows, cursor, and oldest-first playback history under one + /// queue lock for session persistence. + pub async fn get_persistable_queue_state( + &self, + ) -> (Vec, Option, Vec) { + let queue = self.queue.read().await; + queue.get_persistable_state() + } + + /// Restore the oldest-first playback history after restoring its queue. + pub async fn restore_queue_history(&self, history: Vec) { + let queue = self.queue.write().await; + queue.restore_history_indices(history); + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + } + /// Get the full queue state without the upcoming/history caps that /// `get_queue_state` applies. Used by clients that paginate the /// upcoming list and need the complete play history (Queue sidebar). @@ -543,8 +632,8 @@ impl QbzCore { /// slot — use when nothing is actively playing and the user wants a full /// reset. pub async fn clear_queue(&self, keep_current: bool) { - self.set_queue_offline_only(false); let queue = self.queue.write().await; + self.set_queue_offline_only(false); queue.clear(keep_current); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), @@ -582,12 +671,35 @@ impl QbzCore { .await; } + /// Add a track at the END of the manual block ("Play later", #442): + /// after every play-next / play-later already queued, before the source + /// resumes. "Add to queue" stays untouched at the absolute end. + pub async fn add_track_later(&self, track: QueueTrack) { + let queue = self.queue.write().await; + queue.add_track_later(track); + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + } + /// Set the entire queue (replaces existing) pub async fn set_queue(&self, tracks: Vec, start_index: Option) { - // Any queue replacement drops the offline-only-playlist stamp; the - // local-playlist play path re-sets it right after when it applies. - self.set_queue_offline_only(false); + self.set_queue_with_offline_only(tracks, start_index, false) + .await; + } + + /// Replace the queue and publish its offline-only origin as one ordered + /// operation. The flag is set before `QueueUpdated`, closing the window in + /// which a QConnect subscriber could upload an offline-only playlist. + pub async fn set_queue_with_offline_only( + &self, + tracks: Vec, + start_index: Option, + offline_only: bool, + ) { let queue = self.queue.write().await; + self.set_queue_offline_only(offline_only); queue.set_queue(tracks, start_index); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), @@ -603,8 +715,8 @@ impl QbzCore { shuffle_enabled: bool, shuffle_order: Option>, ) { - self.set_queue_offline_only(false); let queue = self.queue.write().await; + self.set_queue_offline_only(false); queue.set_queue_with_order(tracks, start_index, shuffle_enabled, shuffle_order); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), @@ -662,6 +774,60 @@ impl QbzCore { success } + /// Reorder one playback-history occurrence inside Queue View. Source + /// coordinates are most-recent-first; the destination is an oldest-first + /// insertion slot in the chronological projection. + pub async fn move_history_entry( + &self, + history_index: usize, + expected_id: u64, + to_slot: usize, + ) -> bool { + let queue = self.queue.write().await; + let success = queue.move_history_entry(history_index, expected_id, to_slot); + if success { + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + } + success + } + + /// Move one played queue occurrence back into Upcoming without cloning + /// it. This is the local Queue View path; remote renderers use + /// `remove_history_entry` after their authoritative insert succeeds. + pub async fn requeue_history_entry( + &self, + history_index: usize, + expected_id: u64, + to_slot: usize, + ) -> bool { + let queue = self.queue.write().await; + let success = queue.requeue_history_entry(history_index, expected_id, to_slot); + if success { + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + } + success + } + + /// Forget the local history occurrence that an authoritative remote + /// renderer just accepted back into its Upcoming sequence. + pub async fn remove_history_entry(&self, history_index: usize, expected_id: u64) -> bool { + let queue = self.queue.write().await; + let success = queue.remove_history_entry(history_index, expected_id); + if success { + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + } + success + } + /// Jump to a specific track by index pub async fn play_index(&self, index: usize) -> Option { let queue = self.queue.write().await; @@ -686,6 +852,40 @@ impl QbzCore { track } + /// Move the cursor to an upcoming Listen List row while retaining every + /// crossed row in chronological history. `expected_id` makes the snapshot + /// index safe when queue mutations race the frontend activation. + pub async fn play_upcoming_at_preserving_timeline( + &self, + upcoming_index: usize, + expected_id: u64, + ) -> Option { + let queue = self.queue.write().await; + let track = queue.play_upcoming_at_preserving_timeline(upcoming_index, expected_id); + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + track + } + + /// Move the cursor back to a Listen List history row without cloning it + /// into the queue. History coordinates are most-recent-first, matching the + /// public queue snapshot. + pub async fn play_history_at_preserving_timeline( + &self, + history_index: usize, + expected_id: u64, + ) -> Option { + let queue = self.queue.write().await; + let track = queue.play_history_at_preserving_timeline(history_index, expected_id); + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + track + } + /// Play `track_id` preferring an offline-cached copy (the offline tier of /// the shared playback tier-walk) before the player's own L1/L2 → network /// path. `offline` is the frontend's open `OfflineCacheState` (None = no @@ -749,6 +949,40 @@ impl QbzCore { self.player.fetch_for_gapless(client, track_id, quality).await } + /// Progressive counterpart of `fetch_for_external_stream_resolved` for a + /// track that is NOT already cached: the bytes are served to the renderer + /// while they download (see `Player::open_external_stream`). + pub async fn open_external_stream_resolved( + &self, + track_id: u64, + quality: Quality, + ) -> Result { + let guard = self.client.read().await; + let client = guard + .as_ref() + .ok_or_else(|| "No Qobuz client available".to_string())?; + self.player + .open_external_stream(client, track_id, quality) + .await + } + + /// Queue a cold Qobuz successor from its initial CMAF buffer instead of + /// materializing the whole file. Used only by the Streaming-only gapless + /// handoff; cached and offline successors keep the byte path above. + pub async fn queue_gapless_streaming( + &self, + track_id: u64, + quality: Quality, + ) -> Result<(), String> { + let guard = self.client.read().await; + let client = guard + .as_ref() + .ok_or_else(|| "No Qobuz client available".to_string())?; + self.player + .queue_next_streaming(client, track_id, quality) + .await + } + /// Resolve a fully-materialized audio asset (bytes + MIME + quality) for an /// EXTERNAL renderer (Chromecast / DLNA). Tier order mirrors /// `fetch_for_gapless_resolved`: L1/L2 player cache -> OFFLINE (local CMAF @@ -818,6 +1052,12 @@ impl QbzCore { queue.peek_upcoming(count) } + /// Inspect the previous queue row without changing the cursor/history. + pub async fn peek_previous_track(&self) -> Option { + let queue = self.queue.read().await; + queue.peek_previous() + } + /// The current queue track, if any (source-aware playback routing). pub async fn current_track(&self) -> Option { let queue = self.queue.read().await; @@ -890,6 +1130,23 @@ impl QbzCore { current_patched } + /// Attach a resolved cover to any queued track in `ids` that has none. + /// Frontend-agnostic hook for art that resolves AFTER the row was + /// enqueued — a disc's cover is fetched, and the fetch outlives the click + /// that started playback. Returns true if the CURRENT track was patched; + /// the caller then re-pushes the now-playing stamp. + pub async fn patch_queue_artwork(&self, ids: &[u64], url: &str) -> bool { + let queue = self.queue.write().await; + let (any, current_patched) = queue.patch_artwork(ids, url); + if any { + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + } + current_patched + } + // ==================== Search & Catalog ==================== /// Search for albums @@ -1243,6 +1500,22 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Lightweight membership fetch: one playlist's track IDS only + /// (`playlist/get?extra=track_ids`). The membership hydrator's probe — + /// no Track objects, no pagination. + pub async fn get_playlist_track_ids( + &self, + playlist_id: u64, + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + client + .get_playlist_track_ids(playlist_id) + .await + .map_err(CoreError::Api) + } + /// Check how many of `track_ids` are already in the Qobuz playlist /// `playlist_id`. Mirrors Tauri's `v2_check_playlist_duplicates` /// (commands_v2/playlists.rs): fetch the playlist's existing track ids and @@ -1279,6 +1552,27 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Move tracks within a playlist (the replacement flow's position step). + /// + /// Takes MEMBERSHIP ids, like [`Self::remove_tracks_from_playlist`]. + /// `insert_before` is the 0-based slot to land in front of. Callers must + /// treat a failure as non-fatal: position is a nicety, and the flows that + /// use this have already made a write that must not be rolled back. + pub async fn update_playlist_tracks_position( + &self, + playlist_id: u64, + playlist_track_ids: &[u64], + insert_before: u32, + ) -> Result<(), CoreError> { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + client + .update_playlist_tracks_position(playlist_id, playlist_track_ids, insert_before) + .await + .map_err(CoreError::Api) + } + /// Create a new playlist pub async fn create_playlist( &self, @@ -2063,6 +2357,14 @@ impl QbzCore { self.musicbrainz.set_enabled(enabled).await; } + /// The core's shared MusicBrainz client — the ONE instance whose enabled + /// flag `musicbrainz_set_enabled` toggles. Frontend code that needs a + /// client must borrow this one: a private `MusicBrainzClient::new()` + /// starts enabled and silently ignores the user's opt-out. + pub fn musicbrainz_client(&self) -> Arc { + Arc::clone(&self.musicbrainz) + } + /// Resolve an artist name to a MusicBrainz id. Returns `None` if no /// confident match is found. pub async fn musicbrainz_resolve_artist( @@ -2444,13 +2746,229 @@ impl QbzCore { // ----- Location / scene discovery ------------------------------------------- +/// Which stage of scene discovery a progress event describes. +/// +/// [`ScenePhase::as_str`] is the ONLY place the wire spelling lives — the +/// scene bridge document (`progress.phase`) is `"searching" | "validating" | +/// "done"` and nothing else may hand-write those literals. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScenePhase { + Searching, + Validating, + Done, +} + +impl ScenePhase { + pub fn as_str(self) -> &'static str { + match self { + ScenePhase::Searching => "searching", + ScenePhase::Validating => "validating", + ScenePhase::Done => "done", + } + } +} + +/// Cooperative cancellation for scene discovery. +/// +/// A generation guard in the frontend only drops the FINAL publish; it does +/// not stop the traffic behind it, and ONE page can run up to 100 sequential +/// Qobuz artist searches on top of rate-limited MusicBrainz calls (which are +/// serialised at ~1.1 s each). This token is checked between every request, so +/// leaving the view actually stops the work instead of merely ignoring it. +/// +/// Deliberately an `AtomicBool` rather than `tokio_util::sync:: +/// CancellationToken`: no new dependency, and `Clone` is free into whichever +/// task owns the discovery. The frontend holds one token per generation and +/// trips the old one on a new scene, on Retry, on view deactivation and on an +/// offline transition. +#[derive(Clone, Debug, Default)] +pub struct CancelToken(Arc); + +impl CancelToken { + /// A fresh, un-tripped token. + pub fn new() -> Self { + Self::default() + } + + /// Trip the token. Idempotent; every clone sees it. + pub fn cancel(&self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(std::sync::atomic::Ordering::Relaxed) + } +} + +/// Why scene discovery could not produce a page. +/// +/// This type exists because the old signature returned `Ok(vec![])` for +/// offline, MusicBrainz-disabled, unauthenticated, rate-limited AND +/// genuinely-empty alike — so the UI's error state was unreachable and all +/// five collapsed into "No matching artists from this scene…". Every variant +/// here is the ERROR class (error state + Retry) except [`Self::Cancelled`], +/// which the user asked for and must paint nothing at all. +/// +/// "Empty" is NOT an error: a query that completed and found nothing still +/// returns `Ok` with an empty `artists` vector. +#[derive(Debug, thiserror::Error)] +pub enum SceneDiscoveryError { + /// No Qobuz client — the core booted offline-tolerant, or never init'd. + #[error("Not initialized")] + NotInitialized, + + /// Qobuz refused on authentication/entitlement grounds. + #[error("Authentication required: {0}")] + NotAuthenticated(String), + + /// EVERY MusicBrainz genre search failed. Also how a disabled MusicBrainz + /// integration surfaces, since the client refuses each call. + #[error("MusicBrainz unavailable: {0}")] + UpstreamUnavailable(String), + + /// There were candidates to validate and EVERY Qobuz search failed. + #[error("Qobuz catalog unavailable: {0}")] + CatalogUnavailable(String), + + /// The caller tripped the [`CancelToken`]. Never user-visible. + #[error("Scene discovery cancelled")] + Cancelled, + + #[error("Internal error: {0}")] + Internal(String), +} + +impl SceneDiscoveryError { + /// True when the view should show its error state with a Retry button. + /// False only for [`Self::Cancelled`], which must leave the UI untouched. + pub fn shows_error_state(&self) -> bool { + !matches!(self, SceneDiscoveryError::Cancelled) + } + + pub fn is_cancelled(&self) -> bool { + matches!(self, SceneDiscoveryError::Cancelled) + } +} + +impl From for CoreError { + fn from(err: SceneDiscoveryError) -> Self { + match err { + SceneDiscoveryError::NotInitialized => CoreError::NotInitialized, + SceneDiscoveryError::NotAuthenticated(msg) => CoreError::AuthFailed(msg), + other => CoreError::Internal(other.to_string()), + } + } +} + +/// Does this Qobuz failure end the whole page, or just this one candidate? +/// +/// Terminal classes abort the loop immediately instead of burning up to 99 +/// more doomed searches: no client, an auth rejection, an entitlement/403 +/// refusal, the open 403 circuit breaker, and offline mode. Everything else +/// (timeouts, 5xx, one odd response) is per-candidate and merely counted, so a +/// single flaky artist cannot fail a whole scene. +fn scene_fatal_qobuz(err: &CoreError) -> Option { + use qbz_qobuz::ApiError; + match err { + CoreError::NotInitialized => Some(SceneDiscoveryError::NotInitialized), + CoreError::AuthRequired => { + Some(SceneDiscoveryError::NotAuthenticated("session missing".into())) + } + CoreError::AuthFailed(msg) => Some(SceneDiscoveryError::NotAuthenticated(msg.clone())), + CoreError::Api(api) => match api { + ApiError::AuthenticationError(_) + | ApiError::InvalidAppId + | ApiError::InvalidAppSecret + | ApiError::IneligibleUser + | ApiError::Forbidden(_) + | ApiError::ForbiddenCircuitOpen(_) => { + Some(SceneDiscoveryError::NotAuthenticated(api.to_string())) + } + ApiError::OfflineMode => Some(SceneDiscoveryError::CatalogUnavailable(api.to_string())), + _ => None, + }, + _ => None, + } +} + +/// Longest we will honour a `Retry-After` before giving up on that request. +/// MusicBrainz's per-IP limiter recovers in ~1 s; anything asking for more +/// than this is an outage, not a hiccup, and the user is staring at a bar. +const SCENE_MAX_BACKOFF_SECS: u64 = 10; + +/// Sleep `secs` (capped), waking every 250 ms so a cancellation lands promptly +/// instead of after a ten-second nap. Returns `false` when cancelled. +async fn scene_backoff(secs: u64, cancel: &CancelToken) -> bool { + let total = secs.min(SCENE_MAX_BACKOFF_SECS); + let slices = total * 4; + for _ in 0..slices { + if cancel.is_cancelled() { + return false; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + !cancel.is_cancelled() +} + +/// Drop blacklisted rows from a scene response and keep `total_candidates` +/// honest. Returns how many were removed. +/// +/// Run on EVERY cache hit: a hit short-circuits the validation loop (which is +/// where the blacklist is normally applied, Tauri-style), so without this a +/// blocked artist would keep reappearing for the whole 30-day TTL. Rows with +/// no usable Qobuz id are kept — fail-open, same as the Slint adapter. +fn apply_scene_blacklist( + response: &mut LocationDiscoveryResponse, + blacklist: &(dyn Fn(u64) -> bool + Send + Sync), +) -> usize { + let before = response.artists.len(); + response.artists.retain(|c| match c.qobuz_id { + Some(id) if id > 0 => !blacklist(id as u64), + _ => true, + }); + let removed = before - response.artists.len(); + response.total_candidates = response.total_candidates.saturating_sub(removed); + if removed > 0 { + response.has_more = response.next_offset < response.total_candidates; + } + removed +} + impl QbzCore { + /// Throw away every cached scene. + /// + /// Call it when the artist blacklist (or its enabled flag) changes, and on + /// account switch. The post-filter on read covers newly-BLOCKED artists; + /// this covers the two cases it cannot: un-blocking (the validation loop + /// had already substituted a different same-name Qobuz artist for that + /// MBID) and a change of account territory (which decides what validates + /// at all and is not yet part of the cache key). + pub fn invalidate_scene_cache(&self) { + if let Ok(guard) = self.musicbrainz_cache.lock() { + if let Some(cache) = guard.as_ref() { + if let Err(e) = cache.clear_scene_cache() { + log::warn!("invalidate_scene_cache: {e}"); + } + } + } + } + /// "Artists from the same place" — given a source artist's MBID, /// area and genre/tag seeds, find other artists from that area - /// who share the genres, validated against Qobuz. Ports - /// v2_discover_artists_by_location's core pipeline (the scene - /// cache + progress events are omitted; subdivision resolution - /// and affinity scoring are kept). + /// who share the genres, validated against Qobuz. + /// + /// Thin wrapper over [`Self::discover_artists_by_location_with_progress`] + /// with no progress sink, no blacklist and a token that is never tripped. + /// It exists because Rust has no default arguments: adding parameters to + /// this method would break every existing caller, and the Slint binary's + /// `location_view.rs` (which does its own blacklist pass on the way out) + /// must keep compiling untouched. + /// + /// NOTE the one behavioural change for existing callers: a page where + /// EVERY upstream search failed is now `Err`, where it used to be `Ok` + /// with an empty vector. That is the entire point of B7 — offline, + /// MusicBrainz-off, unauthenticated and "genuinely nobody" are different + /// answers and the UI cannot say so if the core flattens them. #[allow(clippy::too_many_arguments)] pub async fn discover_artists_by_location( &self, @@ -2463,11 +2981,107 @@ impl QbzCore { limit: usize, offset: usize, ) -> Result { + let no_blacklist = |_id: u64| false; + let no_progress = |_phase: ScenePhase, _pct: u8, _detail: String| {}; + self.discover_artists_by_location_with_progress( + source_mbid, + area_id, + area_name, + country, + genres, + tags, + limit, + offset, + &no_blacklist, + &CancelToken::new(), + &no_progress, + ) + .await + .map_err(CoreError::from) + } + + /// Scene discovery with a progress stream, a blacklist predicate and + /// cancellation. Ports `v2_discover_artists_by_location` end to end — + /// subdivision resolution, affinity scoring, the 30-day scene cache and + /// the four progress emit points. + /// + /// `blacklist` is INJECTED rather than imported because the blacklist + /// store lives in `qbz-app`/the frontend and `qbz-core` must not learn + /// about a frontend module. It is applied in two places, exactly as Tauri + /// did: inside the validation loop (so a blocked artist lets the + /// NEXT-best same-name Qobuz artist take the slot rather than losing the + /// row) and on every cache hit. + /// + /// `progress` fires with `(phase, 0..=100, detail)`. Two paths emit + /// NOTHING and that is deliberate: a cache hit and a no-seed return. + /// Completion is signalled by this future resolving, never by waiting for + /// a `Done` event — a cached scene would otherwise hang the bar at 0 %. + /// The 95 → 100 jump at the end is Tauri's and is kept; the bar's 300 ms + /// width transition smooths it. + #[allow(clippy::too_many_arguments)] + pub async fn discover_artists_by_location_with_progress( + &self, + source_mbid: &str, + area_id: Option<&str>, + area_name: &str, + country: Option<&str>, + genres: Vec, + tags: Vec, + limit: usize, + offset: usize, + blacklist: &(dyn Fn(u64) -> bool + Send + Sync), + cancel: &CancelToken, + progress: &(dyn Fn(ScenePhase, u8, String) + Send + Sync), + ) -> Result { use std::collections::HashMap; + if cancel.is_cancelled() { + return Err(SceneDiscoveryError::Cancelled); + } + + let source_seeds = AffinitySeeds { + genres: genres.clone(), + tags: tags.clone(), + normalized_seeds: genres.iter().chain(tags.iter()).cloned().collect(), + }; + + // The cache is consulted BEFORE the area resolution below, unlike + // Tauri: resolution is a pure function of `area_id`, so keying on the + // pre-resolution identity saves up to five MusicBrainz round-trips on + // every hit. First page only, matching Tauri — paginated pages are + // cheap to recompute and would multiply the key space by the offset. + let cache_key = location::build_scene_cache_key_v1(&location::SceneCacheKey { + source_mbid, + area_id: area_id.unwrap_or(area_name), + country, + seeds: &source_seeds, + page_size: limit, + catalog_scope: None, + }); + + if offset == 0 { + let mut cached = None; + if let Ok(guard) = self.musicbrainz_cache.lock() { + if let Some(cache) = guard.as_ref() { + cached = cache.get_scene_cache(&cache_key).ok().flatten(); + } + } + if let Some(mut hit) = cached { + let removed = apply_scene_blacklist(&mut hit, blacklist); + log::info!( + "[scene] cache hit for {} ({} artists, {} blacklisted out)", + area_name, + hit.artists.len(), + removed + ); + return Ok(hit); + } + } + // Step 0: smart area resolution — city → parent subdivision // for broader results (Leyton → England, Seattle → - // Washington). + // Washington). A failure here is PARTIAL, not fatal: the raw + // area name still searches, just less broadly. let (search_name, display_name) = match area_id { Some(aid) => match self.musicbrainz.resolve_parent_subdivision(aid).await { Ok(Some((subdivision, _))) => { @@ -2476,7 +3090,10 @@ impl QbzCore { .unwrap_or_else(|| subdivision.clone()); (subdivision, display) } - _ => { + other => { + if let Err(e) = other { + log::warn!("[scene] area resolution failed for {area_name:?}: {e}"); + } let display = country .map(|c| format!("{}, {}", c, area_name)) .unwrap_or_else(|| area_name.to_string()); @@ -2491,11 +3108,9 @@ impl QbzCore { } }; - let source_seeds = AffinitySeeds { - genres: genres.clone(), - tags: tags.clone(), - normalized_seeds: genres.iter().chain(tags.iter()).cloned().collect(), - }; + if cancel.is_cancelled() { + return Err(SceneDiscoveryError::Cancelled); + } // Step 2: pick search genres, dropping overly broad tags that // would return the whole country's catalog. @@ -2537,13 +3152,57 @@ impl QbzCore { let mut candidate_map: HashMap)> = HashMap::new(); let per_genre_limit = 200usize; - for genre in &search_genres { - let result = self + let total_genres = search_genres.len(); + // Failures used to be swallowed one by one and the whole method still + // returned Ok(empty). Counted now, because "one genre 503'd" and "the + // integration is off / we are offline" must not look the same. + let mut genre_failures = 0usize; + let mut last_genre_error = String::new(); + for (genre_idx, genre) in search_genres.iter().enumerate() { + if cancel.is_cancelled() { + return Err(SceneDiscoveryError::Cancelled); + } + // Progress: MusicBrainz search phase = 0-40 %. + progress( + ScenePhase::Searching, + ((genre_idx as f64 / total_genres as f64) * 40.0) as u8, + genre.clone(), + ); + + let mut result = self .musicbrainz .search_artists_by_tag_and_area(genre, &search_name, country, per_genre_limit, 0) .await; - let Ok(response) = result else { - continue; + + // Honour the server's Retry-After once. MusicBrainz answers 503 + // (and the proxy 429) with a whole-second header; the client has + // already parsed it into RateLimited(secs). + if let Err(qbz_integrations::IntegrationError::RateLimited(secs)) = &result { + let secs = *secs; + log::info!("[scene] MusicBrainz rate-limited on {genre:?}, backing off {secs}s"); + if !scene_backoff(secs, cancel).await { + return Err(SceneDiscoveryError::Cancelled); + } + result = self + .musicbrainz + .search_artists_by_tag_and_area( + genre, + &search_name, + country, + per_genre_limit, + 0, + ) + .await; + } + + let response = match result { + Ok(response) => response, + Err(e) => { + log::warn!("[scene] genre+area search failed for {genre:?}: {e}"); + genre_failures += 1; + last_genre_error = e.to_string(); + continue; + } }; for artist in &response.artists { if artist.id == source_mbid { @@ -2587,6 +3246,13 @@ impl QbzCore { } } + // Every upstream search failed => this is an ERROR, not an empty + // scene. It is also how "MusicBrainz is disabled" arrives, since the + // client refuses each call with ServiceUnavailable. + if total_genres > 0 && genre_failures == total_genres { + return Err(SceneDiscoveryError::UpstreamUnavailable(last_genre_error)); + } + // Step 3: score + sort. Final = affinity + (genre_hits-1)*15. let mut scored: Vec<(String, String, Vec, i32)> = candidate_map .into_iter() @@ -2611,22 +3277,127 @@ impl QbzCore { // Step 4: validate against Qobuz (exact normalized-name match, // pick the one with the most albums as a popularity proxy). + let total_to_validate = to_validate.len(); + progress( + ScenePhase::Validating, + 40, + format!("{} candidates", total_to_validate), + ); + let mut validated: Vec = Vec::new(); - for (mbid, mb_name, candidate_genres, score) in &to_validate { - let Ok(results) = self.search_artists(mb_name, 5, 0, None).await else { + let mut validation_failures = 0usize; + let mut last_validation_error = String::new(); + for (validate_idx, (mbid, mb_name, candidate_genres, score)) in + to_validate.iter().enumerate() + { + if cancel.is_cancelled() { + return Err(SceneDiscoveryError::Cancelled); + } + // Progress: Qobuz validation phase = 40-95 %, every 5th row. + if validate_idx % 5 == 0 && total_to_validate > 0 { + progress( + ScenePhase::Validating, + 40 + ((validate_idx as f64 / total_to_validate as f64) * 55.0) as u8, + format!("{}/{}", validate_idx, total_to_validate), + ); + } + + let name_normalized = MusicBrainzCache::normalize_name(mb_name); + + // Read-through validation cache. Only the Qobuz projection is + // stored (id/name/image/album count) — those four are a pure + // function of the name. The candidate is rebuilt around them from + // THIS scene's mbid, score and genres, which is exactly what made + // the Tauri-era whole-LocationCandidate cache unreplayable. + let mut cached_match = None; + if let Ok(guard) = self.musicbrainz_cache.lock() { + if let Some(cache) = guard.as_ref() { + cached_match = cache.get_qobuz_artist_match(&name_normalized).ok().flatten(); + } + } + // A hit on a since-blacklisted artist is treated as a MISS, not as + // "no match": re-running the search lets the next-best same-name + // Qobuz artist take the slot, exactly as a cold run would. + let cached_match = + cached_match.filter(|m| !(m.qobuz_id > 0 && blacklist(m.qobuz_id as u64))); + + // RE-ASSERT THE ACCEPTANCE GATE ON REPLAY. The cache KEY is + // `MusicBrainzCache::normalize_name`, which also strips `'`, `"`, + // `.` and `,`; the cold path below accepts a result only when + // `normalize_artist_name` matches, and that one merely + // trims/lowercases/collapses whitespace. The key is therefore + // strictly COARSER than the predicate, so two MusicBrainz + // candidates that the cold path would treat as different names + // share one cache slot — "Guns N' Roses" and "Guns N Roses", + // "Eve." and "Eve". Whichever validated first would then own the + // row for BOTH, for the full 30-day TTL, and a replay that skipped + // the gate is the only way an artist the cold path would have + // REJECTED can end up attributed to a candidate. + // + // Checked here rather than by narrowing the key, so it still holds + // if either normaliser changes independently later. + let cached_match = cached_match + .filter(|m| normalize_artist_name(&m.name) == normalize_artist_name(mb_name)); + + if let Some(hit) = cached_match { + validated.push(LocationCandidate { + mbid: mbid.clone(), + mb_name: mb_name.clone(), + qobuz_id: Some(hit.qobuz_id), + qobuz_name: Some(hit.name), + qobuz_image: hit.image, + score: *score, + genres: candidate_genres.clone(), + qobuz_albums_count: hit.albums_count, + }); continue; + } + + let results = match self.search_artists(mb_name, 5, 0, None).await { + Ok(results) => results, + Err(e) => { + // Terminal classes end the page here rather than burning + // up to 99 more searches that will fail the same way. + if let Some(fatal) = scene_fatal_qobuz(&e) { + log::warn!("[scene] Qobuz validation aborted: {e}"); + return Err(fatal); + } + log::warn!("[scene] Qobuz validation failed for {mb_name:?}: {e}"); + validation_failures += 1; + last_validation_error = e.to_string(); + continue; + } }; let mb_norm = normalize_artist_name(mb_name); + // The blacklist filters INSIDE the match, not after it: when the + // top same-name artist is blocked, the next-best one takes the + // slot instead of the row disappearing. That is Tauri's rule. let best = results .items .iter() - .filter(|a| normalize_artist_name(&a.name) == mb_norm) + .filter(|a| normalize_artist_name(&a.name) == mb_norm && !blacklist(a.id)) .max_by_key(|a| a.albums_count.unwrap_or(0)); if let Some(qobuz_artist) = best { let image_url = qobuz_artist .image .as_ref() .and_then(|img| img.small.as_ref().or(img.thumbnail.as_ref()).cloned()); + // Positive results only. Tauri's negative cache is disabled on + // purpose: "not on Qobuz" stuck for 30 days is how a newly + // added artist stays invisible. + if let Ok(guard) = self.musicbrainz_cache.lock() { + if let Some(cache) = guard.as_ref() { + let _ = cache.set_qobuz_artist_match( + &name_normalized, + &QobuzArtistMatch { + qobuz_id: qobuz_artist.id as i64, + name: qobuz_artist.name.clone(), + image: image_url.clone(), + albums_count: qobuz_artist.albums_count, + }, + ); + } + } validated.push(LocationCandidate { mbid: mbid.clone(), mb_name: mb_name.clone(), @@ -2640,18 +3411,46 @@ impl QbzCore { } } + // There were candidates and not one could be checked — the catalog is + // unreachable. Distinct from "checked them all, none are on Qobuz", + // which is a legitimately empty scene. + if total_to_validate > 0 && validation_failures == total_to_validate { + return Err(SceneDiscoveryError::CatalogUnavailable( + last_validation_error, + )); + } + + progress( + ScenePhase::Done, + 100, + format!("{} artists", validated.len()), + ); + let scene_label = country .map(|c| c.to_string()) .unwrap_or_else(|| display_name.clone()); let next_offset = offset + to_validate.len(); - Ok(LocationDiscoveryResponse { + let response = LocationDiscoveryResponse { artists: validated, scene_label, genre_summary: genre_summary(&source_seeds), total_candidates, has_more: next_offset < total_candidates, next_offset, - }) + }; + + // First page only, non-empty only — Tauri's rule. The stored rows are + // already blacklist-aware; the post-filter on read catches anything + // blocked afterwards, and `invalidate_scene_cache` covers un-blocking. + if offset == 0 && !response.artists.is_empty() { + if let Ok(guard) = self.musicbrainz_cache.lock() { + if let Some(cache) = guard.as_ref() { + let _ = cache.set_scene_cache(&cache_key, &response); + } + } + } + + Ok(response) } } @@ -2929,6 +3728,7 @@ mod tests { title: String::new(), version: None, isrc: None, + audio_info: None, duration: 0, track_number: 0, media_number: None, @@ -2941,7 +3741,11 @@ mod tests { hires_streamable: false, maximum_sampling_rate: None, maximum_bit_depth: None, - streamable: false, + // `None` = the fixture makes no availability claim, which is the + // honest value: nothing in `track_with`'s callers reads it. + streamable: None, + streamable_at: None, + release_date_stream: None, parental_warning: false, playlist_track_id: None, performers: None, @@ -3073,6 +3877,11 @@ mod tests { image: Default::default(), label: None, genre: None, + artist: None, + version: None, + streamable: None, + streamable_at: None, + release_date_stream: None, }); t } diff --git a/crates/qbz-core/src/lib.rs b/crates/qbz-core/src/lib.rs index 6ada87ead..70aeb3ad8 100644 --- a/crates/qbz-core/src/lib.rs +++ b/crates/qbz-core/src/lib.rs @@ -61,5 +61,11 @@ pub mod system_capabilities; pub use qbz_models::{CoreEvent, FrontendAdapter, LoggingAdapter, NoOpAdapter}; // Re-exports from this crate -pub use core::{normalize_artist_name, QbzCore}; +pub use core::{normalize_artist_name, QbzCore, QueueAuthoritySnapshot}; +// Scene discovery ("artists from the same place"). A frontend driving it needs +// all four: the token to cancel a run it has navigated away from, the phase for +// the progress label, and the typed error to tell "offline" and "the upstreams +// all failed" apart from an honest empty result — which the untyped +// `CoreError` cannot express and which the view's error state depends on. +pub use core::{CancelToken, ScenePhase, SceneDiscoveryError}; pub use error::CoreError; diff --git a/crates/qbz-core/src/offline_resolve.rs b/crates/qbz-core/src/offline_resolve.rs index 927073cce..c28ca3ab1 100644 --- a/crates/qbz-core/src/offline_resolve.rs +++ b/crates/qbz-core/src/offline_resolve.rs @@ -33,7 +33,17 @@ pub async fn resolve_offline_bytes( ) -> Option> { let row = { let guard = offline.db.lock().await; - guard.as_ref()?.get_cmaf_bundle(track_id).ok().flatten()? + let db = guard.as_ref()?; + let row = db.get_cmaf_bundle(track_id).ok().flatten()?; + // A play IS an access. LRU eviction (`maintenance::check_cache_limit`) + // orders by `last_accessed_at`, which until now only the download + // wrote — so the cache evicted the tracks the user played most, + // oldest download first. This is the one place every offline play + // funnels through (both frontends and qbzd resolve here). + if let Err(e) = db.touch(track_id) { + log::debug!("[OfflineResolve] touch({track_id}) failed: {e}"); + } + row }; match row.cache_format { diff --git a/crates/qbz-core/src/system_capabilities.rs b/crates/qbz-core/src/system_capabilities.rs index aa272bfab..267ce064f 100644 --- a/crates/qbz-core/src/system_capabilities.rs +++ b/crates/qbz-core/src/system_capabilities.rs @@ -141,14 +141,55 @@ pub fn pressure_from_figures(mem_available_kb: u64, mem_total_kb: u64) -> Memory } } -/// Read /proc/meminfo and return a pressure snapshot relative to the -/// detected MemoryProfile. Returns None on platforms without -/// /proc/meminfo or when the file is unreadable. +/// Windows' answer to `/proc/meminfo`: total and available physical memory, +/// both in KiB, from `GlobalMemoryStatusEx`. +/// +/// W14. Without this the profile falls back to the top tier and +/// `read_memory_pressure` returns `None` forever, so the memory watchdog is +/// blind on Windows and the prefetch/buffer tiers never step down on a small +/// machine. `ullAvailPhys` is the closest analogue to `MemAvailable`: both +/// mean "what a new allocation can expect without swapping". +#[cfg(windows)] +fn global_memory_status_kb() -> Option<(u64, u64)> { + use windows_sys::Win32::System::SystemInformation::{ + GlobalMemoryStatusEx, MEMORYSTATUSEX, + }; + + let mut status = MEMORYSTATUSEX { + dwLength: std::mem::size_of::() as u32, + ..unsafe { std::mem::zeroed() } + }; + + // SAFETY: `status` is a live, correctly sized MEMORYSTATUSEX whose + // `dwLength` is set as the API requires; the call only writes into it. + let ok = unsafe { GlobalMemoryStatusEx(&mut status) }; + if ok == 0 { + return None; + } + + // The fields are bytes; the rest of this module speaks KiB. + Some((status.ullTotalPhys / 1024, status.ullAvailPhys / 1024)) +} + +/// Read the platform's memory figures and return a pressure snapshot relative +/// to the detected MemoryProfile. Returns None on platforms with neither +/// `/proc/meminfo` nor `GlobalMemoryStatusEx`, or when the source is +/// unreadable. pub fn read_memory_pressure() -> Option { - let content = std::fs::read_to_string("/proc/meminfo").ok()?; - let mem_available_kb = parse_meminfo_available_kb(&content)?; - let profile = memory_profile(); - Some(pressure_from_figures(mem_available_kb, profile.mem_total_kb)) + #[cfg(windows)] + { + let (_total_kb, avail_kb) = global_memory_status_kb()?; + let profile = memory_profile(); + return Some(pressure_from_figures(avail_kb, profile.mem_total_kb)); + } + + #[cfg(not(windows))] + { + let content = std::fs::read_to_string("/proc/meminfo").ok()?; + let mem_available_kb = parse_meminfo_available_kb(&content)?; + let profile = memory_profile(); + Some(pressure_from_figures(mem_available_kb, profile.mem_total_kb)) + } } /// Pure detection given `/proc/meminfo` content. Falls back to Normal @@ -164,6 +205,17 @@ pub fn detect_profile_from_meminfo(content: &str) -> MemoryProfile { /// profile on platforms without `/proc/meminfo` (macOS, Windows) or when /// the file is unreadable for any reason. fn detect_profile() -> MemoryProfile { + #[cfg(windows)] + { + // Same Normal-fallback contract as the meminfo path: a probe we could + // not run must never throttle the machine. + return match global_memory_status_kb() { + Some((total_kb, _avail_kb)) => MemoryProfile::from_total_kb(total_kb), + None => MemoryProfile::from_total_kb(u64::MAX), + }; + } + + #[cfg(not(windows))] match std::fs::read_to_string("/proc/meminfo") { Ok(content) => detect_profile_from_meminfo(&content), Err(_) => MemoryProfile::from_total_kb(u64::MAX), diff --git a/crates/qbz-credentials/Cargo.toml b/crates/qbz-credentials/Cargo.toml index 2183b4060..b44557eae 100644 --- a/crates/qbz-credentials/Cargo.toml +++ b/crates/qbz-credentials/Cargo.toml @@ -16,7 +16,6 @@ aes-gcm = "0.10" sha2 = "0.11" rand = "0.10" dirs = "6" -keyring = { version = "3", features = ["async-secret-service", "crypto-rust"] } [dev-dependencies] tempfile = "3" @@ -29,3 +28,19 @@ tempfile = "3" # running" and dead AT-SPI accessibility. async-io futures are runtime- # agnostic, so our portal call still awaits fine inside tokio. ashpd = { version = "0.13", default-features = false, features = ["async-io", "secret"] } +# OS keyring backends, per target. keyring 3 has NO default backend feature: +# without the per-platform one it silently exports the in-memory `mock` +# (keyring-3.6.3/src/lib.rs:274-298), whose writes succeed and whose contents +# die with the process. An unconditional feature list also meant this crate +# only resolved correctly because Cargo unified features with qbz-secrets. +# Same shape as crates/qbz-secrets/Cargo.toml. +keyring = { version = "3", default-features = false, features = [ + "async-secret-service", + "crypto-rust", +] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", default-features = false, features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3", default-features = false, features = ["windows-native"] } diff --git a/crates/qbz-credentials/src/lib.rs b/crates/qbz-credentials/src/lib.rs index 942781552..4a24b1a4a 100644 --- a/crates/qbz-credentials/src/lib.rs +++ b/crates/qbz-credentials/src/lib.rs @@ -261,6 +261,30 @@ fn load_or_create_machine_id_fallback_at(root: &Path) -> Result, String> /// when none of `/etc/machine-id`, `$HOSTNAME`, `$USER` yields a value (the /// caller then falls back to a persisted random id under its config root). fn machine_id_stable_source() -> Option> { + // WINDOWS FIRST, and deliberately before the two env-var fallbacks below. + // + // Windows has no /etc/machine-id, so before this arm existed the chain fell + // through to `HOSTNAME` and then `USER` -- both of which are UNIX names that + // Windows itself never sets, but that an MSYS/Git-Bash environment may. + // Measured on BFCPC-WIN 2026-08-27: Git-Bash sets HOSTNAME as a shell + // variable it does not export (a child process reads None) and leaves USER + // empty, so the chain returned None and the persisted random fallback kept + // the key stable. Stable BY ACCIDENT: any environment that exports either + // name would silently derive a different key, and a key that changes is a + // credentials file that no longer decrypts. + // + // COMPUTERNAME is set by Windows itself, for every process, however the + // app was launched. Owner-approved 2026-08-27, accepting the one-time + // re-authentication that moving the key costs. + #[cfg(windows)] + { + if let Ok(name) = std::env::var("COMPUTERNAME") { + if !name.trim().is_empty() { + return Some(name.as_bytes().to_vec()); + } + } + } + // Try /etc/machine-id first (Linux) if let Ok(id) = fs::read_to_string("/etc/machine-id") { let trimmed = id.trim(); diff --git a/crates/qbz-dac-wizard-core/Cargo.toml b/crates/qbz-dac-wizard-core/Cargo.toml new file mode 100644 index 000000000..77bec4283 --- /dev/null +++ b/crates/qbz-dac-wizard-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "qbz-dac-wizard-core" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "HiFi/DAC setup wizard — frontend-agnostic core (probes, remediations, config generation)." + +[lib] +name = "qbz_dac_wizard_core" +path = "src/lib.rs" + +# Deliberately NO frontend dependency (no slint, no qbz-ui, no cxx-qt). ADR-006: +# The core is frontend-agnostic so Qt and future non-GUI consumers can share it. +# Everything here returns plain data; pushing it into a UI is the frontend's job. +[dependencies] +qbz-audio = { path = "../qbz-audio" } +qbz-models = { path = "../qbz-models" } +qbz-i18n = { path = "../qbz-i18n" } diff --git a/crates/qbz-dac-wizard-core/src/lib.rs b/crates/qbz-dac-wizard-core/src/lib.rs new file mode 100644 index 000000000..a827b6648 --- /dev/null +++ b/crates/qbz-dac-wizard-core/src/lib.rs @@ -0,0 +1,760 @@ +//! HiFi Wizard (DAC setup) — the frontend-agnostic core. +//! +//! # Why this crate exists +//! +//! The retired Slint wizard wrote directly into frontend globals. This crate is +//! the surviving logic, returning plain data: `qbz-qt` drives it through +//! `dac_wizard_qt.rs`. ADR-006, frontend-agnostic core. +//! +//! `qbzd` duplicates parts of this in `tui/wizard_core.rs`. That one is live +//! code and remains a genuine candidate to fold onto this crate. +//! +//! # What is guaranteed +//! +//! READ-ONLY. Nothing here writes a system file, opens a stream or runs a +//! command. It probes (`qbz_audio::health`, sink enumeration, rate queries) +//! and it formats text. That property is the wizard's whole safety story and +//! it is enforced by construction: this crate has no `std::process::Command` +//! and no write-side `std::fs`. + +use qbz_audio::{AudioStackHealth, Distro, InitSystem, Sandbox}; + +// =========================================================================== +// Environment detection (thin re-exports so a frontend needs ONE dependency) +// =========================================================================== + +/// Distro dropdown options, in `Distro::ALL` order (index = the dropdown's). +pub fn distro_options() -> Vec { + Distro::ALL.iter().map(|d| d.label().to_string()).collect() +} + +/// Init-system dropdown options, in `InitSystem::ALL` order. +pub fn init_options() -> Vec { + InitSystem::ALL + .iter() + .map(|i| i.label().to_string()) + .collect() +} + +/// Auto-detected distro, as a dropdown index. +pub fn detected_distro_index() -> i32 { + qbz_audio::detect_distro().index() as i32 +} + +/// Auto-detected init system, as a dropdown index. +pub fn detected_init_index() -> i32 { + qbz_audio::detect_init().index() as i32 +} + +/// `("Flatpak" | "Snap" | "")` — empty when the process is not sandboxed. +pub fn sandbox_name() -> &'static str { + match qbz_audio::detect_sandbox() { + Sandbox::Flatpak => "Flatpak", + Sandbox::Snap => "Snap", + Sandbox::None => "", + } +} + +/// Blocking audio-stack probe (shells out through `qbz_audio`; call it off the +/// UI thread). +pub fn probe_health() -> AudioStackHealth { + qbz_audio::audio_stack_health() +} + +fn distro_at(index: i32) -> Distro { + Distro::ALL + .get(index.max(0) as usize) + .copied() + .unwrap_or(Distro::Other) +} + +fn init_at(index: i32) -> InitSystem { + InitSystem::ALL + .get(index.max(0) as usize) + .copied() + .unwrap_or(InitSystem::Unknown) +} + +// =========================================================================== +// Check step — health verdict + per-distro remediations +// =========================================================================== + +/// One "you're missing X" fix: a caption and a copy-paste command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Remediation { + pub caption: String, + pub command: String, +} + +/// Everything the check step renders, for one (health, distro, init) triple. +#[derive(Clone, Debug, Default)] +pub struct CheckView { + pub health_ok: bool, + /// Empty in a sandbox — there is no verdict to give, only reference + /// commands (the UI shows its sandbox banner instead). + pub summary: String, + pub remediations: Vec, +} + +/// Build the check step from a completed probe and the two dropdown indices +/// (either of which the user may have overridden). +/// +/// In a sandbox the host probes are blind, so no verdict is rendered — the +/// caller gets reference setup commands for the chosen distro/init instead +/// (Tauri-style, which never probed either). +pub fn check_view( + health: AudioStackHealth, + distro_index: i32, + init_index: i32, + sandboxed: bool, +) -> CheckView { + let distro = distro_at(distro_index); + let init = init_at(init_index); + + if sandboxed { + return CheckView { + health_ok: false, + summary: String::new(), + remediations: reference_commands(distro, init), + }; + } + + let rows = remediations(health, distro, init); + let ok = health.is_ready(); + let summary = if ok { + qbz_i18n::t("Your audio stack is ready for bit-perfect playback.") + } else { + let n = rows.len(); + qbz_i18n::tf( + "{} item needs attention before bit-perfect playback will work.", + "{} items need attention before bit-perfect playback will work.", + n as i64, + &[&n.to_string()], + ) + }; + CheckView { + health_ok: ok, + summary, + remediations: rows, + } +} + +/// (caption, copy-paste command) per missing probe, for the given distro. +/// +/// Service/restart commands are INIT-SYSTEM aware per distro (OpenRC on Gentoo, +/// runit on Void, systemd elsewhere), mirroring the Tauri DistroSelector +/// `restartCommands`. Installs and the restart are kept as separate blocks so +/// the multi-line Gentoo guidance never gets `&&`-joined. +fn remediations(h: AudioStackHealth, d: Distro, init: InitSystem) -> Vec { + // NixOS is declarative: you don't `apt/pacman install` pieces — you enable + // the PipeWire module and rebuild. So collapse all the missing pieces into + // one config block instead of per-package commands. + if d == Distro::NixOS { + if h.is_ready() { + return Vec::new(); + } + return vec![Remediation { + caption: qbz_i18n::t("Enable PipeWire in your NixOS configuration"), + command: NIXOS_PIPEWIRE_BLOCK.to_string(), + }]; + } + + let mut out = Vec::new(); + let mut needs_restart = false; + if !h.has_pw_dump { + out.push(Remediation { + caption: qbz_i18n::t("Install the PipeWire tools (pw-dump)"), + command: install(d, pkg_pw_tools(d)), + }); + needs_restart = true; + } + if !h.cpal_sees_pipewire { + // THE Ubuntu no-list / no-playback bug: the ALSA->PipeWire bridge PCM. + out.push(Remediation { + caption: qbz_i18n::t("Install the ALSA bridge so playback can reach PipeWire"), + command: install(d, "pipewire-alsa"), + }); + needs_restart = true; + } + if !h.has_pactl { + out.push(Remediation { + caption: qbz_i18n::t("Install the Pulse compatibility tools (optional fallback)"), + command: install(d, pkg_pulse(d)), + }); + needs_restart = true; + } + if !h.any_devices { + out.push(Remediation { + caption: qbz_i18n::t( + "No sinks detected — reinstall the ALSA UCM profiles, then reboot", + ), + command: install_reinstall(d, "alsa-ucm-conf"), + }); + } + // WirePlumber down, or we just installed something → (re)start the stack + // with the ACTUAL init system running on this machine (not guessed from the + // distro — Gentoo+systemd and Gentoo+OpenRC must differ). + if !h.wireplumber_active || needs_restart { + out.push(Remediation { + caption: qbz_i18n::t("(Re)start the PipeWire audio services"), + command: restart_cmd(init).to_string(), + }); + } + out +} + +/// Init-system-aware "(re)start the audio services" command. PipeWire is a +/// user-session service, so only systemd has a first-class `--user` restart; +/// the others either use their user-service supervisor or a re-login. +fn restart_cmd(init: InitSystem) -> &'static str { + match init { + InitSystem::Systemd => "systemctl --user restart pipewire pipewire-pulse wireplumber", + InitSystem::OpenRc => { + "# OpenRC: PipeWire runs in your user session, not as an OpenRC service.\n\ + # Log out and back in to restart it." + } + InitSystem::Runit => { + "sv restart pipewire wireplumber # if set up as runit user services; otherwise log out and back in" + } + InitSystem::S6 => "# s6: restart via your supervision tree, or log out and back in", + InitSystem::Dinit => "dinitctl restart pipewire wireplumber # or log out and back in", + InitSystem::Unknown => "# Restart PipeWire via your init system, or log out and back in", + } +} + +/// The restart command for a dropdown index — the review step's step 3. +pub fn restart_command_for(init_index: i32) -> String { + restart_cmd(init_at(init_index)).to_string() +} + +fn pkg_pw_tools(d: Distro) -> &'static str { + match d { + // Debian-family (incl. antiX) ship pw-* in pipewire-bin. + Distro::Debian | Distro::Antix => "pipewire-bin", + Distro::Fedora => "pipewire-utils", + // Arch (incl. Artix) / openSUSE / Gentoo / Void ship pw-* with pipewire. + _ => "pipewire", + } +} + +fn pkg_pulse(d: Distro) -> &'static str { + match d { + Distro::Debian | Distro::Antix => "pipewire-pulse pulseaudio-utils", + Distro::Fedora => "pipewire-pulseaudio", + _ => "pipewire-pulse", + } +} + +fn install(d: Distro, pkgs: &str) -> String { + match d { + // Package manager is a property of the distro family, NOT the init. + Distro::Debian | Distro::Antix => format!("sudo apt install {pkgs}"), + Distro::Fedora => format!("sudo dnf install {pkgs}"), + Distro::Arch | Distro::Artix => format!("sudo pacman -S {pkgs}"), + Distro::OpenSuse => format!("sudo zypper install {pkgs}"), + Distro::Gentoo => format!("sudo emerge {pkgs} # package name may differ on Gentoo"), + Distro::Void => format!("sudo xbps-install -S {pkgs}"), + // NixOS is special-cased in remediations(); this is an unreached fallback. + Distro::NixOS => { + format!("# NixOS: add to configuration.nix (see the PipeWire block) — {pkgs}") + } + Distro::Other => format!("Install with your package manager: {pkgs}"), + } +} + +fn install_reinstall(d: Distro, pkg: &str) -> String { + match d { + Distro::Debian | Distro::Antix => format!("sudo apt install --reinstall {pkg}"), + Distro::Fedora => format!("sudo dnf reinstall {pkg}"), + _ => install(d, pkg), + } +} + +const NIXOS_PIPEWIRE_BLOCK: &str = "# /etc/nixos/configuration.nix:\n\ + services.pipewire = {\n\ + \u{20}\u{20}enable = true;\n\ + \u{20}\u{20}alsa.enable = true;\n\ + \u{20}\u{20}pulse.enable = true;\n\ + \u{20}\u{20}wireplumber.enable = true;\n\ + };\n\ + # then apply:\n\ + sudo nixos-rebuild switch"; + +/// Full reference setup commands for the chosen distro/init, shown when QBZ +/// can't probe the host (sandbox). Mirrors the Tauri DistroSelector, which +/// always showed per-distro install + restart commands (no probing). +fn reference_commands(d: Distro, init: InitSystem) -> Vec { + if d == Distro::NixOS { + return vec![Remediation { + caption: qbz_i18n::t("Enable PipeWire in your NixOS configuration"), + command: NIXOS_PIPEWIRE_BLOCK.to_string(), + }]; + } + vec![ + Remediation { + caption: qbz_i18n::t("Install the PipeWire audio stack"), + command: install(d, full_stack_pkgs(d)), + }, + Remediation { + caption: qbz_i18n::t("(Re)start the PipeWire audio services"), + command: restart_cmd(init).to_string(), + }, + ] +} + +/// The full recommended package set (incl. `pipewire-alsa`, the bit the old +/// Tauri list omitted — the cause of the Ubuntu empty-list bug). +fn full_stack_pkgs(d: Distro) -> &'static str { + match d { + Distro::Debian | Distro::Antix => { + "pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils" + } + Distro::Fedora => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils", + Distro::Arch | Distro::Artix => { + "pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils" + } + Distro::OpenSuse => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils", + Distro::Gentoo => "media-video/pipewire media-video/wireplumber media-sound/alsa-utils", + Distro::Void => "pipewire wireplumber alsa-utils", + Distro::NixOS => "", + Distro::Other => "pipewire pipewire-pulse wireplumber alsa-utils", + } +} + +// =========================================================================== +// Select-DACs step — enumeration + the manual escape hatch +// =========================================================================== + +/// One enumerated sink the user can pick to configure. +#[derive(Clone, Debug, Default)] +pub struct DacCandidate { + /// PipeWire `node.name` — the value the manual escape hatch asks for. + pub id: String, + /// Pretty name, e.g. "DacMagic Plus Analog Stereo". + pub description: String, + /// "usb" | "pci" | "bluetooth" | "" + pub bus: String, + pub is_default: bool, + /// hardware && bus ∈ {usb,pci} → pre-selected. + pub looks_like_dac: bool, + /// Pre-filled supported rates, e.g. "44.1 / 96 / 192 kHz". + pub rates_label: String, +} + +/// Enumerate sinks via the pw-dump-robust path and probe rates for the likely +/// DACs. BLOCKING — call it off the UI thread. +pub fn detect_blocking() -> Vec { + let devices = qbz_audio::backend::BackendManager::create_backend( + qbz_audio::backend::AudioBackendType::PipeWire, + ) + .and_then(|b| b.enumerate_devices()) + .unwrap_or_default(); + + let mut out = Vec::new(); + for d in devices { + let bus = d.device_bus.unwrap_or_default(); + let looks_like_dac = d.is_hardware && (bus == "usb" || bus == "pci"); + // Only probe rates for likely DACs (skip virtual/monitor sinks). + let rates_label = if looks_like_dac { + format_rates(&qbz_audio::query_dac_capabilities(&d.id).sample_rates) + } else { + String::new() + }; + let description = if d.name.is_empty() { + d.id.clone() + } else { + d.name + }; + out.push(DacCandidate { + id: d.id, + description, + bus, + is_default: d.is_default, + looks_like_dac, + rates_label, + }); + } + out +} + +/// Validate a manually-pasted `node.name`. 1:1 with the Tauri +/// `validateNodeName`. +pub fn validate_node_name(name: &str) -> bool { + let t = name.trim(); + !t.is_empty() && (t.contains("alsa_output") || t.contains("alsa_input")) +} + +/// Classify a pasted node name. 1:1 with the Tauri `detectDacType`. +pub fn detect_dac_type(name: &str) -> &'static str { + let l = name.to_lowercase(); + if l.contains("usb-") || l.contains(".usb") { + "usb" + } else if l.contains("pci-") || l.contains(".pci") { + "pci" + } else if l.contains("bluez") || l.contains("bluetooth") { + "bluetooth" + } else if l.contains("virtual") || l.contains("null") || l.contains("dummy") { + "virtual" + } else { + "unknown" + } +} + +/// "44.1 / 96 / 192 kHz" from a rate list (kHz, .1 only when non-integer). +pub fn format_rates(rates: &[u32]) -> String { + if rates.is_empty() { + return String::new(); + } + let parts: Vec = rates + .iter() + .map(|&r| { + if r % 1000 == 0 { + format!("{}", r / 1000) + } else { + format!("{:.1}", r as f64 / 1000.0) + } + }) + .collect(); + format!("{} kHz", parts.join(" / ")) +} + +// =========================================================================== +// Test step — the four curated tracks + the N6 read-back +// =========================================================================== + +/// One curated test track (owner-provided). Resolved by id-hint first, then by +/// "artist title" search if the id 404s (a pulled license) — never raw-id-only. +pub struct TestSeed { + pub depth: u32, + pub rate: f64, + pub id_hint: u64, + pub artist: &'static str, + pub title: &'static str, +} + +pub const TEST_SEEDS: [TestSeed; 4] = [ + TestSeed { depth: 16, rate: 44100.0, id_hint: 19301386, artist: "George Harrison", title: "My Sweet Lord" }, + TestSeed { depth: 24, rate: 44100.0, id_hint: 266725027, artist: "Billie Eilish", title: "LUNCH" }, + TestSeed { depth: 24, rate: 96000.0, id_hint: 126886854, artist: "Iron Maiden", title: "Stratego" }, + TestSeed { depth: 24, rate: 192000.0, id_hint: 52265, artist: "Toto", title: "Africa" }, +]; + +/// True if a resolved track matches this seed's family (rate + bit depth — the +/// two 44.1 seeds only differ by depth). +pub fn track_matches_seed(track: &qbz_models::Track, seed: &TestSeed) -> bool { + let rate_ok = track + .maximum_sampling_rate + .map(|r| (r * 1000.0 - seed.rate).abs() < 1.0 || (r - seed.rate).abs() < 1.0) + .unwrap_or(false); + let depth_ok = track + .maximum_bit_depth + .map(|d| d == seed.depth) + .unwrap_or(false); + rate_ok && depth_ok +} + +/// The two read-back lines plus the truth signal, for one poll. +#[derive(Clone, Debug, Default)] +pub struct PollView { + pub requested_label: String, + pub negotiated_label: String, + /// The DAC's real clock matches what QBZ asked for. + pub rate_matched: bool, +} + +/// Format one poll: the rate QBZ requested vs the DAC's real negotiated rate +/// (N6). +pub fn poll_view( + requested_rate: u32, + requested_bits: u32, + negotiated: Option, +) -> PollView { + let requested_label = if requested_rate > 0 { + qbz_i18n::t_args( + "QBZ requesting {} · {}-bit", + &[&khz(requested_rate), &requested_bits.to_string()], + ) + } else { + qbz_i18n::t("Nothing playing") + }; + match negotiated { + Some(n) => PollView { + requested_label, + // The DAC's REAL hardware params (N6): rate + ALSA container format + // (e.g. S32_LE = 24-bit in a 32-bit frame) + channels. This is the + // bit-perfect proof — exactly what the hardware is clocked at. + negotiated_label: qbz_i18n::t_args( + "DAC: {} · {} · {} ch", + &[&khz(n.sample_rate), &n.format, &n.channels.to_string()], + ), + rate_matched: requested_rate > 0 && n.sample_rate == requested_rate, + }, + None => PollView { + requested_label, + negotiated_label: qbz_i18n::t("Waiting for the DAC to start playing…"), + rate_matched: false, + }, + } +} + +fn khz(hz: u32) -> String { + if hz % 1000 == 0 { + format!("{} kHz", hz / 1000) + } else { + format!("{:.1} kHz", hz as f64 / 1000.0) + } +} + +// =========================================================================== +// Review-and-apply step — per-DAC config generation +// =========================================================================== + +/// One selected DAC's generated copy-paste config (read-only). +#[derive(Clone, Debug, Default)] +pub struct DacConfig { + pub name: String, + pub node_name: String, + pub pipewire_conf: String, + pub pulse_conf: String, + pub wireplumber_conf: String, +} + +/// Re-probe rates + build the three config snippets per DAC. BLOCKING. +pub fn gen_configs_blocking(dacs: Vec<(String, String)>) -> Vec { + dacs.into_iter() + .map(|(node_name, name)| { + let rates = qbz_audio::query_dac_capabilities(&node_name).sample_rates; + let short = short_name(&name, &node_name); + DacConfig { + pipewire_conf: pipewire_conf(&short, &rates), + pulse_conf: pulse_conf(&short), + wireplumber_conf: wireplumber_conf(&short, &node_name, &rates, &name), + name, + node_name, + } + }) + .collect() +} + +/// The three `~/.config/...` paths one generated config would create — the +/// done step's "Config files you can create" list, in the same order the +/// review step shows the snippets. +pub fn created_paths(configs: &[DacConfig]) -> Vec { + let mut paths = Vec::new(); + for d in configs { + let short = short_name(&d.name, &d.node_name); + paths.push(format!( + "~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf" + )); + paths.push(format!( + "~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf" + )); + paths.push(format!( + "~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf" + )); + } + paths +} + +pub const BACKUP_CMD: &str = "BACKUP=~/.config/qbz/backups/pipewire-$(date +%Y%m%d-%H%M%S)\nmkdir -p \"$BACKUP\"\ncp -a ~/.config/pipewire \"$BACKUP/\" 2>/dev/null || true\ncp -a ~/.config/wireplumber \"$BACKUP/\" 2>/dev/null || true\necho \"Backup created at: $BACKUP\""; + +/// A short, filename-safe DAC name: slug of the description, else the node.name. +fn short_name(name: &str, node_name: &str) -> String { + let slug = slugify(name); + if !slug.is_empty() { + return slug; + } + let nslug = slugify(node_name); + if nslug.is_empty() { + "dac".to_string() + } else { + nslug + } +} + +fn slugify(s: &str) -> String { + let mut out = String::new(); + let mut prev_dash = false; + for ch in s.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash && !out.is_empty() { + out.push('-'); + prev_dash = true; + } + } + out.trim_matches('-').to_string() +} + +fn rates_list(rates: &[u32]) -> String { + if rates.is_empty() { + "44100 48000 88200 96000 176400 192000".to_string() + } else { + rates + .iter() + .map(|r| r.to_string()) + .collect::>() + .join(" ") + } +} + +fn pipewire_conf(short: &str, rates: &[u32]) -> String { + let rates = rates_list(rates); + [ + "mkdir -p ~/.config/pipewire/pipewire.conf.d".to_string(), + format!("cat > ~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf << 'EOF'"), + "# QBZ DAC Setup - Sample Rate Switching".to_string(), + "context.properties = {".to_string(), + format!(" default.clock.allowed-rates = [ {rates} ]"), + "}".to_string(), + "EOF".to_string(), + ] + .join("\n") +} + +fn pulse_conf(short: &str) -> String { + [ + "mkdir -p ~/.config/pipewire/client.conf.d".to_string(), + format!("cat > ~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf << 'EOF'"), + "# QBZ DAC Setup - Per-App Bit-Perfect".to_string(), + "stream.rules = [".to_string(), + " {".to_string(), + " matches = [".to_string(), + " { application.process.binary = \"qbz\" }".to_string(), + " { application.name = \"PipeWire ALSA [qbz]\" }".to_string(), + " ]".to_string(), + " actions = { update-props = { resample.disable = true, channelmix.disable = true } }" + .to_string(), + " }".to_string(), + "]".to_string(), + "EOF".to_string(), + ] + .join("\n") +} + +fn wireplumber_conf(short: &str, node_name: &str, rates: &[u32], description: &str) -> String { + let rates = rates_list(rates); + [ + "mkdir -p ~/.config/wireplumber/wireplumber.conf.d".to_string(), + format!("cat > ~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf << 'EOF'"), + format!("# QBZ DAC Setup - {description}"), + "monitor.alsa.rules = [".to_string(), + " {".to_string(), + " matches = [".to_string(), + format!(" {{ node.name = \"{node_name}\", media.class = \"Audio/Sink\" }}"), + " ]".to_string(), + " actions = {".to_string(), + " update-props = {".to_string(), + format!(" audio.allowed-rates = [ {rates} ]"), + " resample.disable = true".to_string(), + " channelmix.disable = true".to_string(), + " }".to_string(), + " }".to_string(), + " }".to_string(), + "]".to_string(), + "EOF".to_string(), + ] + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_node_names_like_tauri() { + assert!(validate_node_name( + "alsa_output.usb-Cambridge-00.analog-stereo" + )); + assert!(validate_node_name("alsa_input.pci-0000_00.analog-stereo")); + assert!(!validate_node_name("")); + assert!(!validate_node_name(" ")); + assert!(!validate_node_name("bluez_output.AA_BB")); + } + + #[test] + fn detects_dac_type() { + assert_eq!( + detect_dac_type("alsa_output.usb-Cambridge-00.analog-stereo"), + "usb" + ); + assert_eq!( + detect_dac_type("alsa_output.pci-0000_00_1f.3.analog-stereo"), + "pci" + ); + assert_eq!(detect_dac_type("bluez_output.AA"), "bluetooth"); + assert_eq!(detect_dac_type("alsa_output.virtual-dummy"), "virtual"); + assert_eq!(detect_dac_type("something.else"), "unknown"); + } + + #[test] + fn formats_rates_khz() { + assert_eq!(format_rates(&[44100, 96000, 192000]), "44.1 / 96 / 192 kHz"); + assert_eq!(format_rates(&[]), ""); + } + + #[test] + fn slugifies_descriptions() { + assert_eq!( + slugify("DacMagic Plus Analog Stereo"), + "dacmagic-plus-analog-stereo" + ); + assert_eq!( + slugify("Built-in Audio Analog Stereo"), + "built-in-audio-analog-stereo" + ); + assert_eq!(slugify(" weird__name!! "), "weird-name"); + assert_eq!(slugify(""), ""); + } + + #[test] + fn wireplumber_conf_pins_node_and_rates() { + let c = wireplumber_conf( + "dacmagic", + "alsa_output.usb-x.analog-stereo", + &[44100, 192000], + "DacMagic", + ); + assert!(c.contains("node.name = \"alsa_output.usb-x.analog-stereo\"")); + assert!(c.contains("audio.allowed-rates = [ 44100 192000 ]")); + assert!(c.contains("99-qbz-dac-dacmagic.conf")); + assert!(c.contains("resample.disable = true")); + } + + /// The done step's path list must line up with the snippets the review step + /// shows — three paths per DAC, in the pipewire / client / wireplumber + /// order the accordions use. + #[test] + fn created_paths_are_three_per_dac_in_order() { + let cfg = DacConfig { + name: "DacMagic Plus".to_string(), + node_name: "alsa_output.usb-x.analog-stereo".to_string(), + ..Default::default() + }; + let paths = created_paths(&[cfg]); + assert_eq!(paths.len(), 3); + assert!(paths[0].contains("pipewire.conf.d/99-qbz-dac-dacmagic-plus.conf")); + assert!(paths[1].contains("client.conf.d/99-qbz-bitperfect-dacmagic-plus.conf")); + assert!(paths[2].contains("wireplumber.conf.d/99-qbz-dac-dacmagic-plus.conf")); + } + + /// A sandbox has no verdict to give: no summary, and the rows are the + /// reference install + restart rather than probe-derived fixes. + #[test] + fn sandbox_renders_reference_commands_and_no_verdict() { + // A fully-healthy probe, so the assertion cannot pass by accident: in + // a sandbox the verdict is suppressed REGARDLESS of what was probed. + let health = AudioStackHealth { + wireplumber_active: true, + has_pw_dump: true, + cpal_sees_pipewire: true, + has_pactl: true, + any_devices: true, + }; + let view = check_view(health, 0, 0, true); + assert!(view.summary.is_empty()); + assert!(!view.health_ok); + assert_eq!(view.remediations.len(), 2); + } +} diff --git a/crates/qbz-dac-wizard/Cargo.toml b/crates/qbz-dac-wizard/Cargo.toml deleted file mode 100644 index 7de0e79a6..000000000 --- a/crates/qbz-dac-wizard/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "qbz-dac-wizard" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "HiFi/DAC setup wizard controller (Slint) — extracted feature crate." - -[lib] -name = "qbz_dac_wizard" -path = "src/lib.rs" - -[dependencies] -qbz-ui = { path = "../qbz-ui" } -qbz-audio = { path = "../qbz-audio" } -qbz-models = { path = "../qbz-models" } -qbz-i18n = { path = "../qbz-i18n" } -slint = { version = "=1.16.1", default-features = false, features = ["std", "compat-1-2"] } diff --git a/crates/qbz-dac-wizard/src/lib.rs b/crates/qbz-dac-wizard/src/lib.rs deleted file mode 100644 index 628acf605..000000000 --- a/crates/qbz-dac-wizard/src/lib.rs +++ /dev/null @@ -1,794 +0,0 @@ -//! HiFi Wizard (DAC setup) controller. -//! -//! Slice 6 (check step): runs the frontend-agnostic audio-stack probes -//! (`qbz_audio::health`) on open, maps them to per-distro copy-paste -//! remediations the check step renders, and recomputes them when the user -//! overrides the distro. Read-only — nothing here writes a system file or -//! opens a stream. - -use std::sync::Mutex; - -use qbz_audio::{AudioStackHealth, Distro, InitSystem}; -use slint::{ComponentHandle, Model, ModelRc, VecModel}; - -use qbz_ui::{AppWindow, DacCandidateRow, DacConfigRow, DacWizardState, RemediationRow}; - -// Last probe result, so a distro override recomputes commands without -// re-shelling on every dropdown change. -static LAST_HEALTH: Mutex> = Mutex::new(None); - -/// Synchronous part of opening the wizard: reset, fill the distro dropdown -/// (auto-detected, always overridable), and show a "checking…" state until the -/// async probe lands. -pub fn open_immediate(window: &AppWindow) { - let st = window.global::(); - st.set_open(true); - st.set_step(0); - st.set_welcome_confirmed(false); - - let distro_opts: Vec = - Distro::ALL.iter().map(|d| d.label().into()).collect(); - st.set_distro_options(ModelRc::new(VecModel::from(distro_opts))); - st.set_distro_index(qbz_audio::detect_distro().index() as i32); - - let init_opts: Vec = - InitSystem::ALL.iter().map(|i| i.label().into()).collect(); - st.set_init_options(ModelRc::new(VecModel::from(init_opts))); - st.set_init_index(qbz_audio::detect_init().index() as i32); - - let sandbox = qbz_audio::detect_sandbox(); - st.set_sandboxed(sandbox != qbz_audio::Sandbox::None); - st.set_sandbox_name( - match sandbox { - qbz_audio::Sandbox::Flatpak => "Flatpak", - qbz_audio::Sandbox::Snap => "Snap", - qbz_audio::Sandbox::None => "", - } - .into(), - ); - - st.set_health_ok(false); - st.set_health_summary(qbz_i18n::t("Checking your audio stack…").into()); - st.set_remediations(ModelRc::new(VecModel::from(Vec::::new()))); -} - -/// Apply a completed health probe: cache it and re-render from the current -/// distro/init selections. -pub fn apply_health(window: &AppWindow, health: AudioStackHealth) { - *LAST_HEALTH.lock().unwrap() = Some(health); - recompute(window); -} - -/// User overrode the distro (package manager) — recompute. -pub fn set_distro(window: &AppWindow, index: i32) { - window.global::().set_distro_index(index); - recompute(window); -} - -/// User overrode the init system (service commands) — recompute. -pub fn set_init(window: &AppWindow, index: i32) { - window.global::().set_init_index(index); - recompute(window); -} - -/// Rebuild the remediations from the cached probe + the current distro/init -/// dropdown selections (either of which the user can override). -fn recompute(window: &AppWindow) { - let st = window.global::(); - let health = LAST_HEALTH - .lock() - .unwrap() - .unwrap_or_else(qbz_audio::audio_stack_health); - let distro = Distro::ALL - .get(st.get_distro_index().max(0) as usize) - .copied() - .unwrap_or(Distro::Other); - let init = InitSystem::ALL - .get(st.get_init_index().max(0) as usize) - .copied() - .unwrap_or(InitSystem::Unknown); - - // In a sandbox the host probes are blind, so don't render a health verdict — - // show reference setup commands for the chosen distro/init (Tauri-style, - // which never probed either). The UI shows a sandbox info banner instead. - let rows = if st.get_sandboxed() { - st.set_health_ok(false); - st.set_health_summary("".into()); - reference_commands(distro, init) - } else { - let r = remediations(health, distro, init); - st.set_health_ok(health.is_ready()); - st.set_health_summary(if health.is_ready() { - qbz_i18n::t("Your audio stack is ready for bit-perfect playback.").into() - } else { - let n = r.len(); - qbz_i18n::tf( - "{} item needs attention before bit-perfect playback will work.", - "{} items need attention before bit-perfect playback will work.", - n as i64, - &[&n.to_string()], - ) - .into() - }); - r - }; - let model: Vec = rows - .into_iter() - .map(|(caption, command)| RemediationRow { - caption: caption.into(), - command: command.into(), - }) - .collect(); - st.set_remediations(ModelRc::new(VecModel::from(model))); -} - -/// (caption, copy-paste command) per missing probe, for the given distro. -/// -/// Service/restart commands are INIT-SYSTEM aware per distro (OpenRC on Gentoo, -/// runit on Void, systemd elsewhere), mirroring the Tauri DistroSelector -/// `restartCommands`. Installs and the restart are kept as separate blocks so -/// the multi-line Gentoo guidance never gets `&&`-joined. -fn remediations(h: AudioStackHealth, d: Distro, init: InitSystem) -> Vec<(String, String)> { - // NixOS is declarative: you don't `apt/pacman install` pieces — you enable - // the PipeWire module and rebuild. So collapse all the missing pieces into - // one config block instead of per-package commands. - if d == Distro::NixOS { - if h.is_ready() { - return Vec::new(); - } - return vec![( - qbz_i18n::t("Enable PipeWire in your NixOS configuration"), - NIXOS_PIPEWIRE_BLOCK.to_string(), - )]; - } - - let mut out = Vec::new(); - let mut needs_restart = false; - if !h.has_pw_dump { - out.push(( - qbz_i18n::t("Install the PipeWire tools (pw-dump)"), - install(d, pkg_pw_tools(d)), - )); - needs_restart = true; - } - if !h.cpal_sees_pipewire { - // THE Ubuntu no-list / no-playback bug: the ALSA->PipeWire bridge PCM. - out.push(( - qbz_i18n::t("Install the ALSA bridge so playback can reach PipeWire"), - install(d, "pipewire-alsa"), - )); - needs_restart = true; - } - if !h.has_pactl { - out.push(( - qbz_i18n::t("Install the Pulse compatibility tools (optional fallback)"), - install(d, pkg_pulse(d)), - )); - needs_restart = true; - } - if !h.any_devices { - out.push(( - qbz_i18n::t("No sinks detected — reinstall the ALSA UCM profiles, then reboot"), - install_reinstall(d, "alsa-ucm-conf"), - )); - } - // WirePlumber down, or we just installed something → (re)start the stack - // with the ACTUAL init system running on this machine (not guessed from the - // distro — Gentoo+systemd and Gentoo+OpenRC must differ). - if !h.wireplumber_active || needs_restart { - out.push(( - qbz_i18n::t("(Re)start the PipeWire audio services"), - restart_cmd(init).to_string(), - )); - } - out -} - -/// Init-system-aware "(re)start the audio services" command. PipeWire is a -/// user-session service, so only systemd has a first-class `--user` restart; -/// the others either use their user-service supervisor or a re-login. -fn restart_cmd(init: InitSystem) -> &'static str { - match init { - InitSystem::Systemd => "systemctl --user restart pipewire pipewire-pulse wireplumber", - InitSystem::OpenRc => { - "# OpenRC: PipeWire runs in your user session, not as an OpenRC service.\n\ - # Log out and back in to restart it." - } - InitSystem::Runit => { - "sv restart pipewire wireplumber # if set up as runit user services; otherwise log out and back in" - } - InitSystem::S6 => "# s6: restart via your supervision tree, or log out and back in", - InitSystem::Dinit => "dinitctl restart pipewire wireplumber # or log out and back in", - InitSystem::Unknown => "# Restart PipeWire via your init system, or log out and back in", - } -} - -fn pkg_pw_tools(d: Distro) -> &'static str { - match d { - // Debian-family (incl. antiX) ship pw-* in pipewire-bin. - Distro::Debian | Distro::Antix => "pipewire-bin", - Distro::Fedora => "pipewire-utils", - // Arch (incl. Artix) / openSUSE / Gentoo / Void ship pw-* with pipewire. - _ => "pipewire", - } -} - -fn pkg_pulse(d: Distro) -> &'static str { - match d { - Distro::Debian | Distro::Antix => "pipewire-pulse pulseaudio-utils", - Distro::Fedora => "pipewire-pulseaudio", - _ => "pipewire-pulse", - } -} - -fn install(d: Distro, pkgs: &str) -> String { - match d { - // Package manager is a property of the distro family, NOT the init. - Distro::Debian | Distro::Antix => format!("sudo apt install {pkgs}"), - Distro::Fedora => format!("sudo dnf install {pkgs}"), - Distro::Arch | Distro::Artix => format!("sudo pacman -S {pkgs}"), - Distro::OpenSuse => format!("sudo zypper install {pkgs}"), - Distro::Gentoo => format!("sudo emerge {pkgs} # package name may differ on Gentoo"), - Distro::Void => format!("sudo xbps-install -S {pkgs}"), - // NixOS is special-cased in remediations(); this is an unreached fallback. - Distro::NixOS => format!("# NixOS: add to configuration.nix (see the PipeWire block) — {pkgs}"), - Distro::Other => format!("Install with your package manager: {pkgs}"), - } -} - -fn install_reinstall(d: Distro, pkg: &str) -> String { - match d { - Distro::Debian | Distro::Antix => format!("sudo apt install --reinstall {pkg}"), - Distro::Fedora => format!("sudo dnf reinstall {pkg}"), - _ => install(d, pkg), - } -} - -const NIXOS_PIPEWIRE_BLOCK: &str = "# /etc/nixos/configuration.nix:\n\ - services.pipewire = {\n\ - \u{20}\u{20}enable = true;\n\ - \u{20}\u{20}alsa.enable = true;\n\ - \u{20}\u{20}pulse.enable = true;\n\ - \u{20}\u{20}wireplumber.enable = true;\n\ - };\n\ - # then apply:\n\ - sudo nixos-rebuild switch"; - -/// Full reference setup commands for the chosen distro/init, shown when QBZ -/// can't probe the host (sandbox). Mirrors the Tauri DistroSelector, which -/// always showed per-distro install + restart commands (no probing). -fn reference_commands(d: Distro, init: InitSystem) -> Vec<(String, String)> { - if d == Distro::NixOS { - return vec![( - qbz_i18n::t("Enable PipeWire in your NixOS configuration"), - NIXOS_PIPEWIRE_BLOCK.to_string(), - )]; - } - vec![ - ( - qbz_i18n::t("Install the PipeWire audio stack"), - install(d, full_stack_pkgs(d)), - ), - ( - qbz_i18n::t("(Re)start the PipeWire audio services"), - restart_cmd(init).to_string(), - ), - ] -} - -/// The full recommended package set (incl. `pipewire-alsa`, the bit the old -/// Tauri list omitted — the cause of the Ubuntu empty-list bug). -fn full_stack_pkgs(d: Distro) -> &'static str { - match d { - Distro::Debian | Distro::Antix => { - "pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils" - } - Distro::Fedora => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils", - Distro::Arch | Distro::Artix => { - "pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils" - } - Distro::OpenSuse => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils", - Distro::Gentoo => "media-video/pipewire media-video/wireplumber media-sound/alsa-utils", - Distro::Void => "pipewire wireplumber alsa-utils", - Distro::NixOS => "", - Distro::Other => "pipewire pipewire-pulse wireplumber alsa-utils", - } -} - -// ── Slice 7: select-dacs (auto-detect + manual escape hatch) ─────────────── - -/// Plain, `Send` candidate produced on the worker thread. -pub struct DacCandidateData { - id: String, - description: String, - bus: String, - is_default: bool, - looks_like_dac: bool, - rates_label: String, -} - -/// Immediate UI feedback before the (blocking) enumeration runs. -pub fn begin_detect(window: &AppWindow) { - window.global::().set_detecting(true); -} - -/// Heavy work (enumerate sinks via the pw-dump-robust path + probe rates for -/// the likely DACs). Runs on a blocking thread; returns plain data. -pub fn detect_blocking() -> Vec { - let devices = qbz_audio::backend::BackendManager::create_backend( - qbz_audio::backend::AudioBackendType::PipeWire, - ) - .and_then(|b| b.enumerate_devices()) - .unwrap_or_default(); - - let mut out = Vec::new(); - for d in devices { - let bus = d.device_bus.unwrap_or_default(); - let looks_like_dac = d.is_hardware && (bus == "usb" || bus == "pci"); - // Only probe rates for likely DACs (skip virtual/monitor sinks). - let rates_label = if looks_like_dac { - format_rates(&qbz_audio::query_dac_capabilities(&d.id).sample_rates) - } else { - String::new() - }; - let description = if d.name.is_empty() { d.id.clone() } else { d.name }; - out.push(DacCandidateData { - id: d.id, - description, - bus, - is_default: d.is_default, - looks_like_dac, - rates_label, - }); - } - out -} - -/// Apply enumerated candidates to the state. Pre-selects the likely DACs; if -/// nothing enumerated, flips `has-enumeration` off so the manual escape hatch -/// shows. -pub fn apply_candidates(window: &AppWindow, data: Vec) { - let st = window.global::(); - let rows: Vec = data - .iter() - .map(|d| DacCandidateRow { - id: d.id.clone().into(), - description: d.description.clone().into(), - bus: d.bus.clone().into(), - is_default: d.is_default, - looks_like_dac: d.looks_like_dac, - checked: d.looks_like_dac, - rates_label: d.rates_label.clone().into(), - }) - .collect(); - let any = rows.iter().any(|r| r.checked); - st.set_has_enumeration(!data.is_empty()); - st.set_any_dac_selected(any); - st.set_candidates(ModelRc::new(VecModel::from(rows))); - st.set_detecting(false); -} - -/// Flip one candidate's checkbox + recompute the Next gate. -pub fn toggle_dac(window: &AppWindow, index: i32) { - let st = window.global::(); - let model = st.get_candidates(); - if let Some(vm) = model - .as_any() - .downcast_ref::>() - { - if let Some(mut row) = vm.row_data(index.max(0) as usize) { - row.checked = !row.checked; - vm.set_row_data(index.max(0) as usize, row); - } - } - let any = (0..model.row_count()).any(|i| model.row_data(i).map(|r| r.checked).unwrap_or(false)); - st.set_any_dac_selected(any); -} - -/// Validate a manually-pasted node.name (escape hatch). 1:1 with the Tauri -/// `validateNodeName` / `detectDacType`. -pub fn validate_manual(window: &AppWindow, text: &str) { - let st = window.global::(); - st.set_manual_node_name(text.into()); - st.set_manual_valid(validate_node_name(text)); - st.set_manual_dac_type(detect_dac_type(text).into()); -} - -fn validate_node_name(name: &str) -> bool { - let t = name.trim(); - !t.is_empty() && (t.contains("alsa_output") || t.contains("alsa_input")) -} - -fn detect_dac_type(name: &str) -> &'static str { - let l = name.to_lowercase(); - if l.contains("usb-") || l.contains(".usb") { - "usb" - } else if l.contains("pci-") || l.contains(".pci") { - "pci" - } else if l.contains("bluez") || l.contains("bluetooth") { - "bluetooth" - } else if l.contains("virtual") || l.contains("null") || l.contains("dummy") { - "virtual" - } else { - "unknown" - } -} - -/// "44.1 / 96 / 192 kHz" from a rate list (kHz, .1 only when non-integer). -fn format_rates(rates: &[u32]) -> String { - if rates.is_empty() { - return String::new(); - } - let parts: Vec = rates - .iter() - .map(|&r| { - if r % 1000 == 0 { - format!("{}", r / 1000) - } else { - format!("{:.1}", r as f64 / 1000.0) - } - }) - .collect(); - format!("{} kHz", parts.join(" / ")) -} - -#[cfg(test)] -mod slice7_tests { - use super::*; - - #[test] - fn validates_node_names_like_tauri() { - assert!(validate_node_name("alsa_output.usb-Cambridge-00.analog-stereo")); - assert!(validate_node_name("alsa_input.pci-0000_00.analog-stereo")); - assert!(!validate_node_name("")); - assert!(!validate_node_name(" ")); - assert!(!validate_node_name("bluez_output.AA_BB")); - } - - #[test] - fn detects_dac_type() { - assert_eq!(detect_dac_type("alsa_output.usb-Cambridge-00.analog-stereo"), "usb"); - assert_eq!(detect_dac_type("alsa_output.pci-0000_00_1f.3.analog-stereo"), "pci"); - assert_eq!(detect_dac_type("bluez_output.AA"), "bluetooth"); - assert_eq!(detect_dac_type("alsa_output.virtual-dummy"), "virtual"); - assert_eq!(detect_dac_type("something.else"), "unknown"); - } - - #[test] - fn formats_rates_khz() { - assert_eq!(format_rates(&[44100, 96000, 192000]), "44.1 / 96 / 192 kHz"); - assert_eq!(format_rates(&[]), ""); - } -} - -// ── Slice 9: self-service playback test (N6 read-back) ───────────────────── - -/// One curated test track (owner-provided). Resolved by id-hint first, then by -/// "artist title" search if the id 404s (a pulled license) — never raw-id-only. -pub struct TestSeed { - pub depth: u32, - pub rate: f64, - pub id_hint: u64, - pub artist: &'static str, - pub title: &'static str, -} - -pub const TEST_SEEDS: [TestSeed; 4] = [ - TestSeed { depth: 16, rate: 44100.0, id_hint: 19301386, artist: "George Harrison", title: "My Sweet Lord" }, - TestSeed { depth: 24, rate: 44100.0, id_hint: 266725027, artist: "Billie Eilish", title: "LUNCH" }, - TestSeed { depth: 24, rate: 96000.0, id_hint: 126886854, artist: "Iron Maiden", title: "Stratego" }, - TestSeed { depth: 24, rate: 192000.0, id_hint: 52265, artist: "Toto", title: "Africa" }, -]; - -/// True if a resolved track matches this seed's family (rate + bit depth — the -/// two 44.1 seeds only differ by depth). -pub fn track_matches_seed(track: &qbz_models::Track, seed: &TestSeed) -> bool { - let rate_ok = track - .maximum_sampling_rate - .map(|r| (r * 1000.0 - seed.rate).abs() < 1.0 || (r - seed.rate).abs() < 1.0) - .unwrap_or(false); - let depth_ok = track.maximum_bit_depth.map(|d| d == seed.depth).unwrap_or(false); - rate_ok && depth_ok -} - -/// Resolved test tracks, kept so the user can jump straight to any of them -/// (re-set the queue at the chosen index via the working play path). -static TEST_TRACKS: Mutex> = Mutex::new(Vec::new()); - -pub fn stash_test_tracks(tracks: Vec) { - *TEST_TRACKS.lock().unwrap() = tracks; -} - -pub fn test_tracks() -> Vec { - TEST_TRACKS.lock().unwrap().clone() -} - -/// Start the test: show the "playing" state. The read-back probes whichever -/// DAC is actively playing (scan), so no node needs to be stashed. -pub fn begin_test(window: &AppWindow) { - let st = window.global::(); - st.set_test_playing(true); - st.set_test_rate_matched(false); - st.set_test_requested_label(qbz_i18n::t("Starting…").into()); - st.set_test_negotiated_label("".into()); -} - -pub fn end_test(window: &AppWindow) { - window.global::().set_test_playing(false); -} - -/// Guardrail: "Use my current queue" with an empty queue — show a hint instead -/// of starting a read-back that would just sit on "Nothing playing". -pub fn queue_empty_notice(window: &AppWindow) { - let st = window.global::(); - st.set_test_playing(false); - st.set_test_rate_matched(false); - st.set_test_negotiated_label("".into()); - st.set_test_requested_label( - qbz_i18n::t("Your queue is empty — add some tracks first, or press Play test.").into(), - ); -} - -/// Apply one poll: the rate QBZ requested vs the DAC's real negotiated rate (N6). -pub fn apply_poll( - window: &AppWindow, - requested_rate: u32, - requested_bits: u32, - negotiated: Option, -) { - let st = window.global::(); - st.set_test_requested_label(if requested_rate > 0 { - qbz_i18n::t_args( - "QBZ requesting {} · {}-bit", - &[&khz(requested_rate), &requested_bits.to_string()], - ) - .into() - } else { - qbz_i18n::t("Nothing playing").into() - }); - match negotiated { - Some(n) => { - // The DAC's REAL hardware params (N6): rate + ALSA container format - // (e.g. S32_LE = 24-bit in a 32-bit frame) + channels. This is the - // bit-perfect proof — exactly what the hardware is clocked at. - st.set_test_negotiated_label( - qbz_i18n::t_args( - "DAC: {} · {} · {} ch", - &[&khz(n.sample_rate), &n.format, &n.channels.to_string()], - ) - .into(), - ); - // Truth signal: the DAC's real clock matches what QBZ asked for. - st.set_test_rate_matched(requested_rate > 0 && n.sample_rate == requested_rate); - } - None => { - st.set_test_negotiated_label(qbz_i18n::t("Waiting for the DAC to start playing…").into()); - st.set_test_rate_matched(false); - } - } -} - -fn khz(hz: u32) -> String { - if hz % 1000 == 0 { - format!("{} kHz", hz / 1000) - } else { - format!("{:.1} kHz", hz as f64 / 1000.0) - } -} - -// ── Slice 10: review-and-apply (per-DAC config generation) ───────────────── - -/// Plain, `Send` per-DAC generated config (built on a worker thread). -pub struct DacConfigData { - name: String, - node_name: String, - pipewire_conf: String, - pulse_conf: String, - wireplumber_conf: String, -} - -/// (node_name, display_name) for every checked candidate, or a valid manual one. -pub fn checked_dacs(window: &AppWindow) -> Vec<(String, String)> { - let st = window.global::(); - let model = st.get_candidates(); - let mut out = Vec::new(); - for i in 0..model.row_count() { - if let Some(r) = model.row_data(i) { - if r.checked { - out.push((r.id.to_string(), r.description.to_string())); - } - } - } - if out.is_empty() { - let manual = st.get_manual_node_name().to_string(); - if !manual.trim().is_empty() && st.get_manual_valid() { - out.push((manual.clone(), manual)); - } - } - out -} - -/// Re-probe rates + build the three config snippets per DAC (blocking). -pub fn gen_configs_blocking(dacs: Vec<(String, String)>) -> Vec { - dacs.into_iter() - .map(|(node_name, name)| { - let rates = qbz_audio::query_dac_capabilities(&node_name).sample_rates; - let short = short_name(&name, &node_name); - DacConfigData { - pipewire_conf: pipewire_conf(&short, &rates), - pulse_conf: pulse_conf(&short), - wireplumber_conf: wireplumber_conf(&short, &node_name, &rates, &name), - name, - node_name, - } - }) - .collect() -} - -/// Push generated configs + backup/restart/paths to the state. -pub fn apply_configs(window: &AppWindow, data: Vec) { - let st = window.global::(); - let single = data.len() == 1; - let rows: Vec = data - .iter() - .map(|d| DacConfigRow { - name: d.name.clone().into(), - node_name: d.node_name.clone().into(), - pipewire_conf: d.pipewire_conf.clone().into(), - pulse_conf: d.pulse_conf.clone().into(), - wireplumber_conf: d.wireplumber_conf.clone().into(), - expanded: single, // one DAC → expanded; multiple → collapsed accordions - }) - .collect(); - let mut paths: Vec = Vec::new(); - for d in &data { - let short = short_name(&d.name, &d.node_name); - paths.push(format!("~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf").into()); - paths.push(format!("~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf").into()); - paths - .push(format!("~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf").into()); - } - st.set_dac_configs(ModelRc::new(VecModel::from(rows))); - st.set_created_paths(ModelRc::new(VecModel::from(paths))); - st.set_backup_cmd(BACKUP_CMD.into()); - let init = InitSystem::ALL - .get(st.get_init_index().max(0) as usize) - .copied() - .unwrap_or(InitSystem::Unknown); - st.set_restart_cmd(restart_cmd(init).into()); -} - -/// Collapse/expand one DAC's generated-config accordion. -pub fn toggle_config(window: &AppWindow, index: i32) { - let model = window.global::().get_dac_configs(); - if let Some(vm) = model.as_any().downcast_ref::>() { - if let Some(mut row) = vm.row_data(index.max(0) as usize) { - row.expanded = !row.expanded; - vm.set_row_data(index.max(0) as usize, row); - } - } -} - -const BACKUP_CMD: &str = "BACKUP=~/.config/qbz/backups/pipewire-$(date +%Y%m%d-%H%M%S)\nmkdir -p \"$BACKUP\"\ncp -a ~/.config/pipewire \"$BACKUP/\" 2>/dev/null || true\ncp -a ~/.config/wireplumber \"$BACKUP/\" 2>/dev/null || true\necho \"Backup created at: $BACKUP\""; - -/// A short, filename-safe DAC name: slug of the description, else the node.name. -fn short_name(name: &str, node_name: &str) -> String { - let slug = slugify(name); - if !slug.is_empty() { - return slug; - } - let nslug = slugify(node_name); - if nslug.is_empty() { - "dac".to_string() - } else { - nslug - } -} - -fn slugify(s: &str) -> String { - let mut out = String::new(); - let mut prev_dash = false; - for ch in s.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - prev_dash = false; - } else if !prev_dash && !out.is_empty() { - out.push('-'); - prev_dash = true; - } - } - out.trim_matches('-').to_string() -} - -fn rates_list(rates: &[u32]) -> String { - if rates.is_empty() { - "44100 48000 88200 96000 176400 192000".to_string() - } else { - rates - .iter() - .map(|r| r.to_string()) - .collect::>() - .join(" ") - } -} - -fn pipewire_conf(short: &str, rates: &[u32]) -> String { - let rates = rates_list(rates); - [ - "mkdir -p ~/.config/pipewire/pipewire.conf.d".to_string(), - format!("cat > ~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf << 'EOF'"), - "# QBZ DAC Setup - Sample Rate Switching".to_string(), - "context.properties = {".to_string(), - format!(" default.clock.allowed-rates = [ {rates} ]"), - "}".to_string(), - "EOF".to_string(), - ] - .join("\n") -} - -fn pulse_conf(short: &str) -> String { - [ - "mkdir -p ~/.config/pipewire/client.conf.d".to_string(), - format!("cat > ~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf << 'EOF'"), - "# QBZ DAC Setup - Per-App Bit-Perfect".to_string(), - "stream.rules = [".to_string(), - " {".to_string(), - " matches = [".to_string(), - " { application.process.binary = \"qbz\" }".to_string(), - " { application.name = \"PipeWire ALSA [qbz]\" }".to_string(), - " ]".to_string(), - " actions = { update-props = { resample.disable = true, channelmix.disable = true } }" - .to_string(), - " }".to_string(), - "]".to_string(), - "EOF".to_string(), - ] - .join("\n") -} - -fn wireplumber_conf(short: &str, node_name: &str, rates: &[u32], description: &str) -> String { - let rates = rates_list(rates); - [ - "mkdir -p ~/.config/wireplumber/wireplumber.conf.d".to_string(), - format!("cat > ~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf << 'EOF'"), - format!("# QBZ DAC Setup - {description}"), - "monitor.alsa.rules = [".to_string(), - " {".to_string(), - " matches = [".to_string(), - format!(" {{ node.name = \"{node_name}\", media.class = \"Audio/Sink\" }}"), - " ]".to_string(), - " actions = {".to_string(), - " update-props = {".to_string(), - format!(" audio.allowed-rates = [ {rates} ]"), - " resample.disable = true".to_string(), - " channelmix.disable = true".to_string(), - " }".to_string(), - " }".to_string(), - " }".to_string(), - "]".to_string(), - "EOF".to_string(), - ] - .join("\n") -} - -#[cfg(test)] -mod slice10_tests { - use super::*; - - #[test] - fn slugifies_descriptions() { - assert_eq!(slugify("DacMagic Plus Analog Stereo"), "dacmagic-plus-analog-stereo"); - assert_eq!(slugify("Built-in Audio Analog Stereo"), "built-in-audio-analog-stereo"); - assert_eq!(slugify(" weird__name!! "), "weird-name"); - assert_eq!(slugify(""), ""); - } - - #[test] - fn wireplumber_conf_pins_node_and_rates() { - let c = wireplumber_conf("dacmagic", "alsa_output.usb-x.analog-stereo", &[44100, 192000], "DacMagic"); - assert!(c.contains("node.name = \"alsa_output.usb-x.analog-stereo\"")); - assert!(c.contains("audio.allowed-rates = [ 44100 192000 ]")); - assert!(c.contains("99-qbz-dac-dacmagic.conf")); - assert!(c.contains("resample.disable = true")); - } -} diff --git a/crates/qbz-disc/Cargo.toml b/crates/qbz-disc/Cargo.toml new file mode 100644 index 000000000..c3bc65955 --- /dev/null +++ b/crates/qbz-disc/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "qbz-disc" +version.workspace = true +edition.workspace = true + +[dependencies] +log = { workspace = true } +thiserror = { workspace = true } +sha1 = "0.10" +base64 = { workspace = true } +# The disc MEMORY (src/store.rs). SQLite rather than a JSON file because the +# app already speaks it everywhere and a row-at-a-time upsert is what the +# "never overwrite a human" rule needs. +rusqlite = { version = "0.40", features = ["bundled"] } +serde = { workspace = true } +serde_json = { workspace = true } +dirs = "6" +encoding_rs = "0.8" + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" + +# macOS hands the table of contents over as a `.TOC.plist` beside the audio +# files. It is a BINARY plist in practice, so it is parsed rather than read. +[target.'cfg(target_os = "macos")'.dependencies] +plist = "1" + +[dev-dependencies] +reqwest = { workspace = true } +tokio = { workspace = true } +serde_json = { workspace = true } +rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] } diff --git a/crates/qbz-disc/examples/cd_probe.rs b/crates/qbz-disc/examples/cd_probe.rs new file mode 100644 index 000000000..9a34ee8d5 --- /dev/null +++ b/crates/qbz-disc/examples/cd_probe.rs @@ -0,0 +1,68 @@ +//! Read the disc in the drive and prove the crate agrees with reality. +//! `cargo run -p qbz-disc --example cd_probe` +fn main() { + let devs = qbz_disc::list_devices(); + println!("devices: {devs:?}"); + let Some(dev) = devs.first() else { + println!("no drive"); + return; + }; + let toc = match qbz_disc::read_toc(dev) { + Ok(t) => t, + Err(e) => { + println!("toc failed: {e}"); + return; + } + }; + println!("fingerprint: {}", toc.fingerprint()); + println!("leadout LSN: {}", toc.leadout_lsn); + let mut total = 0u64; + for t in &toc.tracks { + let d = t.duration_secs(); + total += d; + println!( + " track {:2} lsn {:>7} {:>6} sectors {:>2}:{:02} {}", + t.number, + t.start_lsn, + t.sectors, + d / 60, + d % 60, + if t.is_audio { "audio" } else { "DATA (skipped)" } + ); + } + println!("total {}:{:02}", total / 60, total % 60); + + // Read the first audio track's opening chunk and prove it is music. + let Some(first) = toc.audio_tracks().next() else { + return; + }; + let mut r = match qbz_disc::cdda::TrackReader::open(dev, first) { + Ok(r) => r, + Err(e) => { + println!("open failed: {e}"); + return; + } + }; + let mut buf = Vec::new(); + // Skip the first second — track 1 often opens on silence. + for _ in 0..2 { + if let Err(e) = r.next_chunk(&mut buf) { + println!("read failed: {e}"); + return; + } + } + let samples: Vec = buf + .chunks_exact(2) + .map(|b| i16::from_le_bytes([b[0], b[1]])) + .collect(); + let peak = samples.iter().map(|s| s.unsigned_abs()).max().unwrap_or(0); + let nz = samples.iter().filter(|s| **s != 0).count(); + println!( + "read {} bytes | {} samples | non-zero {}% | peak {} | byte-swapped? {}", + buf.len(), + samples.len(), + 100 * nz / samples.len().max(1), + peak, + qbz_disc::cdda::looks_byte_swapped(&buf) + ); +} diff --git a/crates/qbz-disc/examples/iso_probe.rs b/crates/qbz-disc/examples/iso_probe.rs new file mode 100644 index 000000000..a92edca67 --- /dev/null +++ b/crates/qbz-disc/examples/iso_probe.rs @@ -0,0 +1,32 @@ +//! Walk a real ISO without mounting it. +//! `cargo run -p qbz-disc --example iso_probe -- ` +fn main() { + let path = std::env::args().nth(1).expect("usage: iso_probe "); + let p = std::path::PathBuf::from(&path); + let mut iso = match qbz_disc::iso9660::IsoImage::open(&p) { + Ok(i) => i, + Err(e) => { println!("open failed: {e}"); return; } + }; + println!("root: {:?}", iso.root()); + let root = iso.root().clone(); + for e in iso.read_dir(&root).unwrap_or_default() { + println!(" /{:<16} lsn {:>8} {:>12} bytes {}", e.name, e.lsn, e.size, + if e.is_dir { "DIR" } else { "" }); + } + if let Ok(Some(audio)) = iso.find("/2C_AUDIO") { + println!("/2C_AUDIO:"); + let kids = iso.read_dir(&audio).unwrap_or_default(); + for e in kids.iter().take(6) { + println!(" {:<16} lsn {:>8} {:>12} bytes", e.name, e.lsn, e.size); + } + println!(" … {} entries total", kids.len()); + } + // The structure the SACD reader will actually ask for. + for want in ["/MASTER1.TOC", "/2C_AUDIO/2C_AREA1.TOC", "/2C_AUDIO/TRACK001.2CH"] { + match iso.find(want) { + Ok(Some(e)) => println!("find {want} -> lsn {} size {}", e.lsn, e.size), + Ok(None) => println!("find {want} -> NOT FOUND"), + Err(e) => println!("find {want} -> error {e}"), + } + } +} diff --git a/crates/qbz-disc/examples/mb_probe.rs b/crates/qbz-disc/examples/mb_probe.rs new file mode 100644 index 000000000..e728fef5f --- /dev/null +++ b/crates/qbz-disc/examples/mb_probe.rs @@ -0,0 +1,45 @@ +//! Reproduce EXACTLY what cdda_qt does, and say what comes back. +#[tokio::main] +async fn main() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let id = std::env::args().nth(1) + .unwrap_or_else(|| "BeNBMsD8Du5NO2W61Yk.B2jwwIs-".to_string()); + let url = qbz_disc::discid::lookup_url(&id); + println!("URL: {url}"); + + let client = reqwest::Client::builder() + .user_agent(concat!("QBZ/", env!("CARGO_PKG_VERSION"), " (https://github.com/vicrodh/qbz)")) + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("client"); + + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(e) => { println!("SEND FAILED: {e}"); return; } + }; + let status = resp.status(); + println!("HTTP {status}"); + let text = resp.text().await.unwrap_or_default(); + println!("body: {} bytes", text.len()); + println!("first 300: {}", &text[..text.len().min(300)]); + + let v: serde_json::Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(e) => { println!("NOT JSON: {e}"); return; } + }; + println!("top-level keys: {:?}", v.as_object().map(|o| o.keys().collect::>())); + let releases = v.get("releases").and_then(|r| r.as_array()); + println!("releases: {:?}", releases.map(|r| r.len())); + if let Some(r0) = releases.and_then(|r| r.first()) { + println!(" release keys: {:?}", r0.as_object().map(|o| o.keys().collect::>())); + println!(" title: {:?}", r0.get("title")); + let media = r0.get("media").and_then(|m| m.as_array()); + println!(" media: {:?}", media.map(|m| m.len())); + if let Some(m0) = media.and_then(|m| m.iter().find(|m| m.get("tracks").is_some())) { + let t = m0.get("tracks").and_then(|t| t.as_array()); + println!(" tracks: {:?}", t.map(|t| t.len())); + } else { + println!(" NO media entry has `tracks` <-- aqui muere el parseo"); + } + } +} diff --git a/crates/qbz-disc/examples/sacd_layout2.rs b/crates/qbz-disc/examples/sacd_layout2.rs new file mode 100644 index 000000000..3d3b93221 --- /dev/null +++ b/crates/qbz-disc/examples/sacd_layout2.rs @@ -0,0 +1,158 @@ +//! Which channel layout is right — re-measured on ALIGNED frames. +//! +//! The first answer to this question (`qbz-dsd/src/sacd_source.rs`) was taken +//! on track 4, whose first frame begins 1344 bytes into the payload, so it was +//! measuring bits that were already scrambled by a framing bug. This asks +//! again, with the frame sync applied, and it asks a question that scrambling +//! cannot fake. +//! +//! THE TEST. A DSD bit stream is a 1-bit sigma-delta code: the LOCAL MEAN of +//! the bits *is* the waveform. Average 64 consecutive bits and you have a +//! crude 44.1 kHz sample; music makes that sequence swing widely and slowly, +//! while a scrambled stream is a fair coin whose 64-bit mean sits at 0.5 with +//! the variance of a binomial and nothing else. So: +//! +//! correct layout -> large std-dev, large peak excursion from 0.5 +//! scrambled -> std-dev near the binomial floor (~0.5/sqrt(64) = 0.0625) +//! +//! No filter, no FFT, no threshold anybody had to choose: the binomial floor +//! is arithmetic, and the answer is a ratio against it. +//! +//! Run: cargo run -p qbz-disc --example sacd_layout2 -- [track] + +use qbz_disc::sacd; + +const FRAME: usize = 9408; +const HALF: usize = FRAME / 2; +/// Bits averaged per output sample — DSD64 / 64 = 44.1 kHz. +const DECIM: usize = 64; +/// The std-dev a fair coin produces at this decimation. Anything at this level +/// is noise BY ARITHMETIC, not by opinion. +const COIN_FLOOR: f64 = 0.5 / 8.0; // 0.5 / sqrt(64) + +fn bits(byte: u8) -> [f64; 8] { + let mut out = [0.0; 8]; + for (i, slot) in out.iter_mut().enumerate() { + // MSB first — SACD, like DFF. + *slot = ((byte >> (7 - i)) & 1) as f64; + } + out +} + +/// Decimate a channel's bytes to a waveform and describe its swing. +fn describe(chan: &[u8]) -> (f64, f64) { + let mut samples = Vec::with_capacity(chan.len() * 8 / DECIM); + let mut acc = 0.0; + let mut n = 0usize; + for b in chan { + for v in bits(*b) { + acc += v; + n += 1; + if n == DECIM { + samples.push(acc / DECIM as f64); + acc = 0.0; + n = 0; + } + } + } + if samples.is_empty() { + return (0.0, 0.0); + } + let mean = samples.iter().sum::() / samples.len() as f64; + let var = samples.iter().map(|s| (s - mean).powi(2)).sum::() / samples.len() as f64; + let peak = samples + .iter() + .map(|s| (s - mean).abs()) + .fold(0.0f64, f64::max); + (var.sqrt(), peak) +} + +fn split_block(payload: &[u8]) -> (Vec, Vec) { + let mut l = Vec::new(); + let mut r = Vec::new(); + for f in payload.chunks_exact(FRAME) { + l.extend_from_slice(&f[..HALF]); + r.extend_from_slice(&f[HALF..]); + } + (l, r) +} + +fn split_interleaved(payload: &[u8]) -> (Vec, Vec) { + let mut l = Vec::new(); + let mut r = Vec::new(); + for pair in payload.chunks_exact(2) { + l.push(pair[0]); + r.push(pair[1]); + } + (l, r) +} + +fn report(name: &str, payload: &[u8]) { + for (how, (l, r)) in [ + ("block-per-channel", split_block(payload)), + ("byte-interleaved", split_interleaved(payload)), + ] { + let (ls, lp) = describe(&l); + let (rs, rp) = describe(&r); + println!( + " {name:<11} {how:<18} L sd {:.4} ({:>5.1}x coin) peak {:.3} | \ + R sd {:.4} ({:>5.1}x coin) peak {:.3}", + ls, + ls / COIN_FLOOR, + lp, + rs, + rs / COIN_FLOOR, + rp, + ); + } +} + +fn main() { + let mut args = std::env::args().skip(1); + let Some(path) = args.next() else { + eprintln!("usage: sacd_layout2 [track]"); + std::process::exit(2); + }; + let only: u8 = args.next().and_then(|s| s.parse().ok()).unwrap_or(4); + let path = std::path::PathBuf::from(path); + + let area = sacd::read_area(&path).expect("read_area"); + let Some(track) = area.tracks.iter().find(|t| t.number == only) else { + eprintln!("no track {only}"); + std::process::exit(1); + }; + + let mut iso = qbz_disc::iso9660::IsoImage::open(&path).expect("open"); + let start = track.start_lsn as u64; + let want = (track.length_lsn as usize).min(600); + let raw = iso.read_sectors(start, want).expect("read"); + + // Collect the audio payload, and note where the first frame begins. + let mut payload: Vec = Vec::new(); + let mut first_start: Option = None; + for s in 0..want { + let lsn = start + s as u64; + let sector = &raw[s * 2048..(s + 1) * 2048]; + for p in sacd::parse_sector(sector, lsn).expect("parse") { + if p.data_type != sacd::DATA_TYPE_AUDIO { + continue; + } + if p.frame_start && first_start.is_none() { + first_start = Some(payload.len()); + } + payload.extend_from_slice(§or[p.at..p.at + p.len]); + } + } + let skip = first_start.unwrap_or(0); + println!( + "track {} — {} B payload, first frame at {skip}, coin floor sd {:.4}", + track.number, + payload.len(), + COIN_FLOOR + ); + + println!("UNSYNCED (what ships today):"); + report("unsynced", &payload); + println!("SYNCED (drop {skip} B):"); + report("synced", &payload[skip..]); +} diff --git a/crates/qbz-disc/examples/sacd_probe.rs b/crates/qbz-disc/examples/sacd_probe.rs new file mode 100644 index 000000000..f4b14a502 --- /dev/null +++ b/crates/qbz-disc/examples/sacd_probe.rs @@ -0,0 +1,47 @@ +//! Read a SACD image's stereo area and pull real DSD out of a track. +//! `cargo run -p qbz-disc --example sacd_probe -- ` +fn main() { + let path = std::env::args().nth(1).expect("usage: sacd_probe "); + let p = std::path::PathBuf::from(&path); + let area = match qbz_disc::sacd::read_area(&p) { + Ok(a) => a, + Err(e) => { println!("area failed: {e}"); return; } + }; + println!("album: {:?} | artist: {:?}", area.album, area.artist); + println!("channels {} | area LSN {}..{} | total {:.2}s ({}:{:02})", + area.channels, area.track_start_lsn, area.track_end_lsn, + area.total_playtime_secs, + area.total_playtime_secs as u32 / 60, area.total_playtime_secs as u32 % 60); + println!("{} tracks", area.tracks.len()); + for t in area.tracks.iter().take(6) { + println!(" {:2} lsn {:>8} len {:>7} {:>3}:{:02} {}", + t.number, t.start_lsn, t.length_lsn, + t.duration_secs as u32 / 60, t.duration_secs as u32 % 60, + t.title.as_deref().unwrap_or("—")); + } + // Pull audio out of the FIRST track and see whether it looks like DSD. + let t = &area.tracks[0]; + let mut r = match qbz_disc::sacd::SacdTrackReader::open(&p, t) { + Ok(r) => r, Err(e) => { println!("reader failed: {e}"); return; } + }; + let mut buf = Vec::new(); + let mut total = 0usize; + let mut silence = 0usize; + // ~2.8 seconds of audio: enough to see whether it is silence or music. + for _ in 0..10 { + match r.next_chunk(&mut buf, 100) { + Ok(0) => break, + Ok(n) => { + total += n; + silence += buf.iter().filter(|b| **b == 0x99).count(); + } + Err(e) => { println!("read failed: {e}"); return; } + } + } + println!("\nextracted {total} bytes of audio payload from track 1"); + println!("0x99 (DSD silence) bytes: {silence} ({}%)", 100 * silence / total.max(1)); + let frames = total as f64 / qbz_disc::sacd::DSD64_STEREO_FRAME as f64; + println!("that is {frames:.3} DSD64 stereo frames ({:.3}s of audio)", frames / 75.0); + println!("first 32 bytes: {}", buf.iter().take(32) + .map(|b| format!("{b:02x}")).collect::>().join(" ")); +} diff --git a/crates/qbz-disc/examples/sacd_sync.rs b/crates/qbz-disc/examples/sacd_sync.rs new file mode 100644 index 000000000..ef7716b83 --- /dev/null +++ b/crates/qbz-disc/examples/sacd_sync.rs @@ -0,0 +1,113 @@ +//! Where do the DSD frames actually START inside a track's audio payload? +//! +//! `SacdTrackReader::next_chunk` concatenates every audio packet and the +//! demuxer then chops the result into 9408-byte frames, assuming byte 0 of the +//! first packet is byte 0 of a frame. The sector descriptors carry a +//! `frame_start` bit that nobody reads. This measures whether ignoring it is +//! safe: it prints, per track, the offset of the first frame start and whether +//! every later frame start sits on a 9408 multiple from there. +//! +//! Run: cargo run -p qbz-disc --example sacd_sync -- [track] + +use qbz_disc::sacd; + +fn main() { + let mut args = std::env::args().skip(1); + let Some(path) = args.next() else { + eprintln!("usage: sacd_sync [track]"); + std::process::exit(2); + }; + let only: Option = args.next().and_then(|s| s.parse().ok()); + let path = std::path::PathBuf::from(path); + + let area = match sacd::read_area(&path) { + Ok(a) => a, + Err(e) => { + eprintln!("read_area failed: {e}"); + std::process::exit(1); + } + }; + println!( + "{} — {} tracks, fingerprint {}", + path.display(), + area.tracks.len(), + area.fingerprint() + ); + + const FRAME: usize = 9408; + for t in area.tracks.iter().filter(|t| only.is_none_or(|n| t.number == n)) { + // Walk the track's sectors by hand so the frame_start bits stay + // visible — `next_chunk` throws them away. + let mut iso = match qbz_disc::iso9660::IsoImage::open(&path) { + Ok(i) => i, + Err(e) => { + eprintln!("open failed: {e}"); + return; + } + }; + let start = t.start_lsn as u64; + let end = start + t.length_lsn as u64; + // 400 sectors is over a second of audio — plenty to see the pattern. + let want = ((end - start) as usize).min(400); + let raw = match iso.read_sectors(start, want) { + Ok(r) => r, + Err(e) => { + eprintln!("track {}: read failed: {e}", t.number); + continue; + } + }; + + let mut audio_len = 0usize; + let mut starts: Vec = Vec::new(); + for s in 0..want { + let lsn = start + s as u64; + let sector = &raw[s * 2048..(s + 1) * 2048]; + let packets = match sacd::parse_sector(sector, lsn) { + Ok(p) => p, + Err(e) => { + eprintln!("track {} lsn {lsn}: {e}", t.number); + break; + } + }; + for p in packets { + if p.data_type != sacd::DATA_TYPE_AUDIO { + continue; + } + if p.frame_start { + starts.push(audio_len); + } + audio_len += p.len; + } + } + + let first = starts.first().copied(); + // Are the later frame starts exactly one frame apart from the first? + let aligned_to_first = first.map(|f| { + starts + .iter() + .all(|s| (s - f) % FRAME == 0) + }); + // And is the NAIVE assumption (frames start at payload byte 0) right? + let aligned_to_zero = starts.iter().all(|s| s % FRAME == 0); + + println!( + "track {:>2}: audio {:>8} B in {want} sectors · frame starts {:>3} · \ + first at {:?} · spaced-from-first {:?} · naive-zero-aligned {}", + t.number, + audio_len, + starts.len(), + first, + aligned_to_first, + aligned_to_zero, + ); + if starts.len() >= 3 { + let gaps: Vec = starts.windows(2).map(|w| w[1] - w[0]).collect(); + let odd: Vec<&usize> = gaps.iter().filter(|g| **g != FRAME).collect(); + println!( + " gaps: first three {:?} · gaps that are NOT {FRAME}: {}", + &gaps[..gaps.len().min(3)], + odd.len() + ); + } + } +} diff --git a/crates/qbz-disc/src/cdda.rs b/crates/qbz-disc/src/cdda.rs new file mode 100644 index 000000000..7c924a913 --- /dev/null +++ b/crates/qbz-disc/src/cdda.rs @@ -0,0 +1,85 @@ +//! Reading a CD, whichever way this platform offers. +//! +//! ONE set of names — `list_devices`, `read_toc`, `TrackReader`, `read_audio`, +//! `drive_model` — over two implementations that share no code at all: +//! +//! * **Linux** ([`crate::cdda_linux`]) talks to the drive through the +//! kernel's ioctls and reads raw sectors. +//! * **macOS** ([`crate::cdda_macos`]) does not: the OS mounts the disc and +//! exposes one AIFF per track, so the "read" is a file read. +//! +//! This facade exists so nothing above it branches on the platform. `qbz-rip`, +//! `audible_qt` and `cdda_qt` were written against the Linux names and needed +//! no change when macOS arrived, which is the test of whether a seam is in the +//! right place. +//! +//! The two readers agree on the ANSWER even though they agree on nothing else: +//! measured on the same disc the same afternoon, both produce DiscID +//! `BeNBMsD8Du5NO2W61Yk.B2jwwIs-` and fingerprint `38bef21351f7fca3` (see +//! [`crate::toc`]). +//! +//! ON EVERY OTHER PLATFORM the calls are present and report "no drive". A +//! Windows build must COMPILE — the disc feature is one view of many — and the +//! honest failure is an empty drive list, not a crate that will not build. + +pub use crate::toc::{CdError, Toc, TocTrack}; + +/// The kernel accepts 1..=75 frames per `CDROMREADAUDIO` call; the macOS +/// reader uses the same chunk size so both platforms hand callers the same +/// shape of buffer. +pub const MAX_FRAMES_PER_READ: u32 = 75; + +#[cfg(target_os = "linux")] +pub use crate::cdda_linux::{ + drive_model, list_devices, looks_byte_swapped, read_audio, read_toc, TrackReader, +}; + +#[cfg(target_os = "macos")] +pub use crate::cdda_macos::{drive_model, list_devices, read_audio, read_toc, TrackReader}; + +/// The byte-order diagnostic is Linux's — it exists because a DRIVE can hand +/// back either order and this crate measured which. On macOS the OS produced +/// the file, AIFF's order is defined by the format, and there is nothing to +/// sniff; the reader normalises and the question does not arise. +#[cfg(not(target_os = "linux"))] +pub fn looks_byte_swapped(_pcm: &[u8]) -> bool { + false +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod unsupported { + use super::*; + use std::path::{Path, PathBuf}; + + pub fn list_devices() -> Vec { + Vec::new() + } + pub fn drive_model(_dev: &Path) -> Option { + None + } + pub fn read_toc(_dev: &Path) -> Result { + Err(CdError::NoDrive) + } + pub fn read_audio( + _dev: &Path, + _lsn: u32, + _frames: u32, + _out: &mut Vec, + ) -> Result<(), CdError> { + Err(CdError::NoDrive) + } + + /// Present so callers compile; every construction fails. + pub struct TrackReader(()); + impl TrackReader { + pub fn open(_dev: &Path, _track: &TocTrack) -> Result { + Err(CdError::NoDrive) + } + pub fn next_chunk(&mut self, _out: &mut Vec) -> Result { + Err(CdError::NoDrive) + } + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub use unsupported::{drive_model, list_devices, read_audio, read_toc, TrackReader}; diff --git a/crates/qbz-disc/src/cdda_linux.rs b/crates/qbz-disc/src/cdda_linux.rs new file mode 100644 index 000000000..88b5a7458 --- /dev/null +++ b/crates/qbz-disc/src/cdda_linux.rs @@ -0,0 +1,448 @@ +//! CD-DA off a real drive on LINUX, through the kernel's own ioctls. +//! +//! No libcdio. Not because libcdio is bad — it is better at RIPPING than this +//! will ever be — but because this path PLAYS a clean disc, and for that the +//! kernel interface is enough while a native library would have to be built +//! into Flatpak, Snap and AppImage. Both approaches need the same `--device` +//! permission in a sandbox; only one needs the library shipped. +//! +//! WHAT THIS IS NOT: accurate extraction. There is no overlap synchronisation, +//! no cache modelling, no jitter verification, no scratch reconstruction. On a +//! clean disc that is fine for playback; on a marginal one it can click, and +//! an unreadable sector surfaces as an ERROR rather than a hole full of zeros. +//! Silence that pretends to be audio is the one outcome bit-perfect playback +//! must never produce. +//! +//! MEASURED on the owner's USB drive with Tool — *Fear Inoculum* (2026-08-20), +//! before any of this existed: +//! - `cd-info` reports access mode IOCTL; the TOC reads back 7 audio tracks, +//! lead-out at LSN 356568; +//! - `CDROMREADAUDIO` over 20 frames from LSN 4000 returned 47 040 bytes, +//! 99 % non-zero samples, peak 14559 — real audio, not silence; +//! - the drive hands back LITTLE-endian samples. Neighbouring samples差 +//! average 73.5 read as LE against 20 971 read as BE, a 285x margin. So +//! the bytes go straight into a little-endian WAV with no swap. That is +//! measured for THIS drive; `looks_byte_swapped` below keeps the check +//! available as a diagnostic rather than as an article of faith. + +use std::os::fd::{AsRawFd, OwnedFd}; +use std::path::{Path, PathBuf}; + +use crate::toc::{CdError, Toc, TocTrack}; + +// --- ioctl numbers and layouts (uapi/linux/cdrom.h) ------------------------- +const CDROMREADTOCHDR: libc::c_ulong = 0x5305; +const CDROMREADTOCENTRY: libc::c_ulong = 0x5306; +const CDROMREADAUDIO: libc::c_ulong = 0x530e; +const CDROM_DRIVE_STATUS: libc::c_ulong = 0x5326; + +const CDROM_LBA: u8 = 0x01; +/// The lead-out's pseudo track number: its start is the end of the last track. +const CDROM_LEADOUT: u8 = 0xAA; +const CDS_DISC_OK: libc::c_int = 4; +/// `cdte_ctrl` bit 2 marks a DATA track. A mixed-mode disc carries one and it +/// must be skipped: handing its bytes to an audio path produces noise. +const CTRL_DATA: u8 = 0x04; +/// The kernel accepts 1..=75 frames per `CDROMREADAUDIO` call. +pub const MAX_FRAMES_PER_READ: u32 = 75; + +#[repr(C)] +#[derive(Default)] +struct CdromTocHdr { + trk0: u8, + trk1: u8, +} + +#[repr(C)] +#[derive(Default)] +struct CdromTocEntry { + track: u8, + /// adr in the low nibble, ctrl in the high one — a C bitfield, read whole. + adr_ctrl: u8, + format: u8, + _pad: u8, + addr_lba: i32, + datamode: u8, + _pad2: [u8; 3], +} + +#[repr(C)] +struct CdromReadAudio { + addr_lba: i32, + addr_format: u8, + _pad: [u8; 3], + nframes: libc::c_int, + buf: *mut u8, +} + +/// Optical devices the kernel is showing, most conventional first. +/// +/// `/dev/sr0` is NOT hardcoded anywhere in this crate: a box can have several +/// drives, and `/dev/cdrom` is a udev symlink that may point at any of them. +pub fn list_devices() -> Vec { + let mut out = Vec::new(); + for n in 0..8 { + let p = PathBuf::from(format!("/dev/sr{n}")); + if p.exists() { + out.push(p); + } + } + if out.is_empty() { + let alt = PathBuf::from("/dev/cdrom"); + if alt.exists() { + out.push(alt); + } + } + out +} + +/// "HL-DT-ST BD-RE BU40N" — the drive as the kernel names it, or `None`. +/// +/// A rip log without the drive in it cannot answer the question people +/// actually ask of one two years later ("which reader produced this?"), and +/// the answer is sitting in sysfs. Best-effort by design: a missing file, a +/// container with no `/sys`, or a device name that is a symlink somewhere +/// unexpected all mean "unknown", never an error. +pub fn drive_model(dev: &Path) -> Option { + // `/dev/cdrom` is a symlink; resolve it or the sysfs name is wrong. + let real = std::fs::canonicalize(dev).unwrap_or_else(|_| dev.to_path_buf()); + let name = real.file_name()?.to_string_lossy().into_owned(); + let base = PathBuf::from("/sys/block").join(&name).join("device"); + let read = |f: &str| { + std::fs::read_to_string(base.join(f)) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }; + match (read("vendor"), read("model")) { + (Some(v), Some(m)) => Some(format!("{v} {m}")), + (None, Some(m)) => Some(m), + (Some(v), None) => Some(v), + (None, None) => None, + } +} + +fn open_device(dev: &Path) -> Result { + // O_NONBLOCK is required, not optional: without it `open` on an optical + // device BLOCKS until a disc is loaded and spun up, which on an empty tray + // means hanging forever. + let c = std::ffi::CString::new(dev.as_os_str().as_encoded_bytes()) + .map_err(|_| CdError::Open(dev.to_path_buf(), std::io::Error::other("bad path")))?; + let fd = unsafe { libc::open(c.as_ptr(), libc::O_RDONLY | libc::O_NONBLOCK) }; + if fd < 0 { + return Err(CdError::Open( + dev.to_path_buf(), + std::io::Error::last_os_error(), + )); + } + Ok(unsafe { ::from_raw_fd(fd) }) +} + +/// Read the table of contents, skipping data tracks by their control bits. +pub fn read_toc(dev: &Path) -> Result { + let fd = open_device(dev)?; + let raw = fd.as_raw_fd(); + + let status = unsafe { libc::ioctl(raw, CDROM_DRIVE_STATUS, 0) }; + if status != CDS_DISC_OK { + return Err(CdError::NoDisc); + } + + let mut hdr = CdromTocHdr::default(); + if unsafe { libc::ioctl(raw, CDROMREADTOCHDR, &mut hdr) } < 0 { + return Err(CdError::Toc(std::io::Error::last_os_error())); + } + + let entry_lba = |n: u8| -> Result<(u32, u8), CdError> { + let mut e = CdromTocEntry { + track: n, + format: CDROM_LBA, + ..Default::default() + }; + if unsafe { libc::ioctl(raw, CDROMREADTOCENTRY, &mut e) } < 0 { + return Err(CdError::Toc(std::io::Error::last_os_error())); + } + Ok((e.addr_lba.max(0) as u32, e.adr_ctrl >> 4)) + }; + + let (leadout_lsn, _) = entry_lba(CDROM_LEADOUT)?; + + let mut raw_tracks: Vec<(u8, u32, bool)> = Vec::new(); + for n in hdr.trk0..=hdr.trk1 { + let (lba, ctrl) = entry_lba(n)?; + raw_tracks.push((n, lba, ctrl & CTRL_DATA == 0)); + } + + // A track's length is the gap to whatever starts next — the following + // track, or the lead-out for the last one. The TOC does not carry lengths. + let mut tracks = Vec::with_capacity(raw_tracks.len()); + for (i, &(number, start_lsn, is_audio)) in raw_tracks.iter().enumerate() { + let next = raw_tracks + .get(i + 1) + .map(|t| t.1) + .unwrap_or(leadout_lsn); + tracks.push(TocTrack { + number, + start_lsn, + sectors: next.saturating_sub(start_lsn), + is_audio, + }); + } + + if !tracks.iter().any(|t| t.is_audio) { + return Err(CdError::NotAudio); + } + + Ok(Toc { + device: dev.to_path_buf(), + tracks, + leadout_lsn, + }) +} + +/// Read `frames` sectors starting at `lsn` into `out`, which is resized to fit. +/// +/// Retries a few times before giving up: a marginal sector often reads on a +/// second attempt, and the alternative to a retry here is a click. What it +/// will NOT do is return zeros — an unrecoverable sector is an error, and the +/// caller stops the track rather than playing silence over it. +pub fn read_audio(dev: &Path, lsn: u32, frames: u32, out: &mut Vec) -> Result<(), CdError> { + let frames = frames.clamp(1, MAX_FRAMES_PER_READ); + out.resize(frames as usize * crate::CDDA_SECTOR_BYTES, 0); + let fd = open_device(dev)?; + read_audio_fd(fd.as_raw_fd(), dev, lsn, frames, out) +} + +fn read_audio_fd( + raw: libc::c_int, + dev: &Path, + lsn: u32, + frames: u32, + out: &mut [u8], +) -> Result<(), CdError> { + let mut last = std::io::Error::other("no attempt made"); + for attempt in 0..3 { + let mut arg = CdromReadAudio { + addr_lba: lsn as i32, + addr_format: CDROM_LBA, + _pad: [0; 3], + nframes: frames as libc::c_int, + buf: out.as_mut_ptr(), + }; + if unsafe { libc::ioctl(raw, CDROMREADAUDIO, &mut arg) } >= 0 { + if attempt > 0 { + log::info!("[cdda] sector {lsn} read on attempt {}", attempt + 1); + } + return Ok(()); + } + last = std::io::Error::last_os_error(); + } + log::warn!( + "[cdda] {} refused sector {lsn} after 3 attempts: {last}", + dev.display() + ); + Err(CdError::Read { lsn, source: last }) +} + +/// An open reader positioned on one track, so a whole track is read through a +/// SINGLE file descriptor instead of re-opening the device 4000 times. +pub struct TrackReader { + fd: OwnedFd, + device: PathBuf, + next_lsn: u32, + end_lsn: u32, +} + +impl TrackReader { + pub fn open(dev: &Path, track: &TocTrack) -> Result { + Ok(Self { + fd: open_device(dev)?, + device: dev.to_path_buf(), + next_lsn: track.start_lsn, + end_lsn: track.start_lsn + track.sectors, + }) + } + + pub fn finished(&self) -> bool { + self.next_lsn >= self.end_lsn + } + + /// Next chunk of raw CD-DA bytes, up to `MAX_FRAMES_PER_READ` sectors. + /// `Ok(0)` means the track is done. + pub fn next_chunk(&mut self, out: &mut Vec) -> Result { + if self.finished() { + out.clear(); + return Ok(0); + } + let frames = (self.end_lsn - self.next_lsn).min(MAX_FRAMES_PER_READ); + out.resize(frames as usize * crate::CDDA_SECTOR_BYTES, 0); + read_audio_fd( + self.fd.as_raw_fd(), + &self.device, + self.next_lsn, + frames, + out, + )?; + self.next_lsn += frames; + Ok(out.len()) + } +} + +/// Diagnostic: does this buffer look byte-swapped? +/// +/// Audio is continuous, so neighbouring samples differ by little; a wrong +/// endianness turns that into noise. Measured on the owner's drive the ratio +/// was 285x in favour of little-endian, so this returns false there. It exists +/// because "every drive is little-endian" is a claim about ONE drive, and the +/// honest way to hold it is as something checkable rather than assumed. +pub fn looks_byte_swapped(pcm: &[u8]) -> bool { + let step = |swap: bool| -> f64 { + let mut total = 0f64; + let mut n = 0u32; + let mut prev: Option = None; + // Left channel only: 4 bytes per frame, take the first pair. + for f in pcm.chunks_exact(4) { + let s = if swap { + i16::from_be_bytes([f[0], f[1]]) + } else { + i16::from_le_bytes([f[0], f[1]]) + }; + if let Some(p) = prev { + total += (s as i32 - p as i32).unsigned_abs() as f64; + n += 1; + } + prev = Some(s); + } + if n == 0 { + 0.0 + } else { + total / n as f64 + } + }; + let le = step(false); + let be = step(true); + be > 0.0 && le > be +} + +#[cfg(test)] +mod tests { + use super::*; + + fn toc(entries: &[(u8, u32, bool)], leadout: u32) -> Toc { + let mut tracks = Vec::new(); + for (i, &(number, start_lsn, is_audio)) in entries.iter().enumerate() { + let next = entries.get(i + 1).map(|e| e.1).unwrap_or(leadout); + tracks.push(TocTrack { + number, + start_lsn, + sectors: next - start_lsn, + is_audio, + }); + } + Toc { + device: PathBuf::from("/dev/null"), + tracks, + leadout_lsn: leadout, + } + } + + /// The REAL table of contents of the owner's disc, read 2026-08-20. + fn fear_inoculum() -> Toc { + toc( + &[ + (1, 0, true), + (2, 46_577, true), + (3, 100_047, true), + (4, 157_373, true), + (5, 218_718, true), + (6, 264_120, true), + (7, 285_735, true), + ], + 356_568, + ) + } + + #[test] + fn track_lengths_come_from_the_gaps_and_the_leadout() { + let t = fear_inoculum(); + // Track 1 = 10:21, and the LAST track has to reach the lead-out or the + // longest song on the disc would be cut short. + assert_eq!(t.tracks[0].sectors, 46_577); + assert_eq!(t.tracks[0].duration_secs(), 621); + assert_eq!(t.tracks[6].sectors, 70_833); + assert_eq!(t.tracks[6].duration_secs(), 944); + assert_eq!(t.tracks.len(), 7); + } + + #[test] + fn a_data_track_is_never_offered_as_audio() { + // Enhanced CD: three songs then a data track. + let t = toc(&[(1, 0, true), (2, 20_000, true), (3, 40_000, false)], 60_000); + assert_eq!(t.tracks.len(), 3); + assert_eq!(t.audio_tracks().count(), 2); + assert!(t.audio_tracks().all(|x| x.number != 3)); + } + + #[test] + fn the_fingerprint_identifies_the_medium_not_the_drive() { + let a = fear_inoculum(); + let mut b = fear_inoculum(); + b.device = PathBuf::from("/dev/sr3"); + // Same disc in a different drive: same identity. + assert_eq!(a.fingerprint(), b.fingerprint()); + + // A different disc with the same track COUNT must not collide — this + // is the case that would resolve a persisted queue onto the wrong + // album. + let other = toc( + &[ + (1, 0, true), + (2, 46_500, true), + (3, 100_047, true), + (4, 157_373, true), + (5, 218_718, true), + (6, 264_120, true), + (7, 285_735, true), + ], + 356_568, + ); + assert_ne!(a.fingerprint(), other.fingerprint()); + } + + #[test] + fn frames_per_track_match_the_wav_the_player_will_be_promised() { + let t = fear_inoculum(); + let first = &t.tracks[0]; + assert_eq!(first.frames(), 46_577 * 588); + // 10:21 of 44.1k stereo 16-bit — the size the streaming path declares. + assert_eq!( + crate::wav_total_size_16(first.frames(), 2), + 44 + 46_577 * 588 * 4 + ); + } + + #[test] + fn a_read_is_clamped_to_what_the_kernel_accepts() { + // The ioctl takes 1..=75 frames; asking for more is not a silent + // truncation somewhere deep, it is clamped here where it is visible. + assert_eq!(MAX_FRAMES_PER_READ, 75); + } + + #[test] + fn the_endianness_probe_agrees_with_the_measured_drive() { + // A smooth ramp is what real audio looks like read the RIGHT way. + let mut pcm = Vec::new(); + for i in 0..2000i16 { + let s = (i.wrapping_mul(7)) % 3000; + pcm.extend_from_slice(&s.to_le_bytes()); // L + pcm.extend_from_slice(&s.to_le_bytes()); // R + } + assert!(!looks_byte_swapped(&pcm), "little-endian audio flagged as swapped"); + + let swapped: Vec = pcm + .chunks_exact(2) + .flat_map(|p| [p[1], p[0]]) + .collect(); + assert!(looks_byte_swapped(&swapped), "byte-swapped audio not detected"); + } +} diff --git a/crates/qbz-disc/src/cdda_macos.rs b/crates/qbz-disc/src/cdda_macos.rs new file mode 100644 index 000000000..e87de2ad6 --- /dev/null +++ b/crates/qbz-disc/src/cdda_macos.rs @@ -0,0 +1,585 @@ +//! CD-DA on macOS, where the operating system has already done the reading. +//! +//! macOS MOUNTS an audio CD. There is no raw-sector ioctl to reach for and no +//! device node worth opening: the disc appears at `/Volumes//` as one +//! AIFF per track, plus a `.TOC.plist` describing the geometry. So the "read" +//! here is a file read, and the seam that makes it fit — [`crate::CdRef`] +//! naming a device and a sector range — keeps working because the sector range +//! maps onto a byte range in the AIFF exactly. +//! +//! WHAT THE "DEVICE" IS. On Linux it is `/dev/sr0`. Here it is the mounted +//! VOLUME (`/Volumes/Fear Inoculum`). Both are "where this disc is", both are +//! a `PathBuf`, and nothing above this layer has to know which kind it holds. +//! +//! MEASURED on the Mac mini with the owner's Tool — *Fear Inoculum* +//! (2026-08-21), USB drive moved over from the Linux box: +//! +//! - `.TOC.plist` gives `Start Block` WITH the 150-sector lead-in included +//! (track 1 starts at 150, lead-out at 356718) where the Linux ioctl +//! reports it without (0 and 356568). Subtracting `LEAD_IN` reproduces the +//! kernel's numbers EXACTLY: same per-track LSNs, same sector counts, and +//! therefore the same DiscID `BeNBMsD8Du5NO2W61Yk.B2jwwIs-` and the same +//! fingerprint `38bef21351f7fca3`. That equality is the point — a disc +//! corrected on one machine is found on the other. +//! - The files are named ` .aiff` when the Music app has looked +//! the disc up, and `<n> Audio Track.aiff` when it has not. Only the +//! LEADING INTEGER is dependable, so that is all this matches on. The +//! titles in those names are deliberately ignored: QBZ does its own +//! DiscID lookup, and taking Apple's guess would make the naming depend on +//! whether some other app happened to have network access. +//! +//! BYTE ORDER, and it is NOT what the format's reputation suggests. AIFF is +//! big-endian by definition — Apple, 68k era — so the obvious reading is to +//! swap every pair on the way out. That is wrong here, and measuring caught it: +//! macOS writes **AIFC with compression type `sowt`**, which is `twos` +//! spelled backwards and means the samples are ALREADY little-endian. An +//! unconditional swap produced audio that looked perfectly healthy by every +//! cheap statistic (97 % non-zero, full-scale peak) and did not match the +//! Linux rip of the same track by a single byte. +//! +//! So the COMM chunk is read and the swap is CONDITIONAL. VERIFIED END TO END, +//! all seven tracks of the owner's disc: the PCM this file yields on macOS +//! md5s to exactly what `metaflac --show-md5sum` reports for the FLACs the +//! Linux ioctl path ripped from the same disc hours earlier. Two operating +//! systems, two read paths that share no code, one identical stream of samples. +//! +//! ONE TRANSIENT DISAGREEMENT, and it is worth knowing about. Reading all +//! seven tracks back to back once produced a different digest for the LAST +//! track; three further reads of that track all matched. So the read is not +//! perfectly reproducible on a marginal region — which is precisely the +//! caveat `cdda_linux` states about this whole approach (no overlap +//! synchronisation, no jitter verification, no scratch reconstruction). It is +//! also why the rip log must not claim more than it does. +//! +//! And a difference in what "0 read errors" MEANS here: on Linux an unreadable +//! sector fails the ioctl and aborts. On macOS the OS did the reading, so a +//! failure surfaces only if the file read fails — a drive that quietly hands +//! back wrong-but-valid bytes is invisible to both, and to EAC in burst mode. + +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +use crate::toc::{CdError, Toc, TocTrack}; + +/// Sectors of lead-in that macOS includes in every `Start Block` and the +/// kernel does not. It is not a fudge factor: 150 sectors is two seconds, the +/// Red Book lead-in, and the MusicBrainz DiscID is defined in terms of the +/// numbers WITH it — which is why `discid::disc_id` adds it back. +const LEAD_IN: u32 = 150; + +/// Mounted audio CDs, one entry per volume that has a `.TOC.plist`. +/// +/// The plist is the discriminator rather than the file extension: a volume +/// full of AIFFs somebody copied to a USB stick is not a disc, and offering to +/// rip it would be offering to copy files the user already has. +pub fn list_devices() -> Vec<PathBuf> { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir("/Volumes") else { + return out; + }; + for e in entries.flatten() { + let p = e.path(); + if p.join(".TOC.plist").is_file() { + out.push(p); + } + } + out.sort(); + out +} + +/// The drive behind a mounted volume, for the rip log. +/// +/// `diskutil` knows, but shelling out for one cosmetic line in a log is not +/// worth the process — and on this path the OS did the reading anyway, so the +/// honest answer is that the drive is not what produced these bytes. The log's +/// non-Linux arm says so in words instead. +pub fn drive_model(_dev: &Path) -> Option<String> { + None +} + +/// Read the table of contents out of the volume's `.TOC.plist`. +pub fn read_toc(dev: &Path) -> Result<Toc, CdError> { + let path = dev.join(".TOC.plist"); + let raw = std::fs::read(&path).map_err(|e| CdError::Open(path.clone(), e))?; + let value: plist::Value = plist::from_bytes(&raw) + .map_err(|e| CdError::Toc(std::io::Error::other(format!("{path:?}: {e}"))))?; + + let sessions = value + .as_dictionary() + .and_then(|d| d.get("Sessions")) + .and_then(|s| s.as_array()) + .ok_or_else(|| CdError::Toc(std::io::Error::other("no Sessions in .TOC.plist")))?; + + // A hybrid disc can carry several sessions; the audio one is session 1 and + // is what a CD player sees. Anything past it is data by construction. + let session = sessions + .first() + .and_then(|s| s.as_dictionary()) + .ok_or_else(|| CdError::Toc(std::io::Error::other("empty Sessions")))?; + + let leadout = session + .get("Leadout Block") + .and_then(|v| v.as_unsigned_integer()) + .ok_or_else(|| CdError::Toc(std::io::Error::other("no Leadout Block")))? + as u32; + + let array = session + .get("Track Array") + .and_then(|v| v.as_array()) + .ok_or_else(|| CdError::Toc(std::io::Error::other("no Track Array")))?; + + // (number, start, is_audio), in disc order. + let mut raw_tracks: Vec<(u8, u32, bool)> = Vec::new(); + for t in array { + let Some(d) = t.as_dictionary() else { continue }; + let (Some(point), Some(start)) = ( + d.get("Point").and_then(|v| v.as_unsigned_integer()), + d.get("Start Block").and_then(|v| v.as_unsigned_integer()), + ) else { + continue; + }; + // `Data` true is the data track of a mixed-mode disc — the same track + // the Linux path skips by its `cdte_ctrl` bit. + let is_audio = !d.get("Data").and_then(|v| v.as_boolean()).unwrap_or(false); + raw_tracks.push((point as u8, start as u32, is_audio)); + } + raw_tracks.sort_by_key(|t| t.1); + if raw_tracks.is_empty() { + return Err(CdError::NotAudio); + } + if !raw_tracks.iter().any(|t| t.2) { + return Err(CdError::NotAudio); + } + + // A track's length is the distance to the NEXT track's start, and the last + // one runs to the lead-out — computed AFTER the lead-in is removed so the + // subtraction cannot underflow on track 1. + let starts: Vec<u32> = raw_tracks + .iter() + .map(|t| t.1.saturating_sub(LEAD_IN)) + .collect(); + let leadout = leadout.saturating_sub(LEAD_IN); + let tracks = raw_tracks + .iter() + .enumerate() + .map(|(i, (number, _, is_audio))| { + let start = starts[i]; + let end = starts.get(i + 1).copied().unwrap_or(leadout); + TocTrack { + number: *number, + start_lsn: start, + sectors: end.saturating_sub(start), + is_audio: *is_audio, + } + }) + .collect(); + + Ok(Toc { + device: dev.to_path_buf(), + tracks, + leadout_lsn: leadout, + }) +} + +/// The AIFF the OS mounted for one track number. +/// +/// Matched on the LEADING INTEGER of the file name and nothing else: the rest +/// is whatever the Music app decided to call it, which depends on a lookup QBZ +/// did not make and may not have happened at all. +fn track_file(dev: &Path, number: u8) -> Option<PathBuf> { + let entries = std::fs::read_dir(dev).ok()?; + for e in entries.flatten() { + let p = e.path(); + let ext = p.extension()?.to_string_lossy().to_ascii_lowercase(); + if ext != "aiff" && ext != "aif" { + continue; + } + let name = p.file_name()?.to_string_lossy().into_owned(); + let digits: String = name.chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.parse::<u8>().ok() == Some(number) { + return Some(p); + } + } + None +} + +/// Where the audio sits inside an AIFF/AIFC, and which way round its bytes are. +/// +/// A hand-rolled walk of the IFF chunks rather than a decoder dependency: the +/// payload is raw PCM and the questions are only "where does SSND's data +/// begin", "how long is it" and "is it big-endian". `offset`/`blockSize` in +/// SSND are almost always zero but are honoured anyway, because a file that +/// sets them and is read as if it had not is off by that many bytes for its +/// whole length. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AudioExtent { + at: u64, + len: u64, + /// The samples need swapping to reach little-endian. + big_endian: bool, +} + +fn aiff_audio_extent(file: &mut std::fs::File) -> Result<AudioExtent, std::io::Error> { + let mut header = [0u8; 12]; + file.read_exact(&mut header)?; + if &header[0..4] != b"FORM" { + return Err(std::io::Error::other("not an IFF file")); + } + let is_aifc = &header[8..12] == b"AIFC"; + if &header[8..12] != b"AIFF" && !is_aifc { + return Err(std::io::Error::other("not an AIFF")); + } + // Plain AIFF has no compression field and is big-endian by definition. + // AIFC declares one, and macOS writes `sowt` — little-endian. + let mut big_endian = true; + let mut pos = 12u64; + loop { + let mut ch = [0u8; 8]; + file.seek(SeekFrom::Start(pos))?; + if file.read_exact(&mut ch).is_err() { + return Err(std::io::Error::other("no SSND chunk")); + } + let size = u32::from_be_bytes([ch[4], ch[5], ch[6], ch[7]]) as u64; + if &ch[0..4] == b"COMM" && is_aifc && size >= 22 { + // channels(2) frames(4) bits(2) rate(10) = 18, then the 4-byte + // compression id. + let mut body = [0u8; 22]; + file.read_exact(&mut body)?; + big_endian = match &body[18..22] { + // Little-endian two's complement: `twos` written backwards, + // which is the joke and the definition. + b"sowt" => false, + // `NONE` and `twos` are the big-endian spellings; anything + // else is a codec this reader does not decode, and the honest + // move is to say so rather than emit noise. + b"NONE" | b"twos" | b"raw " => true, + other => { + return Err(std::io::Error::other(format!( + "unsupported AIFC compression {:?}", + String::from_utf8_lossy(other) + ))) + } + }; + } + if &ch[0..4] == b"SSND" { + let mut ssnd = [0u8; 8]; + file.read_exact(&mut ssnd)?; + let offset = u32::from_be_bytes([ssnd[0], ssnd[1], ssnd[2], ssnd[3]]) as u64; + return Ok(AudioExtent { + at: pos + 8 + 8 + offset, + len: size.saturating_sub(8 + offset), + big_endian, + }); + } + // IFF chunks are word-aligned: an odd size is followed by a pad byte. + pos += 8 + size + (size & 1); + } +} + +/// Reads one track's audio out of the mounted AIFF, in the sector-sized +/// chunks the Linux path hands back. +/// +/// The API is the Linux `TrackReader`'s, deliberately: `audible_qt` and +/// `qbz-rip` drive both platforms through the same two calls. +pub struct TrackReader { + file: std::fs::File, + /// Byte offset of the next read, absolute in the file. + at: u64, + /// One past the last audio byte of this track. + end: u64, + /// The file's samples are big-endian and need swapping on the way out. + /// FALSE on every disc macOS mounts (`sowt`), and honoured anyway because + /// a plain AIFF is a legal thing to find here. + big_endian: bool, +} + +impl TrackReader { + /// Open a reader over the audio at this track's SECTOR RANGE. + /// + /// Resolved by `start_lsn`, NOT by `number`, and that is not a preference: + /// `audible_qt::play_cd_track` rebuilds a `TocTrack` from a [`crate::CdRef`] + /// — which carries a device and a sector range and no track number — and + /// fills the number in as ZERO. On Linux nothing reads it. Here, matching + /// on it meant every CD track answered "could not read the disc", and the + /// caller was right: the sector range IS the address, so that is what this + /// resolves. + /// + /// A start INSIDE a track is honoured too, so a caller that asks for a + /// sector part-way in gets that offset rather than the track's beginning. + pub fn open(dev: &Path, track: &TocTrack) -> Result<Self, CdError> { + let toc = read_toc(dev)?; + let owner = toc + .tracks + .iter() + .find(|t| track.start_lsn >= t.start_lsn && track.start_lsn < t.start_lsn + t.sectors) + // A zero-length or out-of-range request still gets the track whose + // start matches exactly, which is what a caller passing a whole + // track means. + .or_else(|| toc.tracks.iter().find(|t| t.start_lsn == track.start_lsn)) + .ok_or(CdError::NoDisc)?; + let into_track = + (track.start_lsn - owner.start_lsn) as u64 * crate::CDDA_SECTOR_BYTES as u64; + + let path = track_file(dev, owner.number).ok_or(CdError::NoDisc)?; + let mut file = + std::fs::File::open(&path).map_err(|e| CdError::Open(path.clone(), e))?; + let extent = aiff_audio_extent(&mut file).map_err(|e| CdError::Open(path.clone(), e))?; + + // The TOC's sector count is the LENGTH, and the SSND payload is only a + // ceiling. Measured: SSND carries 570 frames MORE than COMM declares + // on every track of this disc (2280 bytes of padding), so trusting the + // payload size would append a fifth of a second of tail to each track + // and break the digest match with the Linux rip. COMM's frame count + // agrees with the TOC exactly, on all seven. + let want = track.sectors as u64 * crate::CDDA_SECTOR_BYTES as u64; + let available = extent.len.saturating_sub(into_track); + let len = want.min(available); + let at = extent.at + into_track; + file.seek(SeekFrom::Start(at)) + .map_err(|e| CdError::Open(path.clone(), e))?; + Ok(Self { + file, + at, + end: at + len, + big_endian: extent.big_endian, + }) + } + + /// Append the next chunk to `out` (cleared first), returning its length. + /// Zero means the track is done. + pub fn next_chunk(&mut self, out: &mut Vec<u8>) -> Result<usize, CdError> { + out.clear(); + if self.at >= self.end { + return Ok(0); + } + let want = (crate::cdda::MAX_FRAMES_PER_READ as u64 + * crate::CDDA_SECTOR_BYTES as u64) + .min(self.end - self.at) as usize; + out.resize(want, 0); + self.file + .read_exact(out) + .map_err(|e| CdError::Read { lsn: 0, source: e })?; + self.at += want as u64; + // Only when the FILE says so. macOS writes `sowt` (little-endian), so + // this does nothing on a mounted CD — and doing it unconditionally, + // which is what "AIFF is big-endian" suggests, silently produced audio + // that passed every cheap sanity check and matched nothing. + if self.big_endian { + for pair in out.chunks_exact_mut(2) { + pair.swap(0, 1); + } + } + Ok(want) + } +} + +/// Sector-addressed read, for parity with the Linux entry point. +pub fn read_audio(dev: &Path, lsn: u32, frames: u32, out: &mut Vec<u8>) -> Result<(), CdError> { + let toc = read_toc(dev)?; + let track = toc + .tracks + .iter() + .find(|t| lsn >= t.start_lsn && lsn < t.start_lsn + t.sectors) + .ok_or(CdError::NoDisc)?; + // `open` resolves and seeks by `start_lsn`, so asking for the requested + // sector directly is enough — there is no second offset to apply here. + let mut reader = TrackReader::open( + dev, + &TocTrack { + number: track.number, + start_lsn: lsn, + sectors: frames, + is_audio: true, + }, + )?; + out.clear(); + let mut chunk = Vec::new(); + while reader.next_chunk(&mut chunk)? > 0 { + out.extend_from_slice(&chunk); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The lead-in conversion, against the numbers read off the owner's disc + /// on BOTH machines the same afternoon. If this ever drifts, a disc + /// corrected on Linux stops being found on macOS and nothing says why. + #[test] + fn the_mac_toc_reproduces_the_kernels_numbers_exactly() { + let mac_starts: [u32; 7] = [150, 46727, 100197, 157523, 218868, 264270, 285885]; + let linux_starts: [u32; 7] = [0, 46577, 100047, 157373, 218718, 264120, 285735]; + for (m, l) in mac_starts.iter().zip(linux_starts.iter()) { + assert_eq!(m - LEAD_IN, *l); + } + assert_eq!(356718 - LEAD_IN, 356568); + } + + #[test] + fn a_track_file_is_matched_on_its_number_and_never_on_its_title() { + let dir = std::env::temp_dir().join(format!("qbz-mac-cd-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // Both shapes the Music app produces, plus a decoy that starts with a + // digit but is not a track. + std::fs::write(dir.join("1 Fear Inoculum.aiff"), b"x").unwrap(); + std::fs::write(dir.join("2 Audio Track.aiff"), b"x").unwrap(); + std::fs::write(dir.join("3 Invincible.txt"), b"x").unwrap(); + assert!(track_file(&dir, 1).unwrap().ends_with("1 Fear Inoculum.aiff")); + assert!(track_file(&dir, 2).unwrap().ends_with("2 Audio Track.aiff")); + assert!(track_file(&dir, 3).is_none(), "a .txt is not a track"); + assert!(track_file(&dir, 9).is_none()); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Build a volume that looks like a mounted audio CD: a `.TOC.plist` and + /// one AIFC per track, `sowt` like the real thing. + fn fake_volume(dir: &Path, tracks: &[(u8, u32, u32)]) { + std::fs::create_dir_all(dir).unwrap(); + let mut plist_tracks = Vec::new(); + for (n, start, sectors) in tracks { + let mut d = plist::Dictionary::new(); + d.insert("Point".into(), plist::Value::from(*n as u64)); + d.insert("Start Block".into(), plist::Value::from((*start + LEAD_IN) as u64)); + d.insert("Data".into(), plist::Value::from(false)); + plist_tracks.push(plist::Value::Dictionary(d)); + + // One AIFC whose payload is the track number repeated, so a read + // that lands on the wrong file is unmistakable. + let bytes = *sectors as usize * crate::CDDA_SECTOR_BYTES; + let mut f = Vec::new(); + f.extend_from_slice(b"FORM"); + f.extend_from_slice(&0u32.to_be_bytes()); + f.extend_from_slice(b"AIFC"); + f.extend_from_slice(b"COMM"); + f.extend_from_slice(&22u32.to_be_bytes()); + f.extend_from_slice(&2u16.to_be_bytes()); + f.extend_from_slice(&((bytes / 4) as u32).to_be_bytes()); + f.extend_from_slice(&16u16.to_be_bytes()); + f.extend_from_slice(&[0u8; 10]); + f.extend_from_slice(b"sowt"); + f.extend_from_slice(b"SSND"); + f.extend_from_slice(&((bytes + 8) as u32).to_be_bytes()); + f.extend_from_slice(&0u32.to_be_bytes()); + f.extend_from_slice(&0u32.to_be_bytes()); + f.extend(std::iter::repeat(*n).take(bytes)); + std::fs::write(dir.join(format!("{n} Track {n}.aiff")), &f).unwrap(); + } + let last = tracks.last().unwrap(); + let mut session = plist::Dictionary::new(); + session.insert("First Track".into(), plist::Value::from(1u64)); + session.insert("Last Track".into(), plist::Value::from(tracks.len() as u64)); + session.insert( + "Leadout Block".into(), + plist::Value::from((last.1 + last.2 + LEAD_IN) as u64), + ); + session.insert("Track Array".into(), plist::Value::Array(plist_tracks)); + let mut root = plist::Dictionary::new(); + root.insert( + "Sessions".into(), + plist::Value::Array(vec![plist::Value::Dictionary(session)]), + ); + plist::to_file_xml(dir.join(".TOC.plist"), &plist::Value::Dictionary(root)).unwrap(); + } + + /// THE regression guard for the 2026-08-21 defect. + /// + /// `audible_qt::play_cd_track` rebuilds its `TocTrack` from a `CdRef`, + /// which has no track number, and passes ZERO. Resolving by number meant + /// every CD track on macOS answered "could not read the disc" — while + /// ripping, which passes a real number, worked fine. So the two paths + /// disagreed and only one of them was ever exercised. + #[test] + fn a_track_opens_by_its_sector_range_even_with_no_track_number() { + let dir = std::env::temp_dir().join(format!("qbz-mac-lsn-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // Two tracks, 2 and 3 sectors long. + fake_volume(&dir, &[(1, 0, 2), (2, 2, 3)]); + + let toc = read_toc(&dir).unwrap(); + assert_eq!(toc.tracks.len(), 2); + assert_eq!(toc.tracks[0].start_lsn, 0); + assert_eq!(toc.tracks[1].start_lsn, 2); + assert_eq!(toc.tracks[1].sectors, 3); + + // Exactly what playback hands over: no number. + let as_playback_sees_it = TocTrack { + number: 0, + start_lsn: 2, + sectors: 3, + is_audio: true, + }; + let mut reader = TrackReader::open(&dir, &as_playback_sees_it) + .expect("a numberless track must still open"); + let mut chunk = Vec::new(); + let mut all = Vec::new(); + while reader.next_chunk(&mut chunk).unwrap() > 0 { + all.extend_from_slice(&chunk); + } + assert_eq!(all.len(), 3 * crate::CDDA_SECTOR_BYTES); + assert!(all.iter().all(|b| *b == 2), "it opened track 2's file"); + + // And a start PART-WAY into a track lands at that offset, not at the + // track's beginning. + let mid = TocTrack { + number: 0, + start_lsn: 3, + sectors: 2, + is_audio: true, + }; + let mut reader = TrackReader::open(&dir, &mid).unwrap(); + let mut all = Vec::new(); + while reader.next_chunk(&mut chunk).unwrap() > 0 { + all.extend_from_slice(&chunk); + } + assert_eq!(all.len(), 2 * crate::CDDA_SECTOR_BYTES); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// `sowt` means the samples are ALREADY little-endian. Swapping them — + /// which "AIFF is big-endian" invites — produced audio that passed every + /// cheap statistic and matched the Linux rip nowhere. + #[test] + fn a_sowt_payload_is_not_byte_swapped() { + let dir = std::env::temp_dir().join(format!("qbz-mac-sowt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + fake_volume(&dir, &[(1, 0, 1)]); + let mut file = std::fs::File::open(dir.join("1 Track 1.aiff")).unwrap(); + let e = aiff_audio_extent(&mut file).unwrap(); + assert!(!e.big_endian, "sowt is little-endian"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_ssnd_payload_is_found_past_the_chunks_before_it() { + let dir = std::env::temp_dir().join(format!("qbz-mac-aiff-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.aiff"); + let mut f = Vec::new(); + f.extend_from_slice(b"FORM"); + f.extend_from_slice(&0u32.to_be_bytes()); // size, unread + f.extend_from_slice(b"AIFF"); + // A COMM chunk with an ODD size, so the pad byte has to be honoured. + f.extend_from_slice(b"COMM"); + f.extend_from_slice(&3u32.to_be_bytes()); + f.extend_from_slice(&[1, 2, 3, 0]); + f.extend_from_slice(b"SSND"); + f.extend_from_slice(&(8u32 + 4).to_be_bytes()); + f.extend_from_slice(&0u32.to_be_bytes()); // offset + f.extend_from_slice(&0u32.to_be_bytes()); // blockSize + f.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); + std::fs::write(&path, &f).unwrap(); + + let mut file = std::fs::File::open(&path).unwrap(); + let e = aiff_audio_extent(&mut file).unwrap(); + assert_eq!(e.len, 4); + assert!(e.big_endian, "a plain AIFF has no compression field"); + let mut buf = vec![0u8; 4]; + file.seek(SeekFrom::Start(e.at)).unwrap(); + file.read_exact(&mut buf).unwrap(); + assert_eq!(buf, [0xAA, 0xBB, 0xCC, 0xDD]); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/qbz-disc/src/discid.rs b/crates/qbz-disc/src/discid.rs new file mode 100644 index 000000000..ae05c49c7 --- /dev/null +++ b/crates/qbz-disc/src/discid.rs @@ -0,0 +1,147 @@ +//! The MusicBrainz Disc ID, computed from a table of contents. +//! +//! A CD-DA carries no titles. The only thing it has that identifies the RECORD +//! rather than the plastic is the geometry of its tracks, and MusicBrainz's +//! Disc ID is a hash of exactly that — so a disc anyone has ever submitted can +//! be named without reading a byte of audio. +//! +//! Verified end to end against the owner's disc on 2026-08-20: the TOC read by +//! `cdda::read_toc` produced `BeNBMsD8Du5NO2W61Yk.B2jwwIs-`, and MusicBrainz +//! answered with Tool — *Fear Inoculum* (2019-08-30) whose seven track lengths +//! match the measured TOC to the second. That is the whole chain — reader, +//! hash, encoding, lookup — proven at once, which is why it is worth writing +//! down here rather than trusting the spec alone. +//! +//! Spec: <https://musicbrainz.org/doc/Disc_ID_Calculation> + +use sha1::{Digest, Sha1}; + +/// Blocks between the start of the disc and the first track. LBA numbering +/// starts at 0 but track 1 physically begins at 00:02:00, so every offset in +/// the hash is its LBA plus this. +const LEAD_IN: u32 = 150; +/// The hash always covers 99 track slots, whether or not the disc has them. +const TRACK_SLOTS: usize = 99; + +/// Compute the Disc ID for a table of contents. +/// +/// `starts` are the LBA start addresses of the AUDIO tracks in disc order and +/// `leadout` is the lead-out LBA — both exactly as the drive reports them, in +/// this crate's `Toc`. +/// +/// Returns `None` for a disc with no tracks or more than 99, which cannot have +/// a Disc ID at all. +pub fn disc_id(starts: &[u32], leadout: u32) -> Option<String> { + if starts.is_empty() || starts.len() > TRACK_SLOTS { + return None; + } + // The hash input is TEXT, not packed binary: uppercase hex, fixed width, + // zero padded. Getting this wrong produces a valid-looking 28-character id + // that matches nothing, which is a failure mode with no symptom beyond + // "MusicBrainz never knows any of my discs". + let mut blob = String::with_capacity(4 + (TRACK_SLOTS + 1) * 8); + blob.push_str(&format!("{:02X}", 1)); + blob.push_str(&format!("{:02X}", starts.len())); + blob.push_str(&format!("{:08X}", leadout + LEAD_IN)); + for i in 0..TRACK_SLOTS { + let v = starts.get(i).map(|s| s + LEAD_IN).unwrap_or(0); + blob.push_str(&format!("{v:08X}")); + } + + let digest = Sha1::digest(blob.as_bytes()); + + // MusicBrainz's base64 is RFC 4648 with three substitutions. It is not + // URL-safe base64 (that keeps `=`), so the alphabet is applied by hand. + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(digest); + Some( + b64.replace('+', ".") + .replace('/', "_") + .replace('=', "-"), + ) +} + +/// The URL that resolves a Disc ID to releases, with recordings and artists. +/// +/// MusicBrainz requires a descriptive User-Agent and rate-limits anonymous +/// clients to one request a second; a caller that ignores either gets blocked, +/// so both belong to whoever performs the request, not here. +pub fn lookup_url(disc_id: &str) -> String { + format!( + "https://musicbrainz.org/ws/2/discid/{disc_id}\ + ?fmt=json&inc=recordings+artist-credits+release-groups" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The owner's disc, and the id MusicBrainz actually answered to. + const FEAR_INOCULUM_STARTS: [u32; 7] = + [0, 46_577, 100_047, 157_373, 218_718, 264_120, 285_735]; + const FEAR_INOCULUM_LEADOUT: u32 = 356_568; + const FEAR_INOCULUM_ID: &str = "BeNBMsD8Du5NO2W61Yk.B2jwwIs-"; + + #[test] + fn the_owners_disc_hashes_to_the_id_musicbrainz_answered_to() { + // This is not a self-consistency check. The expected value came from + // a live lookup that returned Tool — Fear Inoculum with all seven + // track lengths matching the TOC, so if this test fails the reader, + // the hash or the encoding has drifted from something real. + assert_eq!( + disc_id(&FEAR_INOCULUM_STARTS, FEAR_INOCULUM_LEADOUT).as_deref(), + Some(FEAR_INOCULUM_ID) + ); + } + + #[test] + fn an_id_is_always_twenty_eight_characters_of_the_modified_alphabet() { + let id = disc_id(&FEAR_INOCULUM_STARTS, FEAR_INOCULUM_LEADOUT).unwrap(); + assert_eq!(id.len(), 28); + // The three substitutions must have happened: no RFC 4648 characters + // may survive, or the URL is malformed and the lookup 404s. + assert!(!id.contains('+') && !id.contains('/') && !id.contains('=')); + } + + #[test] + fn the_lead_in_is_added_or_every_id_is_wrong() { + // Removing the 150-block lead-in yields a different, useless id. The + // guard exists because the mistake is invisible: the id still looks + // right, it just matches nothing on earth. + let shifted: Vec<u32> = FEAR_INOCULUM_STARTS.iter().map(|s| s + LEAD_IN).collect(); + assert_ne!( + disc_id(&shifted, FEAR_INOCULUM_LEADOUT + LEAD_IN).as_deref(), + Some(FEAR_INOCULUM_ID) + ); + } + + #[test] + fn a_disc_that_cannot_have_an_id_gets_none_rather_than_a_plausible_string() { + assert_eq!(disc_id(&[], 100), None); + let too_many: Vec<u32> = (0..100).collect(); + assert_eq!(disc_id(&too_many, 200_000), None); + } + + #[test] + fn two_different_discs_do_not_share_an_id() { + let other = [0u32, 46_500, 100_047, 157_373, 218_718, 264_120, 285_735]; + assert_ne!( + disc_id(&other, FEAR_INOCULUM_LEADOUT), + disc_id(&FEAR_INOCULUM_STARTS, FEAR_INOCULUM_LEADOUT) + ); + } + + #[test] + fn the_lookup_url_carries_what_a_title_needs() { + let u = lookup_url(FEAR_INOCULUM_ID); + assert!(u.contains(FEAR_INOCULUM_ID)); + // Without `recordings` the answer has releases but no track titles, + // which is the entire point of asking. `release-groups` matters for a + // different reason: cover art is far more reliably attached to the + // GROUP than to one pressing. + assert!(u.contains("inc=recordings")); + assert!(u.contains("release-groups")); + assert!(u.contains("fmt=json")); + } +} diff --git a/crates/qbz-disc/src/iso9660.rs b/crates/qbz-disc/src/iso9660.rs new file mode 100644 index 000000000..a6126efce --- /dev/null +++ b/crates/qbz-disc/src/iso9660.rs @@ -0,0 +1,354 @@ +//! A read-only ISO 9660 directory walk, without mounting anything. +//! +//! Mounting needs root (or a helper the app has no business shipping), and the +//! two things QBZ wants out of a disc image — a SACD's audio area and the +//! ordinary audio files inside a data image — both only need to find files and +//! read byte ranges. That is a few hundred lines, and it costs no privileges. +//! +//! Scope is deliberately small: the Primary Volume Descriptor, directory +//! records, and extents. No Joliet, no Rock Ridge, no El Torito. What it will +//! NOT do is guess: a file recorded in several extents, or interleaved, is +//! DETECTED and reported rather than flattened to its first extent, because a +//! flattened audio file plays the wrong bytes and nothing upstream can tell. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +/// ISO 9660 logical sector. The standard permits others; every disc image in +/// practice, and both of the owner's SACDs, use 2048. +pub const SECTOR: u64 = 2048; +/// The volume descriptors start here. +const PVD_LSN: u64 = 16; + +#[derive(Debug, thiserror::Error)] +pub enum IsoError { + #[error("cannot open {0}: {1}")] + Open(PathBuf, std::io::Error), + #[error("read failed at sector {lsn}: {source}")] + Read { + lsn: u64, + #[source] + source: std::io::Error, + }, + #[error("not an ISO 9660 image (no CD001 descriptor)")] + NotIso, + #[error("the image declares a {0}-byte logical block; only 2048 is supported")] + BlockSize(u16), + #[error("{0} is recorded in several extents or interleaved — refusing to read it as one range")] + MultiExtent(String), + #[error("directory tree is deeper or larger than expected (possible loop)")] + Malformed, +} + +/// One entry of a directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// As recorded, with the `;1` version suffix already stripped. + pub name: String, + pub lsn: u32, + pub size: u32, + pub is_dir: bool, + /// File-flags bit 7: "this is not the final directory record for this + /// file". A file with it set continues in the NEXT record — its bytes are + /// several extents, not one range. + /// + /// This is not theoretical. `2C_TAREA.2CH` on the owner's Rheingold Disc 1 + /// is recorded as FOUR records: three of 1 073 739 776 bytes and one of + /// 128 579 584. A reader that takes the first and calls it the file reads + /// 1 GiB of a 3.35 GB area and reports success. + pub multi_extent: bool, +} + +impl Entry { + pub fn offset(&self) -> u64 { + self.lsn as u64 * SECTOR + } +} + +pub struct IsoImage { + file: File, + path: PathBuf, + root: Entry, +} + +impl IsoImage { + pub fn open(path: &Path) -> Result<Self, IsoError> { + let mut file = + File::open(path).map_err(|e| IsoError::Open(path.to_path_buf(), e))?; + let pvd = read_sector(&mut file, PVD_LSN)?; + + // Descriptor type 1 = primary, then the "CD001" standard identifier. + if &pvd[1..6] != b"CD001" { + return Err(IsoError::NotIso); + } + let block = u16::from_le_bytes([pvd[128], pvd[129]]); + if block as u64 != SECTOR { + return Err(IsoError::BlockSize(block)); + } + + // The root directory RECORD is embedded in the PVD at offset 156, in + // the same 34-byte shape as any other directory record. + let root = parse_record(&pvd[156..190], 0) + .ok_or(IsoError::Malformed)? + .0; + + Ok(Self { + file, + path: path.to_path_buf(), + root: Entry { + name: "/".to_string(), + ..root + }, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn root(&self) -> &Entry { + &self.root + } + + /// Entries of a directory, excluding the `.` and `..` records. + pub fn read_dir(&mut self, dir: &Entry) -> Result<Vec<Entry>, IsoError> { + if !dir.is_dir { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + let sectors = dir.size.div_ceil(SECTOR as u32) as u64; + // A directory that claims to be enormous is corrupt, not interesting. + if sectors > 4096 { + return Err(IsoError::Malformed); + } + for s in 0..sectors { + let buf = read_sector(&mut self.file, dir.lsn as u64 + s)?; + let mut at = 0usize; + while at < buf.len() { + let len = buf[at] as usize; + // A zero length means "no more records in THIS sector" — the + // next one continues the directory. It is not the end. + if len == 0 { + break; + } + if at + len > buf.len() { + return Err(IsoError::Malformed); + } + if let Some((entry, special)) = parse_record(&buf[at..at + len], len) { + if !special { + out.push(entry); + } + } + at += len; + } + } + Ok(out) + } + + /// Resolve an absolute path like `/2C_AUDIO/TRACK001.2CH`. + /// + /// Matching ignores the `;1` version suffix and is case-insensitive: ISO + /// 9660 level 1 upper-cases everything, and a caller should not have to + /// know that. + pub fn find(&mut self, path: &str) -> Result<Option<Entry>, IsoError> { + let mut current = self.root.clone(); + for part in path.split('/').filter(|p| !p.is_empty()) { + let entries = self.read_dir(¤t)?; + match entries + .into_iter() + .find(|e| e.name.eq_ignore_ascii_case(part)) + { + // A multi-extent file is REFUSED here rather than returned + // truncated. Returning the first extent would hand back a + // plausible `Entry` whose bytes are a quarter of the file, and + // nothing downstream could tell. + Some(e) if e.multi_extent => { + return Err(IsoError::MultiExtent(e.name)); + } + Some(e) => current = e, + None => return Ok(None), + } + } + Ok(Some(current)) + } + + /// Read a whole file's extent into memory. + /// + /// `cap` bounds the allocation: the size comes from the IMAGE, which is + /// untrusted input, and a corrupt record must not be able to ask for a + /// gigabyte. Callers that want a huge file should stream it instead. + pub fn read_file(&mut self, entry: &Entry, cap: usize) -> Result<Vec<u8>, IsoError> { + if entry.is_dir { + return Ok(Vec::new()); + } + let want = entry.size as usize; + if want > cap { + return Err(IsoError::Malformed); + } + let mut out = vec![0u8; want]; + self.file + .seek(SeekFrom::Start(entry.offset())) + .and_then(|_| self.file.read_exact(&mut out)) + .map_err(|e| IsoError::Read { + lsn: entry.lsn as u64, + source: e, + })?; + Ok(out) + } + + /// Read `count` sectors starting at an absolute LSN — the SACD reader's + /// way in, since it works in sectors rather than files. + pub fn read_sectors(&mut self, lsn: u64, count: usize) -> Result<Vec<u8>, IsoError> { + let mut out = vec![0u8; count * SECTOR as usize]; + self.file + .seek(SeekFrom::Start(lsn * SECTOR)) + .and_then(|_| self.file.read_exact(&mut out)) + .map_err(|e| IsoError::Read { lsn, source: e })?; + Ok(out) + } +} + +fn read_sector(file: &mut File, lsn: u64) -> Result<[u8; SECTOR as usize], IsoError> { + let mut buf = [0u8; SECTOR as usize]; + file.seek(SeekFrom::Start(lsn * SECTOR)) + .and_then(|_| file.read_exact(&mut buf)) + .map_err(|e| IsoError::Read { lsn, source: e })?; + Ok(buf) +} + +/// Parse one directory record. Returns the entry and whether it is one of the +/// `.` / `..` self-records, which callers skip. +/// +/// Layout (ECMA-119 §9.1), the fields this reader needs: +/// 0 length of record +/// 2 extent LBA, both-endian (little half at 2, big half at 6) +/// 10 data length, both-endian +/// 25 file flags +/// 32 file identifier length +/// 33 file identifier +fn parse_record(rec: &[u8], _len: usize) -> Option<(Entry, bool)> { + if rec.len() < 34 { + return None; + } + let lsn = u32::from_le_bytes([rec[2], rec[3], rec[4], rec[5]]); + let size = u32::from_le_bytes([rec[10], rec[11], rec[12], rec[13]]); + let flags = rec[25]; + let id_len = rec[32] as usize; + if 33 + id_len > rec.len() { + return None; + } + let raw = &rec[33..33 + id_len]; + + // The two self-records are a single 0x00 / 0x01 byte, not text. + let special = id_len == 1 && (raw[0] == 0 || raw[0] == 1); + let name = if special { + if raw[0] == 0 { ".".into() } else { "..".into() } + } else { + let s = String::from_utf8_lossy(raw).to_string(); + // ";1" is the version suffix ISO 9660 appends to every FILE name. + match s.split_once(';') { + Some((base, _)) => base.to_string(), + None => s, + } + }; + + Some(( + Entry { + name, + lsn, + size, + is_dir: flags & 0x02 != 0, + multi_extent: flags & 0x80 != 0, + }, + special, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(name: &str, lsn: u32, size: u32, flags: u8) -> Vec<u8> { + let id = name.as_bytes(); + let mut r = vec![0u8; 33 + id.len()]; + r[0] = r.len() as u8; + r[2..6].copy_from_slice(&lsn.to_le_bytes()); + r[6..10].copy_from_slice(&lsn.to_be_bytes()); + r[10..14].copy_from_slice(&size.to_le_bytes()); + r[14..18].copy_from_slice(&size.to_be_bytes()); + r[25] = flags; + r[32] = id.len() as u8; + r[33..].copy_from_slice(id); + r + } + + #[test] + fn a_file_record_decodes_to_its_extent() { + // TRACK001.2CH on the owner's Disc 2: LSN 1253, 167933952 bytes. + let r = record("TRACK001.2CH;1", 1253, 167_933_952, 0); + let (e, special) = parse_record(&r, r.len()).expect("a well-formed record must parse"); + assert!(!special); + assert_eq!(e.name, "TRACK001.2CH"); + assert_eq!(e.lsn, 1253); + assert_eq!(e.size, 167_933_952); + assert!(!e.is_dir); + // The byte offset a reader seeks to. + assert_eq!(e.offset(), 1253 * 2048); + } + + #[test] + fn the_version_suffix_is_stripped_but_a_dot_in_the_name_survives() { + let r = record("2C_AREA1.TOC;1", 544, 18_432, 0); + let (e, _) = parse_record(&r, r.len()).unwrap(); + assert_eq!(e.name, "2C_AREA1.TOC"); + } + + #[test] + fn the_self_records_are_reported_as_special() { + for byte in [0u8, 1u8] { + let mut r = vec![0u8; 34]; + r[0] = 34; + r[25] = 0x02; + r[32] = 1; + r[33] = byte; + let (_, special) = parse_record(&r, 34).unwrap(); + assert!(special, "the . / .. records must never be listed"); + } + } + + #[test] + fn a_directory_is_told_apart_by_its_flag() { + let d = record("2C_AUDIO", 1_431_814, 2048, 0x02); + let (e, _) = parse_record(&d, d.len()).unwrap(); + assert!(e.is_dir); + } + + #[test] + fn a_multi_extent_file_is_flagged_and_not_passed_off_as_whole() { + // The real shape of 2C_TAREA.2CH on the owner's Disc 1: the first + // three records carry the continuation bit, the last does not. + let cont = record("2C_TAREA.2CH;1", 553, 1_073_739_776, 0x80); + let (e, _) = parse_record(&cont, cont.len()).unwrap(); + assert!(e.multi_extent, "the continuation bit must be visible"); + + let last = record("2C_TAREA.2CH;1", 1_573_414, 128_579_584, 0x00); + let (e, _) = parse_record(&last, last.len()).unwrap(); + assert!(!e.multi_extent); + + // And an ordinary track file is single-extent, which is why the + // feature works at all: TRACK001.2CH, Disc 1. + let track = record("TRACK001.2CH;1", 1253, 197_914_624, 0x00); + let (e, _) = parse_record(&track, track.len()).unwrap(); + assert!(!e.multi_extent); + } + + #[test] + fn a_truncated_record_is_rejected_rather_than_read_past() { + // The size field of a record is untrusted input from the image. + assert!(parse_record(&[0u8; 10], 10).is_none()); + let mut r = record("X", 1, 1, 0); + r[32] = 200; // claims a 200-byte name it does not have + assert!(parse_record(&r, r.len()).is_none()); + } +} diff --git a/crates/qbz-disc/src/lib.rs b/crates/qbz-disc/src/lib.rs new file mode 100644 index 000000000..e8fe07a2b --- /dev/null +++ b/crates/qbz-disc/src/lib.rs @@ -0,0 +1,298 @@ +//! Optical media QBZ can PLAY without indexing any of it. +//! +//! Frontend-agnostic (ADR-006): no UI, no player, no Qt. The crate hands back +//! a track list and raw bytes; who plays them, and how, is the frontend's +//! problem. +//! +//! Deliberately does NOT depend on `qbz-dsd`. The dependency runs the other +//! way — `qbz-dsd` adapts this crate's SACD reader to its `DsdDemuxer` trait — +//! because the reverse closes a cycle. +//! +//! Phase 1 covers CD-DA from a real drive. What is not supported is DETECTED +//! and REPORTED, never silently degraded: a data track in a mixed-mode disc is +//! skipped by name, an unreadable sector is an error rather than a hole full +//! of zeros. Silence that pretends to be audio is the one outcome a +//! bit-perfect player must never produce. + +/// What a table of contents IS — shared by every platform's reader. +pub mod toc; + +/// Reading a CD, whichever way this platform offers. Always present: the +/// answer on a platform with no reader is "no drive", never a build failure. +pub mod cdda; + +#[cfg(target_os = "linux")] +mod cdda_linux; +#[cfg(target_os = "macos")] +mod cdda_macos; + +/// Disc IMAGES are portable — a .iso is a file, and reading one needs no +/// device and no privileges. Only the live-drive path is Linux-gated. +pub mod iso9660; + +/// Naming a disc that carries no names. +pub mod discid; + +/// SACD disc images (Scarlet Book). +pub mod sacd; + +/// What a disc is called, remembered after it has been ejected. +pub mod store; + +pub use cdda::{drive_model, list_devices, read_audio, read_toc}; +pub use toc::{CdError, Toc, TocTrack}; + +/// Bytes per CD-DA sector (a "frame" in the kernel's vocabulary): 588 stereo +/// 16-bit sample pairs. +pub const CDDA_SECTOR_BYTES: usize = 2352; +/// Sectors per second of CD-DA audio. +pub const CDDA_SECTORS_PER_SEC: u32 = 75; +/// CD-DA is 44 100 Hz, 16-bit, stereo. These are not configurable and any code +/// that treats them as such has misunderstood the format. +pub const CDDA_SAMPLE_RATE: u32 = 44_100; +pub const CDDA_BITS: u16 = 16; +pub const CDDA_CHANNELS: u16 = 2; + +/// Seconds of audio in `sectors`, rounded down. +pub fn sectors_to_secs(sectors: u32) -> u64 { + (sectors / CDDA_SECTORS_PER_SEC) as u64 +} + +/// A 44-byte RIFF/WAVE header for 16-BIT PCM. +/// +/// `qbz-dsd` has a `wav_header` already and it is hardcoded to 24-bit — the +/// depth its converter emits. Reusing it for a CD would mean either lying in +/// the header or promoting 16-bit samples to 24, and while that shift is +/// arithmetically lossless it makes the DAC open at 24 bits for a 16-bit +/// source. That is exactly the silent conversion a bit-perfect path must not +/// contain, so a CD gets its own header. +pub fn wav_header_16(total_frames: u64, channels: u16, sample_rate: u32) -> Vec<u8> { + const HEADER_LEN: usize = 44; + let bytes_per_sample = 2u64; + let data_len = (total_frames * channels as u64 * bytes_per_sample) as u32; + let byte_rate = sample_rate * channels as u32 * bytes_per_sample as u32; + let block_align = channels * bytes_per_sample as u16; + let mut h = Vec::with_capacity(HEADER_LEN); + h.extend_from_slice(b"RIFF"); + h.extend_from_slice(&(36 + data_len).to_le_bytes()); + h.extend_from_slice(b"WAVE"); + h.extend_from_slice(b"fmt "); + h.extend_from_slice(&16u32.to_le_bytes()); + h.extend_from_slice(&1u16.to_le_bytes()); // PCM + h.extend_from_slice(&channels.to_le_bytes()); + h.extend_from_slice(&sample_rate.to_le_bytes()); + h.extend_from_slice(&byte_rate.to_le_bytes()); + h.extend_from_slice(&block_align.to_le_bytes()); + h.extend_from_slice(&CDDA_BITS.to_le_bytes()); + h.extend_from_slice(b"data"); + h.extend_from_slice(&data_len.to_le_bytes()); + debug_assert_eq!(h.len(), HEADER_LEN); + h +} + +/// Total size of the WAV a track becomes — what the streaming player needs as +/// its `content_length`. +pub fn wav_total_size_16(total_frames: u64, channels: u16) -> u64 { + 44 + total_frames * channels as u64 * 2 +} + +/// A CD-DA track, encoded as ONE string so it can ride in `LocalTrack.file_path` +/// and through every store and queue that carries a path today. +/// +/// `cdda:/dev/sr0#0+46577` — device, start sector, sector count. +/// +/// It is a real type with a single `parse`/`to_path_string` pair rather than +/// ad-hoc `starts_with` tests scattered around, because the scattered version +/// is a known failure mode in this codebase: a second list of prefixes always +/// ends up missing one site, and the fold is silent (see the six "a place that +/// enumerates sources by hand" bugs of 2026-08-20). One parser, one shape. +/// +/// It is NOT a filesystem path and nothing may stat, open or canonicalise it. +/// Callers test with [`CdRef::is_cd_path`] BEFORE reaching for the filesystem. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CdRef { + pub device: std::path::PathBuf, + pub start_lsn: u32, + pub sectors: u32, +} + +impl CdRef { + pub const SCHEME: &'static str = "cdda:"; + + /// Cheap test callers use to route BEFORE touching the filesystem. + pub fn is_cd_path(s: &str) -> bool { + s.starts_with(Self::SCHEME) + } + + pub fn to_path_string(&self) -> String { + format!( + "{}{}#{}+{}", + Self::SCHEME, + self.device.display(), + self.start_lsn, + self.sectors + ) + } + + pub fn parse(s: &str) -> Option<Self> { + let rest = s.strip_prefix(Self::SCHEME)?; + let (dev, range) = rest.rsplit_once('#')?; + let (lsn, sectors) = range.split_once('+')?; + if dev.is_empty() { + return None; + } + Some(Self { + device: std::path::PathBuf::from(dev), + start_lsn: lsn.parse().ok()?, + sectors: sectors.parse().ok()?, + }) + } +} + +/// A SACD track inside an image, as ONE string that rides in a +/// `LocalTrack.file_path` — the same trick `CdRef` plays, for the same reason. +/// +/// `sacd:/path/to/disc.iso#7` +/// +/// The track NUMBER, not its sector range: the area TOC is the authority on +/// extents and re-reading it costs one 18 KB read, so encoding the geometry +/// here would only create a second copy that can go stale. The image path may +/// contain `#` (a filename is user data), so the split is from the RIGHT. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SacdRef { + pub image: std::path::PathBuf, + pub track: u8, +} + +impl SacdRef { + pub const SCHEME: &'static str = "sacd:"; + + /// Cheap test callers use to route BEFORE touching the filesystem — an + /// image path IS a real file, but `open_dsd` must not try to sniff DSF + /// magic at its first byte. + pub fn is_sacd_path(s: &str) -> bool { + s.starts_with(Self::SCHEME) + } + + pub fn to_path_string(&self) -> String { + format!("{}{}#{}", Self::SCHEME, self.image.display(), self.track) + } + + pub fn parse(s: &str) -> Option<Self> { + let rest = s.strip_prefix(Self::SCHEME)?; + let (image, track) = rest.rsplit_once('#')?; + if image.is_empty() { + return None; + } + Some(Self { + image: std::path::PathBuf::from(image), + track: track.parse().ok()?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_header_describes_a_cd_and_not_a_dsd_conversion() { + // 10 seconds of CD-DA. + let frames = 44_100u64 * 10; + let h = wav_header_16(frames, 2, 44_100); + assert_eq!(&h[0..4], b"RIFF"); + assert_eq!(&h[8..12], b"WAVE"); + // bits per sample at offset 34 — 16, never 24. + assert_eq!(u16::from_le_bytes([h[34], h[35]]), 16); + assert_eq!(u32::from_le_bytes([h[24], h[25], h[26], h[27]]), 44_100); + // byte rate = 44100 * 2ch * 2 bytes + assert_eq!(u32::from_le_bytes([h[28], h[29], h[30], h[31]]), 176_400); + // and the sizes agree with the standalone helper + assert_eq!( + wav_total_size_16(frames, 2), + 44 + u32::from_le_bytes([h[40], h[41], h[42], h[43]]) as u64 + ); + } + + #[test] + fn a_measured_track_length_converts_to_its_real_duration() { + // Tool — Fear Inoculum, measured off the owner's disc 2026-08-20: + // track 1 spans 46577 sectors and track 7 spans 70833. + assert_eq!(sectors_to_secs(46_577), 621); // 10:21 + assert_eq!(sectors_to_secs(70_833), 944); // 15:44 + } + + #[test] + fn a_cd_reference_round_trips() { + let r = CdRef { + device: std::path::PathBuf::from("/dev/sr0"), + start_lsn: 285_735, + sectors: 70_833, + }; + let s = r.to_path_string(); + assert_eq!(s, "cdda:/dev/sr0#285735+70833"); + assert_eq!(CdRef::parse(&s), Some(r)); + assert!(CdRef::is_cd_path(&s)); + } + + #[test] + fn a_real_path_is_never_mistaken_for_a_disc() { + // The whole point of the scheme: no filesystem path can collide with + // it, so a routing test cannot send a real file down the disc path. + for p in [ + "/home/u/Music/a.flac", + "/home/u/cdda:weird/x.dsf", + "plex:1234", + "", + ] { + assert!(!CdRef::is_cd_path(p), "{p} was taken for a disc"); + assert_eq!(CdRef::parse(p), None); + } + } + + #[test] + fn a_malformed_reference_is_rejected_rather_than_guessed() { + for bad in [ + "cdda:", + "cdda:/dev/sr0", + "cdda:/dev/sr0#", + "cdda:/dev/sr0#12", + "cdda:/dev/sr0#12+", + "cdda:/dev/sr0#a+b", + "cdda:#0+1", + ] { + assert_eq!(CdRef::parse(bad), None, "{bad} parsed"); + } + } + + #[test] + fn a_sacd_reference_round_trips_even_with_a_hash_in_the_name() { + // A filename is user data and may contain '#', which is why the split + // is from the right rather than the left. + let r = SacdRef { + image: std::path::PathBuf::from("/m/Wagner #1/disc.iso"), + track: 7, + }; + let s = r.to_path_string(); + assert_eq!(s, "sacd:/m/Wagner #1/disc.iso#7"); + assert_eq!(SacdRef::parse(&s), Some(r)); + assert!(SacdRef::is_sacd_path(&s)); + } + + #[test] + fn the_two_disc_schemes_never_claim_each_other() { + let cd = "cdda:/dev/sr0#0+46577"; + let sacd = "sacd:/m/d.iso#3"; + assert!(CdRef::is_cd_path(cd) && !SacdRef::is_sacd_path(cd)); + assert!(SacdRef::is_sacd_path(sacd) && !CdRef::is_cd_path(sacd)); + assert_eq!(CdRef::parse(sacd), None); + assert_eq!(SacdRef::parse(cd), None); + } + + #[test] + fn a_malformed_sacd_reference_is_rejected() { + for bad in ["sacd:", "sacd:/x.iso", "sacd:#3", "sacd:/x.iso#", "sacd:/x.iso#abc"] { + assert_eq!(SacdRef::parse(bad), None, "{bad} parsed"); + } + } +} diff --git a/crates/qbz-disc/src/sacd.rs b/crates/qbz-disc/src/sacd.rs new file mode 100644 index 000000000..33c749b6e --- /dev/null +++ b/crates/qbz-disc/src/sacd.rs @@ -0,0 +1,1844 @@ +//! Scarlet Book: the stereo audio area of a SACD image. +//! +//! Every structure and every constant below was MEASURED on the owner's two +//! Rheingold discs before a line of it was written, by a census rather than a +//! sample — 3 066 893 sectors, both discs, zero exceptions. That matters +//! because the previous attempt at this file started from a single sector and +//! concluded the per-sector header was a fixed 8 bytes. It is not, and a fixed +//! skip would have leaked packet descriptors into the DSD stream: audible +//! noise on a path whose entire promise is bit-perfection. +//! +//! WHAT IS SUPPORTED: DSD64 stereo areas, either flat or DST-compressed, in +//! raw Scarlet Book dumps and ISO/UDF hybrid images. Every pointer and audio +//! extent is bounded by the image; nothing is inferred from an ISO 9660 layer. +//! Images may store 2048-byte logical sectors directly or retain the 12-byte +//! header and 4-byte trailer of each 2064-byte physical sector. + +use std::fs::{File, Metadata}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +use crate::iso9660::{IsoError, SECTOR}; + +const MASTER_TOC_COPIES: [u64; 3] = [510, 520, 530]; +const MAX_AREA_TOC_SECTORS: u16 = 96; + +/// Sectors per second of SACD playing time. The area TOC states it as a +/// max_byte_rate of 716 800 B/s, and 716800 / 2048 = 350. It is the SACD's +/// answer to the CD's 75, and it is what ties a track's start LSN to its +/// start time — an identity that holds for 44 of 44 tracks on both discs: +/// start_lsn[i] == floor(area_start + start_time[i] * 350) +pub const SECTORS_PER_SEC: u32 = 350; + +/// One uncompressed DSD64 STEREO frame: 2 ch x 2 822 400 bit/s / 75 frames/s +/// / 8 = 9408 bytes. Used as the proof that an area carries no DST — the sum +/// of its audio-packet lengths divides by this EXACTLY (350 495 frames on +/// Disc 1, 306 696 on Disc 2). Compressed frames could not give an integer. +pub const DSD64_STEREO_FRAME: u32 = 9408; + +/// Packet data types seen across both discs: {2, 3, 7}. Only 2 is audio. +pub const DATA_TYPE_AUDIO: u16 = 2; + +#[derive(Debug, thiserror::Error)] +pub enum SacdError { + #[error(transparent)] + Iso(#[from] IsoError), + #[error("this image has no Scarlet Book stereo audio area")] + NoStereoArea, + #[error("this image has no Scarlet Book Master TOC")] + MissingMasterToc, + #[error("the area TOC is not a Scarlet Book stereo TOC (expected TWOCHTOC)")] + NotAnArea, + #[error("{0} is missing from the area TOC")] + MissingBlock(&'static str), + #[error("the legacy flat-DSD reader cannot expose compressed DST frames")] + Dst, + #[error("unsupported channel count: {0}")] + Channels(u8), + #[error("unsupported SACD frame format: {0}")] + FrameFormat(u8), + #[error("unsupported SACD sample-frequency code: {0}")] + SampleFrequency(u8), + #[error("the area declares {0} tracks, which cannot be right")] + TrackCount(u8), + #[error("the Scarlet Book TOC is malformed: {0}")] + MalformedToc(&'static str), + #[error("the valid Master TOC copies disagree about the disc geometry")] + ConflictingMasterTocs, + #[error("the valid copies of an area TOC disagree about its geometry")] + ConflictingAreaTocs, + #[error("sector {lsn} is malformed: {why}")] + BadSector { lsn: u64, why: &'static str }, + #[error("track {track} frame at sector {lsn} is malformed: {why}")] + BadAudioFrame { + track: u8, + lsn: u64, + why: &'static str, + }, + #[error("track {track} has no frame start within 32 sectors from {lsn}")] + MissingFrameStart { track: u8, lsn: u64 }, + #[error("{0} is not a regular file")] + NotRegularFile(PathBuf), + #[error("SACD image length {0} contains a partial sector in the detected layout")] + InvalidImageLength(u64), + #[error("SACD image has Master TOC signatures in conflicting sector layouts")] + AmbiguousSectorLayout, + #[error("SACD image changed while it was being read: {0}")] + ImageChangedDuringRead(PathBuf), +} + +/// How audio frames in the selected stereo area are represented. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SacdEncoding { + Dst, + Dsd3In14, + Dsd3In16, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SacdTrack { + /// 1-based, as printed on the sleeve. + pub number: u8, + pub start_lsn: u32, + /// Sectors to READ. Deliberately one more than the exclusive extent for + /// every track but the last — that is how the disc records it, and the + /// ISO 9660 directory mirrors it byte for byte, so the one-sector overlap + /// a naive reading sees is intentional rather than a bug to correct. + pub length_lsn: u32, + pub start_secs: f64, + pub duration_secs: f64, + /// Exact 75 Hz Scarlet Book timecode, without a float round-trip. + pub start_frame: u32, + pub duration_frames: u32, + pub encoding: SacdEncoding, + pub title: Option<String>, +} + +#[derive(Debug, Clone)] +pub struct SacdArea { + pub track_start_lsn: u32, + pub track_end_lsn: u32, + pub channels: u8, + pub encoding: SacdEncoding, + pub total_playtime_secs: f64, + pub tracks: Vec<SacdTrack>, + /// Album text out of the Master TOC, when it carries any. + pub album: Option<String>, + /// Album artist, same source. + pub artist: Option<String>, +} + +impl SacdArea { + /// A stable identity for this disc, from its GEOMETRY alone. + /// + /// The twin of [`crate::cdda::Toc::fingerprint`], and the same FNV-1a for + /// the same reason: no dependency, and it only has to tell two records + /// apart. It exists so `crate::store` can remember a correction for a SACD + /// too — an image names itself, but "names itself" and "names itself + /// CORRECTLY" are different claims, and the second one is the user's to + /// make. + /// + /// Deliberately NOT the file path: the same disc ripped to two folders is + /// one record, and a `.iso` that gets moved must not lose its correction. + /// Deliberately not the titles either — a fingerprint that changes when + /// the user edits a title could never find the edit again. + pub fn fingerprint(&self) -> String { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut eat = |v: u32| { + for b in v.to_le_bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x1000_0000_01b3); + } + }; + for t in &self.tracks { + eat(t.start_lsn); + eat(t.length_lsn); + eat(t.number as u32); + } + eat(self.track_start_lsn); + eat(self.track_end_lsn); + eat(self.channels as u32); + format!("sacd-{h:016x}") + } +} + +fn be32(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) +} + +fn be16(b: &[u8], at: usize) -> u16 { + u16::from_be_bytes([b[at], b[at + 1]]) +} + +fn time_frames_at(b: &[u8], at: usize) -> u32 { + (b[at] as u32 * 60 + b[at + 1] as u32) * 75 + b[at + 2] as u32 +} + +/// A bounded reader over the 2048-byte logical sectors shared by authored +/// SACD/ISO hybrids and raw Scarlet Book dumps. No filesystem layer is +/// required: the Master TOC and every audio extent already use absolute LSNs. +struct SectorImage { + file: File, + path: PathBuf, + bytes: u64, + layout: SectorLayout, + master_signature_found: bool, + stamp: ImageStamp, + sectors_since_check: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SectorLayout { + Logical2048, + Physical2064, +} + +impl SectorLayout { + fn stride(self) -> u64 { + match self { + Self::Logical2048 => SECTOR, + Self::Physical2064 => 2064, + } + } + + fn payload_offset(self) -> u64 { + match self { + Self::Logical2048 => 0, + Self::Physical2064 => 12, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ImageStamp { + bytes: u64, + modified: Option<std::time::SystemTime>, + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, + #[cfg(unix)] + change_seconds: i64, + #[cfg(unix)] + change_nanoseconds: i64, +} + +impl ImageStamp { + fn from_metadata(metadata: &Metadata) -> Self { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + + Self { + bytes: metadata.len(), + modified: metadata.modified().ok(), + #[cfg(unix)] + device: metadata.dev(), + #[cfg(unix)] + inode: metadata.ino(), + #[cfg(unix)] + change_seconds: metadata.ctime(), + #[cfg(unix)] + change_nanoseconds: metadata.ctime_nsec(), + } + } +} + +impl SectorImage { + fn open(path: &Path) -> Result<Self, SacdError> { + let file = File::open(path) + .map_err(|error| SacdError::Iso(IsoError::Open(path.to_path_buf(), error)))?; + let metadata = file + .metadata() + .map_err(|source| SacdError::Iso(IsoError::Read { lsn: 0, source }))?; + if !metadata.is_file() { + return Err(SacdError::NotRegularFile(path.to_path_buf())); + } + let bytes = metadata.len(); + let mut image = Self { + file, + path: path.to_path_buf(), + bytes, + layout: SectorLayout::Logical2048, + master_signature_found: false, + stamp: ImageStamp::from_metadata(&metadata), + sectors_since_check: 0, + }; + let layout = image.detect_layout()?; + image.master_signature_found = layout.is_some(); + image.layout = layout.unwrap_or(SectorLayout::Logical2048); + image.verify_unchanged()?; + Ok(image) + } + + /// File size alone cannot distinguish layouts: a 2064-byte dump with a + /// multiple of 128 sectors is also divisible by 2048. Probe all three + /// Master positions, including backups and signed but truncated images. + /// Full geometry validation remains the responsibility of the TOC parser. + fn detect_layout(&mut self) -> Result<Option<SectorLayout>, SacdError> { + let mut selected = None; + for layout in [SectorLayout::Logical2048, SectorLayout::Physical2064] { + for lsn in MASTER_TOC_COPIES { + let offset = lsn * layout.stride() + layout.payload_offset(); + if offset + 8 > self.bytes { + continue; + } + let mut signature = [0u8; 8]; + self.file + .seek(SeekFrom::Start(offset)) + .and_then(|_| self.file.read_exact(&mut signature)) + .map_err(|source| SacdError::Iso(IsoError::Read { lsn, source }))?; + if &signature == b"SACDMTOC" { + if selected.is_some_and(|previous| previous != layout) { + return Err(SacdError::AmbiguousSectorLayout); + } + selected = Some(layout); + break; + } + } + } + Ok(selected) + } + + fn validate_length(&self) -> Result<(), SacdError> { + if self.bytes % self.layout.stride() != 0 { + return Err(SacdError::InvalidImageLength(self.bytes)); + } + Ok(()) + } + + fn sectors(&self) -> u64 { + self.bytes / self.layout.stride() + } + + fn has_complete_sector(&self, lsn: u64) -> bool { + lsn < self.sectors() + } + + fn has_sectors(&self, lsn: u64, count: u64) -> bool { + lsn.checked_add(count) + .is_some_and(|end| end <= self.sectors()) + } + + fn read_sectors(&mut self, lsn: u64, count: usize) -> Result<Vec<u8>, SacdError> { + let count_u64 = + u64::try_from(count).map_err(|_| SacdError::MalformedToc("sector count overflows"))?; + let end_lsn = lsn + .checked_add(count_u64) + .ok_or(SacdError::MalformedToc("sector range overflows"))?; + if end_lsn > self.sectors() { + return Err(SacdError::Iso(IsoError::Read { + lsn, + source: std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + format!("read past end of {}", self.path.display()), + ), + })); + } + let stride = self.layout.stride() as usize; + let bytes = count + .checked_mul(stride) + .ok_or(SacdError::MalformedToc("byte count overflows"))?; + let mut out = vec![0u8; bytes]; + self.file + .seek(SeekFrom::Start(lsn * self.layout.stride())) + .and_then(|_| self.file.read_exact(&mut out)) + .map_err(|source| SacdError::Iso(IsoError::Read { lsn, source }))?; + if self.layout == SectorLayout::Physical2064 { + // Read contiguous physical sectors once, then compact their payloads + // in place. Headers/trailers must never reach TOC or audio parsing. + for index in 0..count { + let from = index * stride + self.layout.payload_offset() as usize; + out.copy_within(from..from + SECTOR as usize, index * SECTOR as usize); + } + out.truncate(count * SECTOR as usize); + } + self.sectors_since_check = self.sectors_since_check.saturating_add(count_u64); + // A one-second cadence avoids a metadata syscall for every 1/75 s + // audio frame while still detecting in-place writes and path swaps + // during long playback. TOC callers also verify explicitly at end. + if self.sectors_since_check >= u64::from(SECTORS_PER_SEC) { + self.verify_unchanged()?; + self.sectors_since_check = 0; + } + Ok(out) + } + + fn verify_unchanged(&self) -> Result<(), SacdError> { + let open_stamp = self + .file + .metadata() + .ok() + .map(|metadata| ImageStamp::from_metadata(&metadata)); + let path_stamp = std::fs::metadata(&self.path) + .ok() + .map(|metadata| ImageStamp::from_metadata(&metadata)); + if open_stamp.as_ref() != Some(&self.stamp) || path_stamp.as_ref() != Some(&self.stamp) { + return Err(SacdError::ImageChangedDuringRead(self.path.clone())); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AreaSlot { + primary: u32, + backup: u32, + size: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MasterToc { + lsn: u64, + version: (u8, u8), + areas: [AreaSlot; 2], + text_area_count: u8, + text_charset: u8, +} + +fn parse_master_toc(sector: &[u8], lsn: u64, image_sectors: u64) -> Result<MasterToc, SacdError> { + if sector.len() < SECTOR as usize || §or[..8] != b"SACDMTOC" { + return Err(SacdError::MalformedToc("missing SACDMTOC signature")); + } + let version = (sector[8], sector[9]); + if version.0 != 1 || version.1 > 20 { + return Err(SacdError::MalformedToc("unsupported Master TOC version")); + } + let areas = [ + AreaSlot { + primary: be32(sector, 0x40), + backup: be32(sector, 0x44), + size: be16(sector, 0x54), + }, + AreaSlot { + primary: be32(sector, 0x48), + backup: be32(sector, 0x4c), + size: be16(sector, 0x56), + }, + ]; + for slot in areas { + if slot.primary == 0 && slot.backup == 0 && slot.size == 0 { + continue; + } + if slot.size == 0 || slot.size > MAX_AREA_TOC_SECTORS { + return Err(SacdError::MalformedToc("invalid area TOC size")); + } + if slot.primary == 0 && slot.backup == 0 { + return Err(SacdError::MalformedToc("area TOC has no readable copy")); + } + for start in [slot.primary, slot.backup] + .into_iter() + .filter(|start| *start != 0) + { + if u64::from(start) + .checked_add(u64::from(slot.size)) + .is_none_or(|end| end > image_sectors) + { + return Err(SacdError::MalformedToc("area TOC points outside the image")); + } + } + } + if areas.iter().all(|slot| slot.size == 0) { + return Err(SacdError::NoStereoArea); + } + Ok(MasterToc { + lsn, + version, + areas, + text_area_count: sector[0x80], + // First of the eight locale entries at 0x88: language[2], charset, + // reserved. QBZ deliberately selects the first text area, as the + // reference reader does. + text_charset: sector[0x8a], + }) +} + +fn select_master_toc(image: &mut SectorImage) -> Result<Option<MasterToc>, SacdError> { + let mut valid = Vec::new(); + let mut signed_error = None; + for lsn in MASTER_TOC_COPIES { + if !image.has_complete_sector(lsn) { + continue; + } + let first = image.read_sectors(lsn, 1)?; + if &first[..8] != b"SACDMTOC" { + continue; + } + if !image.has_sectors(lsn, 10) { + signed_error.get_or_insert(SacdError::MalformedToc("Master TOC copy is truncated")); + continue; + } + let sector = image.read_sectors(lsn, 10)?; + match parse_master_toc(§or, lsn, image.sectors()) { + Ok(master) => valid.push(master), + Err(error) => { + signed_error.get_or_insert(error); + } + }; + } + let Some(selected) = valid.first().copied() else { + return match signed_error { + Some(error) => Err(error), + None => Ok(None), + }; + }; + if valid + .iter() + .skip(1) + .any(|copy| copy.version != selected.version || copy.areas != selected.areas) + { + return Err(SacdError::ConflictingMasterTocs); + } + Ok(Some(selected)) +} + +/// Result of the cheap scan-time signature and Master TOC validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SacdSniff { + NotSacd, + Sacd, +} + +/// Signature sniff for a folder scan. Scarlet Book identity is the validated +/// Master TOC, not the optional ISO 9660 compatibility layer. +pub fn sniff_sacd_image(path: &Path) -> Result<SacdSniff, SacdError> { + let mut image = SectorImage::open(path)?; + if !image.master_signature_found { + image.verify_unchanged()?; + return Ok(SacdSniff::NotSacd); + } + // A signed image with a truncated tail is an error, not an ordinary ISO + // to ignore/prune. This also covers a signature in a partial Master sector. + image.validate_length()?; + let result = match select_master_toc(&mut image)? { + Some(_) => SacdSniff::Sacd, + None => SacdSniff::NotSacd, + }; + image.verify_unchanged()?; + Ok(result) +} + +/// Compatibility convenience for callers that only need a boolean. +pub fn is_sacd_image(path: &Path) -> bool { + matches!(sniff_sacd_image(path), Ok(SacdSniff::Sacd)) +} + +/// Read the stereo area's table of contents out of a disc image. +pub fn read_area(path: &Path) -> Result<SacdArea, SacdError> { + let mut image = SectorImage::open(path)?; + image.validate_length()?; + let master = select_master_toc(&mut image)?.ok_or(SacdError::MissingMasterToc)?; + + let mut stereo = None; + let mut first_area_error = None; + for slot in master.areas.into_iter().filter(|slot| slot.size != 0) { + match read_area_slot(&mut image, slot) { + Ok(toc) if &toc[..8] == b"TWOCHTOC" => { + if stereo.replace(toc).is_some() { + return Err(SacdError::MalformedToc( + "disc declares more than one stereo area", + )); + } + } + Ok(_) => {} + Err(error) => { + first_area_error.get_or_insert(error); + } + } + } + let toc = match stereo { + Some(toc) => toc, + None => return Err(first_area_error.unwrap_or(SacdError::NoStereoArea)), + }; + let area = parse_stereo_area(&mut image, master, &toc)?; + image.verify_unchanged()?; + Ok(area) +} + +fn read_area_slot(image: &mut SectorImage, slot: AreaSlot) -> Result<Vec<u8>, SacdError> { + let mut first_error = None; + let mut valid: Vec<(u32, Vec<u8>, Vec<u8>)> = Vec::new(); + for start in [slot.primary, slot.backup] + .into_iter() + .filter(|start| *start != 0) + { + let attempt = image.read_sectors(u64::from(start), usize::from(slot.size)); + match attempt { + Ok(mut toc) + if toc.len() >= SECTOR as usize + && (&toc[..8] == b"TWOCHTOC" || &toc[..8] == b"MULCHTOC") => + { + let internal_size = be16(&toc, 10); + if internal_size == 0 || internal_size > slot.size { + first_error.get_or_insert(SacdError::MalformedToc( + "area TOC internal size exceeds its Master TOC extent", + )); + continue; + } + toc.truncate(usize::from(internal_size) * SECTOR as usize); + match area_geometry_key(&toc, image.sectors()) { + Ok(key) => valid.push((start, toc, key)), + Err(error) => { + first_error.get_or_insert(error); + } + } + } + Ok(_) => { + first_error.get_or_insert(SacdError::NotAnArea); + } + Err(error) => { + first_error.get_or_insert(error); + } + } + } + let Some((_, selected, selected_key)) = valid.first() else { + return Err(first_error.unwrap_or(SacdError::NotAnArea)); + }; + if valid.iter().skip(1).any(|(_, _, key)| key != selected_key) { + return Err(SacdError::ConflictingAreaTocs); + } + Ok(selected.clone()) +} + +fn area_geometry_key(toc: &[u8], image_sectors: u64) -> Result<Vec<u8>, SacdError> { + let version = (toc[8], toc[9]); + if version.0 != 1 || version.1 > 20 { + return Err(SacdError::MalformedToc("unsupported area TOC version")); + } + if toc[0x14] != 4 { + return Err(SacdError::SampleFrequency(toc[0x14])); + } + if !matches!(toc[0x15] & 0x0f, 0 | 2 | 3) { + return Err(SacdError::FrameFormat(toc[0x15] & 0x0f)); + } + if toc[0x45] == 0 { + return Err(SacdError::TrackCount(0)); + } + let start = be32(toc, 0x48); + let end = be32(toc, 0x4c); + if start > end || u64::from(end) >= image_sectors { + return Err(SacdError::MalformedToc( + "audio area points outside the image", + )); + } + let trl1 = find_toc_block(toc, b"SACDTRL1", None).ok_or(SacdError::MissingBlock("SACDTRL1"))?; + let trl2 = find_toc_block(toc, b"SACDTRL2", None).ok_or(SacdError::MissingBlock("SACDTRL2"))?; + let count = usize::from(toc[0x45]); + const ARR2: usize = 8 + 255 * 4; + let mut key = Vec::with_capacity(64 + count * 16); + key.extend_from_slice(&toc[..12]); + key.extend_from_slice(&toc[0x10..0x16]); + key.extend_from_slice(&toc[0x20..0x22]); + key.extend_from_slice(&toc[0x40..0x46]); + key.extend_from_slice(&toc[0x48..0x51]); + key.extend_from_slice(&toc[0x80..0x86]); + key.extend_from_slice(&trl1[8..8 + count * 4]); + key.extend_from_slice(&trl1[ARR2..ARR2 + count * 4]); + key.extend_from_slice(&trl2[8..8 + count * 4]); + key.extend_from_slice(&trl2[ARR2..ARR2 + count * 4]); + let mut previous = None; + for i in 0..count { + let track_start = be32(trl1, 8 + i * 4); + let length = be32(trl1, ARR2 + i * 4); + let track_end = u64::from(track_start) + .checked_add(u64::from(length)) + .ok_or(SacdError::MalformedToc("track sector range overflows"))?; + if length == 0 + || track_start < start + || track_end > u64::from(end) + 1 + || previous.is_some_and(|prior| track_start <= prior) + || trl2[8 + i * 4 + 1] >= 60 + || trl2[8 + i * 4 + 2] >= 75 + || trl2[ARR2 + i * 4 + 1] >= 60 + || trl2[ARR2 + i * 4 + 2] >= 75 + { + return Err(SacdError::MalformedToc("track table is invalid")); + } + previous = Some(track_start); + } + Ok(key) +} + +fn parse_stereo_area( + image: &mut SectorImage, + master: MasterToc, + toc: &[u8], +) -> Result<SacdArea, SacdError> { + let channels = toc[0x20]; + if channels != 2 || toc[0x21] & 0x1f != 0 { + return Err(SacdError::Channels(channels)); + } + if toc[0x14] != 4 { + return Err(SacdError::SampleFrequency(toc[0x14])); + } + let encoding = match toc[0x15] & 0x0f { + 0 => SacdEncoding::Dst, + 2 => SacdEncoding::Dsd3In14, + 3 => SacdEncoding::Dsd3In16, + other => return Err(SacdError::FrameFormat(other)), + }; + let track_count = toc[0x45]; + if track_count == 0 { + return Err(SacdError::TrackCount(track_count)); + } + let total_frames = time_frames_at(toc, 0x40); + if toc[0x41] >= 60 || toc[0x42] >= 75 { + return Err(SacdError::MalformedToc("invalid area playtime")); + } + let track_start_lsn = be32(toc, 0x48); + let track_end_lsn = be32(toc, 0x4c); + if track_start_lsn > track_end_lsn || u64::from(track_end_lsn) >= image.sectors() { + return Err(SacdError::MalformedToc( + "audio area points outside the image", + )); + } + + let trl1 = find_toc_block(toc, b"SACDTRL1", None).ok_or(SacdError::MissingBlock("SACDTRL1"))?; + let trl2 = find_toc_block(toc, b"SACDTRL2", None).ok_or(SacdError::MissingBlock("SACDTRL2"))?; + const ARR2: usize = 8 + 255 * 4; + + let text_offset = be16(toc, 0x80); + let text_charset = toc[0x5a]; + let titles = find_toc_block(toc, b"SACDTTxt", (text_offset != 0).then_some(text_offset)) + .map(|block| read_titles(block, track_count as usize, text_charset)) + .unwrap_or_else(|| vec![None; track_count as usize]); + + let mut tracks = Vec::with_capacity(track_count as usize); + let mut previous_start = None; + for i in 0..track_count as usize { + let start_lsn = be32(trl1, 8 + i * 4); + let length_lsn = be32(trl1, ARR2 + i * 4); + let start_frame = time_frames_at(trl2, 8 + i * 4); + let duration_frame = time_frames_at(trl2, ARR2 + i * 4); + if trl2[8 + i * 4 + 1] >= 60 + || trl2[8 + i * 4 + 2] >= 75 + || trl2[ARR2 + i * 4 + 1] >= 60 + || trl2[ARR2 + i * 4 + 2] >= 75 + { + return Err(SacdError::MalformedToc("invalid track timecode")); + } + let end = u64::from(start_lsn) + .checked_add(u64::from(length_lsn)) + .ok_or(SacdError::MalformedToc("track sector range overflows"))?; + if length_lsn == 0 + || start_lsn < track_start_lsn + || u64::from(start_lsn) > u64::from(track_end_lsn) + || end > u64::from(track_end_lsn) + 1 + || previous_start.is_some_and(|previous| start_lsn <= previous) + { + return Err(SacdError::MalformedToc("track sector range is invalid")); + } + previous_start = Some(start_lsn); + tracks.push(SacdTrack { + number: (i + 1) as u8, + start_lsn, + length_lsn, + start_secs: start_frame as f64 / 75.0, + duration_secs: duration_frame as f64 / 75.0, + start_frame, + duration_frames: duration_frame, + encoding, + title: titles.get(i).cloned().flatten(), + }); + } + + let (album, artist) = read_master_text(image, master).unwrap_or((None, None)); + Ok(SacdArea { + track_start_lsn, + track_end_lsn, + channels, + encoding, + total_playtime_secs: total_frames as f64 / 75.0, + tracks, + album, + artist, + }) +} + +fn find_toc_block<'a>( + toc: &'a [u8], + signature: &[u8; 8], + declared: Option<u16>, +) -> Option<&'a [u8]> { + let at_sector = |relative: usize| -> Option<&'a [u8]> { + let at = relative.checked_mul(SECTOR as usize)?; + let block = toc.get(at..)?; + if block.len() < SECTOR as usize { + return None; + } + (&block[..8] == signature).then_some(block) + }; + if let Some(relative) = declared { + return at_sector(usize::from(relative)); + } + (0..toc.len() / SECTOR as usize).find_map(at_sector) +} + +/// Album title and artist out of the Master TOC's text sector. +/// +/// A SACD names itself, which is why this feature needs no network the way a +/// CD does. Measured layout, identical on both of the owner's discs: +/// LSN 510 "SACDMTOC" (the Master TOC; backups at 520 and 530 are +/// byte-identical, so a failure can fall back) +/// LSN 511 "SACDText" the text sector +/// +0x10 u16 BE album title pointer } offsets are relative to the +/// +0x12 u16 BE album artist pointer } START OF THE TEXT SECTOR +/// payload NUL-terminated strings +/// +/// Everything here is optional: an image whose Master TOC is unreadable or +/// carries no text still plays, it just falls back to the file name. +fn read_master_text( + image: &mut SectorImage, + master: MasterToc, +) -> Option<(Option<String>, Option<String>)> { + if master.text_area_count == 0 { + return None; + } + let text = image.read_sectors(master.lsn + 1, 1).ok()?; + if &text[0..8] != b"SACDText" { + return None; + } + let ptr = |at: usize| -> Option<usize> { + let v = u16::from_be_bytes([text[at], text[at + 1]]) as usize; + // Zero means "absent", not "offset zero" — offset zero is the id. + (v != 0 && v < text.len()).then_some(v) + }; + let string_at = |off: usize| -> Option<String> { + let end = text[off..].iter().position(|b| *b == 0)? + off; + decode_sacd_text(&text[off..end], master.text_charset, "Master TOC text") + }; + Some((ptr(0x10).and_then(string_at), ptr(0x12).and_then(string_at))) +} + +/// Track titles from one validated `SACDTTxt` sector. The block's pointer +/// table is relative to the block, and each pointed record can contain several +/// typed strings; type 1 is the title. +fn read_titles(block: &[u8], count: usize, charset: u8) -> Vec<Option<String>> { + let mut out = Vec::with_capacity(count); + for i in 0..count { + let at = 8 + i * 2; + let ptr = match block.get(at..at + 2) { + Some(p) => u16::from_be_bytes([p[0], p[1]]) as usize, + None => { + out.push(None); + continue; + } + }; + out.push(track_title_at(block, ptr, charset, i + 1)); + } + out +} + +fn track_title_at(block: &[u8], ptr: usize, charset: u8, track: usize) -> Option<String> { + let amount = *block.get(ptr)? as usize; + let mut at = ptr.checked_add(4)?; + for _ in 0..amount { + let kind = *block.get(at)?; + at = at.checked_add(2)?; + let tail = block.get(at..)?; + let len = tail.iter().position(|byte| *byte == 0)?; + if kind == 1 { + return decode_sacd_text(&tail[..len], charset, &format!("track {track} title")); + } + at = at.checked_add(len)?; + while block.get(at).is_some_and(|byte| *byte == 0) { + at += 1; + } + } + None +} + +/// Convert one bounded Scarlet Book string to UTF-8. Invalid input degrades +/// only that optional field: geometry and playback remain usable, but no +/// replacement characters are invented and a warning records the fallback. +fn decode_sacd_text(raw: &[u8], charset: u8, field: &str) -> Option<String> { + use encoding_rs::{BIG5, EUC_KR, GBK, SHIFT_JIS}; + + let code = charset & 0x07; + let decoded = match code { + // Unknown is not permission to guess. ISO 646 is 7-bit; reject a + // high byte rather than laundering it through UTF-8 replacement. + 0 => None, + 1 => raw + .iter() + .all(|byte| byte.is_ascii()) + .then(|| String::from_utf8(raw.to_vec()).expect("ASCII is UTF-8")), + // ISO-8859-1 maps every byte directly to the same Unicode scalar. + // Code 7 permits escape sequences; the common no-escape form is the + // same mapping, while an actual ESC requires state we do not guess. + 2 => Some(raw.iter().map(|byte| char::from(*byte)).collect()), + 3 => SHIFT_JIS + .decode_without_bom_handling_and_without_replacement(raw) + .map(|value| value.into_owned()), + 4 => EUC_KR + .decode_without_bom_handling_and_without_replacement(raw) + .map(|value| value.into_owned()), + 5 => GBK + .decode_without_bom_handling_and_without_replacement(raw) + .map(|value| value.into_owned()), + 6 => BIG5 + .decode_without_bom_handling_and_without_replacement(raw) + .map(|value| value.into_owned()), + 7 if !raw.contains(&0x1b) => Some(raw.iter().map(|byte| char::from(*byte)).collect()), + 7 => None, + _ => unreachable!("charset code is masked to three bits"), + }; + let Some(text) = decoded else { + log::warn!("[sacd] ignoring {field}: unknown charset {code} or invalid byte sequence"); + return None; + }; + let text = text.trim().to_string(); + (!text.is_empty()).then_some(text) +} + +// --------------------------------------------------------------------------- +// The sector parser +// --------------------------------------------------------------------------- + +/// One packet of a Scarlet Book audio sector. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Packet { + pub frame_start: bool, + pub data_type: u16, + pub len: usize, + /// Offset of the payload within the 2048-byte sector. + pub at: usize, + /// Sector header bit. DST sectors carry four-byte frame-info records; + /// uncompressed DSD sectors carry three-byte records. + pub dst_encoded: bool, + /// For a DST frame-start packet, the declared number of sectors occupied + /// by this frame. Absent for continuation and uncompressed packets. + pub frame_sector_count: Option<u8>, + /// Absolute area timecode carried by the matching frame-info record. + pub frame_timecode: Option<u32>, + /// Channel count encoded in a DST frame-info record. + pub frame_channels: Option<u8>, +} + +/// Decode one sector's framing. +/// +/// Measured layout, census of 3 066 893 sectors with zero exceptions: +/// byte 0: packet_count = (b >> 5) & 0x07 +/// frame_info_count = (b >> 2) & 0x07 +/// then `packet_count` BIG-ENDIAN 16-bit descriptors: +/// bit 15 frame_start +/// bits 14-11 data_type +/// bits 10-0 length IN BYTES <- ELEVEN bits, not twelve +/// then `frame_info_count` * (DST ? 4 : 3) bytes +/// +/// The eleven-bit length is the correction that matters. The widely-copied +/// layout gives the length twelve bits, and the very first descriptor on both +/// discs is 0x181B — whose low twelve bits are 2075, larger than the sector. +/// With eleven bits it is 27, and 5 + 27 + 2016 = 2048 exactly. The sum rule +/// `header_len + SUM(lengths) == 2048` is what proves the whole decoding, and +/// it is checked here on every sector rather than trusted. +pub fn parse_sector(sector: &[u8], lsn: u64) -> Result<Vec<Packet>, SacdError> { + if sector.len() != SECTOR as usize { + return Err(SacdError::BadSector { + lsn, + why: "not 2048 bytes", + }); + } + let b0 = sector[0]; + let dst_encoded = b0 & 0x01 != 0; + let packet_count = ((b0 >> 5) & 0x07) as usize; + let frame_info_count = ((b0 >> 2) & 0x07) as usize; + let frame_info_size = if dst_encoded { 4 } else { 3 }; + let frame_info_at = 1 + packet_count * 2; + let header_len = frame_info_at + frame_info_count * frame_info_size; + if packet_count == 0 || header_len > sector.len() { + return Err(SacdError::BadSector { + lsn, + why: "impossible packet/frame counts", + }); + } + + let mut packets = Vec::with_capacity(packet_count); + let mut at = header_len; + let mut total = header_len; + let mut frame_info = 0usize; + for k in 0..packet_count { + let w = u16::from_be_bytes([sector[1 + 2 * k], sector[2 + 2 * k]]); + let len = (w & 0x07FF) as usize; + let frame_start = w & 0x8000 != 0; + let data_type = (w >> 11) & 0x07; + // Lead-out sectors on real discs mark their final padding packet as a + // frame start and pair it with an all-0xff sentinel frame-info row. + // The row still counts toward `frame_info_count`, but it has no audio + // semantics and its sentinel timecode must not be validated. + let info = if frame_start { + let info = frame_info_at + frame_info * frame_info_size; + frame_info += 1; + Some(info) + } else { + None + }; + let (frame_sector_count, frame_timecode, frame_channels) = + if let Some(info) = info.filter(|_| data_type == DATA_TYPE_AUDIO) { + let time = sector.get(info..info + 3).ok_or(SacdError::BadSector { + lsn, + why: "frame-info table is truncated", + })?; + if time[1] >= 60 || time[2] >= 75 { + return Err(SacdError::BadSector { + lsn, + why: "frame-info timecode is invalid", + }); + } + let timecode = (time[0] as u32 * 60 + time[1] as u32) * 75 + time[2] as u32; + if dst_encoded { + let flags = sector[info + 3]; + let channels = match (flags & 0x02 != 0, flags & 0x01 != 0) { + (true, false) => 6, + (false, true) => 5, + _ => 2, + }; + (Some((flags >> 2) & 0x1f), Some(timecode), Some(channels)) + } else { + (None, Some(timecode), Some(2)) + } + } else { + (None, None, None) + }; + packets.push(Packet { + frame_start, + data_type, + len, + at, + dst_encoded, + frame_sector_count, + frame_timecode, + frame_channels, + }); + at += len; + total += len; + } + // Flat DSD fills the sector exactly. Real DST authoring also permits an + // undeclared zero tail after the last packet (1..6 bytes commonly, and a + // larger zero tail when a compressed frame ends early). It is alignment, + // not audio: accept only all-zero DST tails and never publish them. + if total > SECTOR as usize + || (total < SECTOR as usize + && (!dst_encoded || sector[total..].iter().any(|byte| *byte != 0))) + { + return Err(SacdError::BadSector { + lsn, + why: "packet lengths do not account for the sector", + }); + } + if frame_info != frame_info_count { + return Err(SacdError::BadSector { + lsn, + why: "frame-info count does not match frame starts", + }); + } + Ok(packets) +} + +pub const MAX_DST_FRAME: usize = 64 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SacdFrameKind { + Dsd, + Dst, +} + +/// One complete, container-validated 1/75-second audio unit. DST remains +/// compressed here; decoding belongs to qbz-dsd. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SacdAudioFrame { + pub kind: SacdFrameKind, + pub payload: Vec<u8>, + pub start_lsn: u64, + pub timecode_frame: u32, +} + +struct BuildingFrame { + frame: SacdAudioFrame, + dst_packets_left: Option<u8>, +} + +/// Assemble exact Scarlet Book frames inside one track window. The reader +/// uses TRL2 timecodes as the publication boundary, so a sector shared by two +/// tracks neither duplicates nor loses its boundary frame. +pub struct SacdFrameReader { + image: SectorImage, + track: u8, + encoding: SacdEncoding, + start_lsn: u64, + next_lsn: u64, + end_lsn: u64, + track_start_frame: u32, + track_end_frame: u32, + target_frame: u32, + emitted: u32, + current: Option<BuildingFrame>, + ready: std::collections::VecDeque<SacdAudioFrame>, + search_started_at: u64, + searched_sectors: u8, + saw_frame_start: bool, +} + +impl SacdFrameReader { + pub fn open(path: &Path, track: &SacdTrack) -> Result<Self, SacdError> { + let image = SectorImage::open(path)?; + image.validate_length()?; + let start_lsn = u64::from(track.start_lsn); + let end_lsn = start_lsn + .checked_add(u64::from(track.length_lsn)) + .ok_or(SacdError::MalformedToc("track sector range overflows"))?; + if end_lsn > image.sectors() { + return Err(SacdError::MalformedToc("track points outside the image")); + } + let track_end_frame = track + .start_frame + .checked_add(track.duration_frames) + .ok_or(SacdError::MalformedToc("track timecode overflows"))?; + Ok(Self { + image, + track: track.number, + encoding: track.encoding, + start_lsn, + next_lsn: start_lsn, + end_lsn, + track_start_frame: track.start_frame, + track_end_frame, + target_frame: track.start_frame, + emitted: 0, + current: None, + ready: std::collections::VecDeque::new(), + search_started_at: start_lsn, + searched_sectors: 0, + saw_frame_start: false, + }) + } + + pub fn finished(&self) -> bool { + self.target_frame.saturating_add(self.emitted) >= self.track_end_frame + } + + pub fn seek_to_fraction( + &mut self, + offset_units: u64, + total_units: u64, + ) -> Result<(), SacdError> { + let duration = self.track_end_frame - self.track_start_frame; + let frame_offset = if total_units == 0 { + 0 + } else { + ((u128::from(duration) * u128::from(offset_units.min(total_units))) + / u128::from(total_units)) as u32 + }; + self.target_frame = self.track_start_frame.saturating_add(frame_offset); + self.emitted = 0; + self.current = None; + self.ready.clear(); + if self.target_frame >= self.track_end_frame { + self.next_lsn = self.end_lsn; + } else { + // DST is variable-rate: a proportional byte/sector estimate can + // be thousands of sectors past the requested time even though it + // looks convincing on constant-rate DSD. Binary-search the + // monotonic on-disc frame timecodes instead. A probe looks ahead + // at most the format's 31-sector DST-frame bound; the final + // 32-sector backoff guarantees the requested frame is reacquired + // whole rather than entered through a continuation packet. + let mut low = self.start_lsn; + let mut high = self.end_lsn; + while high.saturating_sub(low) > 1 { + let middle = low + (high - low) / 2; + match self.frame_at_or_after(middle)? { + Some((_, timecode)) if timecode < self.target_frame => low = middle + 1, + _ => high = middle, + } + } + self.next_lsn = low.saturating_sub(32).max(self.start_lsn); + } + self.search_started_at = self.next_lsn; + self.searched_sectors = 0; + self.saw_frame_start = false; + Ok(()) + } + + fn frame_at_or_after(&mut self, start_lsn: u64) -> Result<Option<(u64, u32)>, SacdError> { + let stop = start_lsn.saturating_add(32).min(self.end_lsn); + for lsn in start_lsn..stop { + let raw = self.image.read_sectors(lsn, 1)?; + let packets = parse_sector(&raw, lsn)?; + let sector_dst = packets.first().is_some_and(|packet| packet.dst_encoded); + if sector_dst != (self.encoding == SacdEncoding::Dst) { + return Err(SacdError::BadSector { + lsn, + why: "sector encoding disagrees with the area TOC", + }); + } + if let Some(packet) = packets + .iter() + .find(|packet| packet.data_type == DATA_TYPE_AUDIO && packet.frame_start) + { + if packet.frame_channels != Some(2) { + return Err(self.bad_frame(lsn, "frame is not stereo")); + } + return packet + .frame_timecode + .map(|timecode| Some((lsn, timecode))) + .ok_or(SacdError::BadSector { + lsn, + why: "frame start has no timecode", + }); + } + } + Ok(None) + } + + pub fn next_frame(&mut self) -> Result<Option<SacdAudioFrame>, SacdError> { + if self.finished() { + self.image.verify_unchanged()?; + return Ok(None); + } + loop { + if let Some(frame) = self.ready.pop_front() { + if frame.timecode_frame < self.target_frame { + continue; + } + let expected = self.target_frame.saturating_add(self.emitted); + if frame.timecode_frame != expected { + return Err(self.bad_frame( + frame.start_lsn, + "frame timecode is discontinuous or seek overshot its target", + )); + } + self.emitted += 1; + return Ok(Some(frame)); + } + if self.next_lsn >= self.end_lsn { + self.finish_current()?; + if self.ready.is_empty() { + return Err( + self.bad_frame(self.end_lsn, "track ended before its TRL2 duration") + ); + } + continue; + } + self.read_next_sector()?; + } + } + + fn read_next_sector(&mut self) -> Result<(), SacdError> { + let lsn = self.next_lsn; + let raw = self.image.read_sectors(lsn, 1)?; + self.next_lsn += 1; + let packets = parse_sector(&raw, lsn)?; + let sector_dst = packets.first().is_some_and(|packet| packet.dst_encoded); + if sector_dst != (self.encoding == SacdEncoding::Dst) { + return Err(SacdError::BadSector { + lsn, + why: "sector encoding disagrees with the area TOC", + }); + } + self.searched_sectors = self.searched_sectors.saturating_add(1); + for packet in packets { + if packet.data_type != DATA_TYPE_AUDIO { + continue; + } + if packet.frame_start { + self.saw_frame_start = true; + self.finish_current()?; + let timecode_frame = packet.frame_timecode.ok_or(SacdError::BadSector { + lsn, + why: "frame start has no timecode", + })?; + if packet.frame_channels != Some(2) { + return Err(self.bad_frame(lsn, "frame is not stereo")); + } + let dst_packets_left = if sector_dst { + match packet.frame_sector_count { + Some(1..=31) => packet.frame_sector_count, + _ => return Err(self.bad_frame(lsn, "invalid DST sector count")), + } + } else { + None + }; + self.current = Some(BuildingFrame { + frame: SacdAudioFrame { + kind: if sector_dst { + SacdFrameKind::Dst + } else { + SacdFrameKind::Dsd + }, + payload: Vec::with_capacity(if sector_dst { + MAX_DST_FRAME.min(16 * 1024) + } else { + DSD64_STEREO_FRAME as usize + }), + start_lsn: lsn, + timecode_frame, + }, + dst_packets_left, + }); + } + let Some(building) = self.current.as_mut() else { + continue; + }; + if let Some(left) = building.dst_packets_left.as_mut() { + if *left == 0 { + return Err(self.bad_frame(lsn, "DST sector count ended before the frame")); + } + *left -= 1; + } + let end = packet.at + packet.len; + building + .frame + .payload + .extend_from_slice(&raw[packet.at..end]); + if building.frame.payload.len() > MAX_DST_FRAME { + return Err(self.bad_frame(lsn, "audio frame exceeds 64 KiB")); + } + } + if !self.saw_frame_start && self.searched_sectors >= 32 { + return Err(SacdError::MissingFrameStart { + track: self.track, + lsn: self.search_started_at, + }); + } + Ok(()) + } + + fn finish_current(&mut self) -> Result<(), SacdError> { + let Some(building) = self.current.take() else { + return Ok(()); + }; + let valid = match building.frame.kind { + SacdFrameKind::Dsd => building.frame.payload.len() == DSD64_STEREO_FRAME as usize, + SacdFrameKind::Dst => building.dst_packets_left == Some(0), + }; + if !valid { + return Err(self.bad_frame(building.frame.start_lsn, "frame closed at the wrong size")); + } + if building.frame.timecode_frame >= self.target_frame + && building.frame.timecode_frame < self.track_end_frame + { + self.ready.push_back(building.frame); + } + Ok(()) + } + + fn bad_frame(&self, lsn: u64, why: &'static str) -> SacdError { + SacdError::BadAudioFrame { + track: self.track, + lsn, + why, + } + } +} + +/// Streams the raw DSD of one track out of an image. +/// Where the first DSD frame is taken to begin. +/// +/// MEASURED on the owner's Rheingold, both discs, every track: audio frames +/// are 9408 bytes and spaced exactly 9408 apart, but the FIRST one does not +/// begin at the start of a track's payload. It begins at 0, 672 or 1344 bytes +/// in, depending on the track — the packet descriptors carry a `frame_start` +/// bit precisely so a reader can find it. +/// +/// Ignoring that bit puts every 9408-byte window across two real frames, so +/// each channel is assembled from the tail of one and the head of the next. +/// It plays; it plays as noise with the music audible behind it, in EVERY +/// delivery mode, because the damage is done before PCM/DoP/native is chosen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameSync { + /// Skip audio bytes until the first packet that declares a frame start. + /// The correct reading, and what playback uses. + ToFirstFrame, + /// Take the payload as it comes. Kept because it is what shipped, and + /// because a probe has to be able to reproduce the broken stream to show + /// the difference. + FromPayloadStart, +} + +pub struct SacdTrackReader { + image: SectorImage, + start_lsn: u64, + next_lsn: u64, + end_lsn: u64, + /// Set once the first frame boundary has been found; until then audio + /// bytes are DROPPED rather than emitted. It IS the sync mode after + /// construction — `FromPayloadStart` simply starts out true — so the mode + /// itself is not kept around to be read twice. + synced: bool, + expected_dst: bool, +} + +impl SacdTrackReader { + pub fn open(path: &Path, track: &SacdTrack) -> Result<Self, SacdError> { + Self::open_with(path, track, FrameSync::ToFirstFrame) + } + + pub fn open_with(path: &Path, track: &SacdTrack, sync: FrameSync) -> Result<Self, SacdError> { + let start_lsn = track.start_lsn as u64; + let image = SectorImage::open(path)?; + image.validate_length()?; + let end_lsn = start_lsn + .checked_add(u64::from(track.length_lsn)) + .ok_or(SacdError::MalformedToc("track sector range overflows"))?; + if end_lsn > image.sectors() { + return Err(SacdError::MalformedToc("track points outside the image")); + } + Ok(Self { + image, + start_lsn, + next_lsn: start_lsn, + // `length_lsn` is how many sectors to read, including the shared + // boundary sector — the disc's own accounting, not ours. + end_lsn, + synced: matches!(sync, FrameSync::FromPayloadStart), + expected_dst: track.encoding == SacdEncoding::Dst, + }) + } + + pub fn finished(&self) -> bool { + self.next_lsn >= self.end_lsn + } + + /// Reposition proportionally inside the track and re-acquire the next + /// declared DSD frame boundary. Mapping against the TOC's sector span is + /// important: 75 Hz is the DSD audio-frame cadence, not the ISO sector + /// cadence (several sectors contribute to each audio frame). + pub fn seek_to_fraction(&mut self, offset_units: u64, total_units: u64) { + let span = self.end_lsn - self.start_lsn; + let sector_offset = if total_units == 0 { + 0 + } else { + ((span as u128 * offset_units.min(total_units) as u128) / total_units as u128) as u64 + }; + self.next_lsn = self.start_lsn + sector_offset; + self.synced = false; + } + + /// Append the next chunk of AUDIO payload to `out`, skipping every + /// non-audio packet — and, until the first frame boundary is found, every + /// audio byte too (see [`FrameSync`]). Returns bytes appended. + /// + /// Zero appended does NOT mean the track is done while the reader is still + /// hunting for its first frame: the caller must ask [`Self::finished`]. + /// + /// Supplementary and padding packets (`data_type` 3 and 7 on these discs) + /// are dropped rather than passed through: they are not audio, and a DSD + /// stream with them in it is noise. + pub fn next_chunk(&mut self, out: &mut Vec<u8>, sectors: usize) -> Result<usize, SacdError> { + out.clear(); + if self.finished() { + return Ok(0); + } + let want = sectors.min((self.end_lsn - self.next_lsn) as usize); + let raw = self.image.read_sectors(self.next_lsn, want)?; + for s in 0..want { + let lsn = self.next_lsn + s as u64; + let sector = &raw[s * SECTOR as usize..(s + 1) * SECTOR as usize]; + let packets = parse_sector(sector, lsn)?; + if packets + .first() + .is_some_and(|packet| packet.dst_encoded != self.expected_dst) + { + return Err(SacdError::BadSector { + lsn, + why: "sector encoding disagrees with the area TOC", + }); + } + if self.expected_dst { + return Err(SacdError::Dst); + } + for p in packets { + if p.data_type != DATA_TYPE_AUDIO { + continue; + } + // Everything before the first declared frame start belongs to + // the previous track's last frame — the areas share boundary + // sectors — and emitting it shifts the whole stream. + if !self.synced { + if !p.frame_start { + continue; + } + self.synced = true; + log::debug!("[sacd] frame sync at lsn {lsn} offset {}", p.at); + } + out.extend_from_slice(§or[p.at..p.at + p.len]); + } + } + self.next_lsn += want as u64; + if self.finished() { + self.image.verify_unchanged()?; + } + Ok(out.len()) + } +} + +#[cfg(test)] +#[path = "sacd_raw_tests.rs"] +mod raw_sector_tests; + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a sector from descriptors read off the owner's Disc 1. + fn sector(b0: u8, descs: &[u16]) -> Vec<u8> { + let mut s = vec![0u8; 2048]; + s[0] = b0; + for (k, w) in descs.iter().enumerate() { + s[1 + 2 * k..3 + 2 * k].copy_from_slice(&w.to_be_bytes()); + } + s + } + + /// The first three DISTINCT sector shapes of Disc 1's audio area, with the + /// exact descriptor words read off the image at LSN 1253 / 1254 / 1257. + /// Each one adds up to 2048 — which IS the proof that the decoding is + /// right, so the test asserts the sum rather than trusting the parser. + #[test] + fn the_three_real_sector_shapes_decode_and_fill_exactly() { + // LSN 1253: 0x44 -> header 8, then (type 3, 24) + (type 2, 2016, start) + let p = parse_sector(§or(0x44, &[0x1818, 0x97E0]), 1253).unwrap(); + assert_eq!(p.len(), 2); + assert_eq!((p[0].data_type, p[0].len, p[0].at), (3, 24, 8)); + assert_eq!((p[1].data_type, p[1].len), (DATA_TYPE_AUDIO, 2016)); + assert!(p[1].frame_start, "0x97E0 has bit 15 set"); + assert_eq!(8 + 24 + 2016, 2048); + + // LSN 1254: 0x40 -> header 5, then (type 3, 27) + (type 2, 2016) + let p = parse_sector(§or(0x40, &[0x181B, 0x17E0]), 1254).unwrap(); + assert_eq!((p[0].len, p[0].at), (27, 5)); + assert!( + !p[0].frame_start && !p[1].frame_start, + "0x40 declares no frame info" + ); + assert_eq!(5 + 27 + 2016, 2048); + + // LSN 1257: 0x64 -> header 10, three packets, the last starting a frame + let p = parse_sector(§or(0x64, &[0x1816, 0x1540, 0x92A0]), 1257).unwrap(); + assert_eq!(p.len(), 3); + assert_eq!((p[0].len, p[1].len, p[2].len), (22, 1344, 672)); + assert!(p[2].frame_start); + assert_eq!(10 + 22 + 1344 + 672, 2048); + } + + #[test] + fn an_eleven_bit_length_is_what_makes_a_sector_add_up() { + // 0x181B, read off LSN 1254. Twelve bits give a length larger than the + // sector it lives in, which is how the widely-copied layout refutes + // itself the first time anyone checks it against a disc. + let w = 0x181Bu16; + assert_eq!((w & 0x0FFF) as usize, 2075, "the 12-bit reading"); + assert!(2075 > 2048, "cannot fit in a sector — hence 11 bits"); + assert_eq!((w & 0x07FF) as usize, 27); + } + + #[test] + fn the_three_first_bytes_that_exist_give_the_three_header_lengths() { + for (b0, pc, fic, header) in [(0x40u8, 2, 0, 5), (0x44, 2, 1, 8), (0x64, 3, 1, 10)] { + assert_eq!(((b0 >> 5) & 0x07) as usize, pc, "packet count of {b0:#04x}"); + assert_eq!(((b0 >> 2) & 0x07) as usize, fic, "frame infos of {b0:#04x}"); + assert_eq!(1 + pc * 2 + fic * 3, header); + } + } + + #[test] + fn a_dst_sector_uses_four_byte_frame_info_and_exposes_its_boundary() { + let mut raw = sector(0x25, &[0x97f9]); + raw[3..7].copy_from_slice(&[1, 2, 3, 0x04]); + let packets = parse_sector(&raw, 77).unwrap(); + assert_eq!(packets.len(), 1); + assert_eq!(packets[0].at, 7); + assert_eq!(packets[0].len, 2041); + assert!(packets[0].dst_encoded); + assert_eq!(packets[0].frame_sector_count, Some(1)); + assert_eq!(packets[0].frame_timecode, Some((60 + 2) * 75 + 3)); + assert_eq!(packets[0].frame_channels, Some(2)); + } + + #[test] + fn frame_info_count_must_equal_the_number_of_starts() { + let raw = sector(0x25, &[0x17f9]); + assert!(matches!( + parse_sector(&raw, 78), + Err(SacdError::BadSector { .. }) + )); + } + + #[test] + fn a_lead_out_padding_start_consumes_but_does_not_validate_its_sentinel_info() { + // Exact shape of the last Bowie and Ritchie area sector: a normal + // supplementary packet followed by padding whose frame-start bit is + // paired with an all-0xff sentinel rather than an audio timecode. + let mut raw = sector(0x44, &[0x1818, 0xbfe0]); + raw[5..8].fill(0xff); + let packets = parse_sector(&raw, 959_402).unwrap(); + assert_eq!(packets.len(), 2); + assert_eq!(packets[1].data_type, 7, "Scarlet Book padding"); + assert!(packets[1].frame_start); + assert_eq!(packets[1].frame_timecode, None); + assert_eq!(packets[1].at + packets[1].len, SECTOR as usize); + } + + #[test] + fn dst_zero_fill_is_alignment_not_audio() { + // Fidelio Disc 2 LSN 969 has this exact header: one 2041-byte audio + // continuation followed by four zero alignment bytes. + let raw = sector(0x21, &[0x17f9]); + let packets = parse_sector(&raw, 969).unwrap(); + assert_eq!(packets.len(), 1); + assert_eq!((packets[0].at, packets[0].len), (3, 2041)); + assert_eq!(packets[0].at + packets[0].len, 2044); + + let mut nonzero_tail = raw; + nonzero_tail[2047] = 1; + assert!(matches!( + parse_sector(&nonzero_tail, 969), + Err(SacdError::BadSector { .. }) + )); + } + + #[test] + fn scarlet_book_text_is_converted_without_replacement_characters() { + assert_eq!( + decode_sacd_text(b"Ouvert\xfcre", 2, "test"), + Some("Ouvertüre".to_string()) + ); + assert_eq!( + decode_sacd_text(b"plain ASCII", 1, "test").as_deref(), + Some("plain ASCII") + ); + assert_eq!(decode_sacd_text(b"bad\xff", 1, "test"), None); + assert_eq!(decode_sacd_text(b"do not guess", 0, "test"), None); + assert_eq!(decode_sacd_text(b"escape\x1bsequence", 7, "test"), None); + } + + #[test] + fn a_changed_image_is_detected_before_more_data_is_trusted() { + use std::io::Write; + + let dir = std::env::temp_dir().join(format!("qbz-sacd-change-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("changed.iso"); + std::fs::write(&path, vec![0u8; SECTOR as usize]).unwrap(); + let image = SectorImage::open(&path).unwrap(); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(&[0u8; 1]) + .unwrap(); + assert!(matches!( + image.verify_unchanged(), + Err(SacdError::ImageChangedDuringRead(_)) + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_partial_sector_has_its_own_error_category() { + let dir = std::env::temp_dir().join(format!("qbz-sacd-length-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("partial.iso"); + std::fs::write(&path, [0u8; 1]).unwrap(); + assert!(matches!( + read_area(&path), + Err(SacdError::InvalidImageLength(1)) + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn dst_track_boundaries_are_taken_from_timecodes_not_shared_sectors() { + use std::io::{Seek, SeekFrom, Write}; + + let dir = std::env::temp_dir().join(format!("qbz-sacd-frame-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("frames.iso"); + let mut file = std::fs::File::create(&path).unwrap(); + for (lsn, timecode) in [(1u64, [0u8, 0, 0]), (2, [0, 0, 1])] { + let mut raw = sector(0x25, &[0x97f9]); + raw[3..6].copy_from_slice(&timecode); + raw[6] = 0x04; + file.seek(SeekFrom::Start(lsn * SECTOR)).unwrap(); + file.write_all(&raw).unwrap(); + } + let track = SacdTrack { + number: 1, + start_lsn: 1, + length_lsn: 2, + start_secs: 0.0, + duration_secs: 1.0 / 75.0, + start_frame: 0, + duration_frames: 1, + encoding: SacdEncoding::Dst, + title: None, + }; + let mut reader = SacdFrameReader::open(&path, &track).unwrap(); + let frame = reader.next_frame().unwrap().unwrap(); + assert_eq!(frame.kind, SacdFrameKind::Dst); + assert_eq!(frame.timecode_frame, 0); + assert_eq!(frame.start_lsn, 1); + assert_eq!(frame.payload.len(), 2041); + assert!(reader.next_frame().unwrap().is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn dst_seek_uses_timecodes_instead_of_assuming_a_constant_compression_ratio() { + use std::io::{Seek, SeekFrom, Write}; + + let dir = std::env::temp_dir().join(format!("qbz-sacd-seek-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("variable-rate.iso"); + let mut file = std::fs::File::create(&path).unwrap(); + let mut lsn = 1u64; + let mut middle_lsn = 0u64; + for frame in 0..100u32 { + if frame == 50 { + middle_lsn = lsn; + } + let sectors = if frame < 50 { 31u8 } else { 1u8 }; + let mut first = sector(0x25, &[0x97f9]); + first[3..6].copy_from_slice(&[0, (frame / 75) as u8, (frame % 75) as u8]); + first[6] = sectors << 2; + file.seek(SeekFrom::Start(lsn * SECTOR)).unwrap(); + file.write_all(&first).unwrap(); + lsn += 1; + for _ in 1..sectors { + let continuation = sector(0x21, &[0x17fd]); + file.seek(SeekFrom::Start(lsn * SECTOR)).unwrap(); + file.write_all(&continuation).unwrap(); + lsn += 1; + } + } + let track = SacdTrack { + number: 1, + start_lsn: 1, + length_lsn: (lsn - 1) as u32, + start_secs: 0.0, + duration_secs: 100.0 / 75.0, + start_frame: 0, + duration_frames: 100, + encoding: SacdEncoding::Dst, + title: None, + }; + let mut reader = SacdFrameReader::open(&path, &track).unwrap(); + reader.seek_to_fraction(50, 100).unwrap(); + let frame = reader.next_frame().unwrap().unwrap(); + assert_eq!(frame.timecode_frame, 50); + assert_eq!(frame.start_lsn, middle_lsn); + + reader.seek_to_fraction(99, 100).unwrap(); + assert_eq!(reader.next_frame().unwrap().unwrap().timecode_frame, 99); + assert!(reader.next_frame().unwrap().is_none()); + + reader.seek_to_fraction(100, 100).unwrap(); + assert!(reader.next_frame().unwrap().is_none()); + + reader.seek_to_fraction(0, 100).unwrap(); + assert_eq!(reader.next_frame().unwrap().unwrap().timecode_frame, 0); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_sector_whose_lengths_do_not_fill_it_is_refused() { + // Decoding one wrong is exactly how a fixed-size header skip corrupts + // a DSD stream, so it must be an error and not a shrug. + let s = sector(0x44, &[0x1818, 0x1000]); // second packet length 0 + assert!(matches!( + parse_sector(&s, 1253), + Err(SacdError::BadSector { .. }) + )); + } + + #[test] + fn the_start_lsn_and_start_time_identity_holds() { + // Verified 44/44 on the owner's discs: this is the relation that + // proves SECTORS_PER_SEC and ties the two TOC blocks together. + // The four values below were read out of SACDTRL1 and SACDTRL2 on + // Disc 1; the identity reproduces every start LSN exactly, which is + // what pins SECTORS_PER_SEC at 350 rather than leaving it a guess. + let area_start = 553u32; + for (start_lsn, start_secs) in [ + (1253u32, 2.0f64), + (97_890, 278.106_666_7), + (123_337, 350.813_333_3), + (150_665, 428.893_333_3), + ] { + let predicted = area_start + (start_secs * SECTORS_PER_SEC as f64) as u32; + assert_eq!(predicted, start_lsn, "track starting at {start_secs}s"); + } + } + + #[test] + fn an_uncompressed_stereo_frame_is_the_size_that_proves_no_dst() { + assert_eq!(DSD64_STEREO_FRAME, 2 * 2_822_400 / 75 / 8); + // The measured audio-payload totals divide by it exactly. + assert_eq!( + 350_495u64 * DSD64_STEREO_FRAME as u64 % DSD64_STEREO_FRAME as u64, + 0 + ); + } + + /// The scan sniff: a bounded, internally plausible Master TOC. The ISO + /// compatibility layer is deliberately irrelevant. + #[test] + fn sniff_accepts_raw_and_hybrid_images_with_a_master_toc() { + use std::io::{Seek, SeekFrom, Write}; + + let dir = std::env::temp_dir().join(format!("qbz-sacd-sniff-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let write = |name: &str, toc_lsn: Option<u64>, pvd: bool| { + let path = dir.join(name); + let mut file = std::fs::File::create(&path).unwrap(); + if pvd { + let mut sector = [0u8; 2048]; + sector[0] = 1; + sector[1..6].copy_from_slice(b"CD001"); + file.seek(SeekFrom::Start(16 * 2048)).unwrap(); + file.write_all(§or).unwrap(); + } + if let Some(lsn) = toc_lsn { + let mut master = [0u8; 2048]; + master[..8].copy_from_slice(b"SACDMTOC"); + master[8] = 1; + master[9] = 20; + master[0x40..0x44].copy_from_slice(&544u32.to_be_bytes()); + master[0x54..0x56].copy_from_slice(&1u16.to_be_bytes()); + file.seek(SeekFrom::Start(lsn * 2048)).unwrap(); + file.write_all(&master).unwrap(); + } + file.seek(SeekFrom::Start(545 * 2048)).unwrap(); + file.write_all(&[0u8; 2048]).unwrap(); + path + }; + + for path in [ + write("sacd.iso", Some(510), true), + write("backup.iso", Some(530), true), + write("raw.iso", Some(510), false), + ] { + assert_eq!( + super::sniff_sacd_image(&path).unwrap(), + SacdSniff::Sacd, + "{}", + path.display() + ); + } + assert!(!super::is_sacd_image(&write("dvd.iso", None, true))); + assert!(!super::is_sacd_image(&dir.join("missing.iso"))); + + let truncated = write("truncated.iso", Some(510), false); + std::fs::OpenOptions::new() + .append(true) + .open(&truncated) + .unwrap() + .write_all(&[0]) + .unwrap(); + assert!(matches!( + super::sniff_sacd_image(&truncated), + Err(SacdError::InvalidImageLength(_)) + )); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/qbz-disc/src/sacd_raw_tests.rs b/crates/qbz-disc/src/sacd_raw_tests.rs new file mode 100644 index 000000000..24cc90e31 --- /dev/null +++ b/crates/qbz-disc/src/sacd_raw_tests.rs @@ -0,0 +1,285 @@ +//! Synthetic logical/physical copies must expose identical TOCs and audio. +use super::*; +use std::io::Write; +use std::sync::atomic::{AtomicU64, Ordering}; + +const LOGICAL: usize = 2048; +const PHYSICAL: usize = 2064; +const SECTORS: usize = 640; // Also makes the physical file divisible by 2048. + +struct TestImage(PathBuf); + +impl TestImage { + fn write(logical: &[u8], physical: bool) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "qbz-sacd-raw-{}-{}.iso", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + if physical { + for sector in logical.chunks_exact(LOGICAL) { + file.write_all(&[0xa5; 12]).unwrap(); + file.write_all(sector).unwrap(); + file.write_all(&[0x5a; 4]).unwrap(); + } + } else { + file.write_all(logical).unwrap(); + } + Self(path) + } +} + +impl Drop for TestImage { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn payload(frame: usize, dst: bool) -> Vec<u8> { + (0..if dst { 3015 } else { 9408 }) + .map(|i| ((frame * 31 + i) % 251) as u8) + .collect() +} + +fn fixture(dst: bool) -> Vec<u8> { + let mut data = vec![0; SECTORS * LOGICAL]; + let span = if dst { 2 } else { 5 }; + for lsn in [510, 520, 530] { + let master = &mut data[lsn * LOGICAL..(lsn + 1) * LOGICAL]; + master[..8].copy_from_slice(b"SACDMTOC"); + master[8..10].copy_from_slice(&[1, 20]); + master[0x40..0x44].copy_from_slice(&544u32.to_be_bytes()); + master[0x44..0x48].copy_from_slice(&548u32.to_be_bytes()); + master[0x54..0x56].copy_from_slice(&3u16.to_be_bytes()); + } + let mut toc = vec![0; 3 * LOGICAL]; + toc[..8].copy_from_slice(b"TWOCHTOC"); + toc[8..12].copy_from_slice(&[1, 20, 0, 3]); + toc[0x14] = 4; + toc[0x15] = if dst { 0 } else { 2 }; + toc[0x20] = 2; + toc[0x42] = 4; + toc[0x45] = 2; + toc[0x48..0x4c].copy_from_slice(&600u32.to_be_bytes()); + toc[0x4c..0x50].copy_from_slice(&(600u32 + 4 * span as u32 - 1).to_be_bytes()); + toc[LOGICAL..LOGICAL + 8].copy_from_slice(b"SACDTRL1"); + toc[2 * LOGICAL..2 * LOGICAL + 8].copy_from_slice(b"SACDTRL2"); + for track in 0..2 { + let at = LOGICAL + 8 + track * 4; + toc[at..at + 4].copy_from_slice(&(600u32 + (track * 2 * span) as u32).to_be_bytes()); + toc[at + 1020..at + 1024].copy_from_slice(&(2 * span as u32).to_be_bytes()); + toc[at + LOGICAL + 2] = (track * 2) as u8; + toc[at + LOGICAL + 1022] = 2; + } + for lsn in [544, 548] { + data[lsn * LOGICAL..(lsn + 3) * LOGICAL].copy_from_slice(&toc); + } + for frame in 0..4 { + let audio = payload(frame, dst); + let mut used = 0; + for index in 0..span { + let lsn = 600 + frame * span + index; + let sector = &mut data[lsn * LOGICAL..(lsn + 1) * LOGICAL]; + let first = index == 0; + if dst { + sector[0] = if first { 0x25 } else { 0x21 }; + let at = if first { 7 } else { 3 }; + let len = (LOGICAL - at).min(audio.len() - used); + let descriptor = 0x1000 | if first { 0x8000 } else { 0 } | len as u16; + sector[1..3].copy_from_slice(&descriptor.to_be_bytes()); + if first { + sector[5] = frame as u8; + sector[6] = 2 << 2; + } + sector[at..at + len].copy_from_slice(&audio[used..used + len]); + used += len; + } else { + sector[0] = if first { 0x44 } else { 0x40 }; + let header = if first { 8 } else { 5 }; + let len = 2016.min(audio.len() - used); + let padding = LOGICAL - header - len; + sector[1..3].copy_from_slice(&(0x1800 | padding as u16).to_be_bytes()); + sector[3..5].copy_from_slice( + &(0x1000 | if first { 0x8000 } else { 0 } | len as u16).to_be_bytes(), + ); + if first { + sector[7] = frame as u8; + } + sector[LOGICAL - len..].copy_from_slice(&audio[used..used + len]); + used += len; + } + } + assert_eq!(used, audio.len()); + } + data +} + +#[test] +fn raw_and_logical_copies_preserve_geometry_frames_and_seeks() { + for dst in [false, true] { + let bytes = fixture(dst); + let logical = TestImage::write(&bytes, false); + let raw = TestImage::write(&bytes, true); + assert_eq!(std::fs::metadata(&raw.0).unwrap().len() % 2048, 0); + let expected = read_area(&logical.0).unwrap(); + let actual = read_area(&raw.0).unwrap(); + assert_eq!(actual.fingerprint(), expected.fingerprint()); + assert_eq!(actual.tracks, expected.tracks); + assert_eq!(sniff_sacd_image(&raw.0).unwrap(), SacdSniff::Sacd); + for (index, track) in actual.tracks.iter().enumerate() { + let mut reader = SacdFrameReader::open(&raw.0, track).unwrap(); + for frame in index * 2..index * 2 + 2 { + let read = reader.next_frame().unwrap().unwrap(); + assert_eq!(read.payload, payload(frame, dst)); + assert_eq!(read.timecode_frame, frame as u32); + } + assert!(reader.next_frame().unwrap().is_none()); + for offset in [1, 0, 2, 1] { + reader.seek_to_fraction(offset, 2).unwrap(); + let read = reader.next_frame().unwrap(); + if offset == 2 { + assert!(read.is_none()); + } else { + assert_eq!( + read.unwrap().payload, + payload(index * 2 + offset as usize, dst) + ); + } + } + } + } +} + +#[test] +fn raw_batch_reads_strip_every_header_and_trailer_and_keep_bounds() { + let bytes = fixture(false); + let raw = TestImage::write(&bytes, true); + let mut reader = SectorImage::open(&raw.0).unwrap(); + for (lsn, count) in [(0, 1), (510, 30), (600, 20), (639, 1), (640, 0)] { + assert_eq!( + reader.read_sectors(lsn as u64, count).unwrap(), + bytes[lsn * LOGICAL..(lsn + count) * LOGICAL] + ); + } + assert!(reader.read_sectors(640, 1).is_err()); + assert!(reader.read_sectors(u64::MAX, 2).is_err()); + let area = read_area(&raw.0).unwrap(); + let mut legacy = SacdTrackReader::open(&raw.0, &area.tracks[0]).unwrap(); + let mut audio = Vec::new(); + legacy.next_chunk(&mut audio, 200).unwrap(); + assert_eq!(audio, [payload(0, false), payload(1, false)].concat()); +} + +#[test] +fn raw_layout_can_be_detected_from_only_the_last_master_copy() { + let mut bytes = fixture(false); + for lsn in [510, 520] { + bytes[lsn * LOGICAL..lsn * LOGICAL + 8].fill(0); + } + let raw = TestImage::write(&bytes, true); + assert_eq!(sniff_sacd_image(&raw.0).unwrap(), SacdSniff::Sacd); + assert_eq!(read_area(&raw.0).unwrap().tracks.len(), 2); +} + +#[test] +fn raw_partial_sectors_are_errors_even_when_only_the_trailer_is_missing() { + for removed in [1, 4, 12, PHYSICAL - 1] { + let raw = TestImage::write(&fixture(false), true); + let track = read_area(&raw.0).unwrap().tracks.remove(0); + std::fs::OpenOptions::new() + .write(true) + .open(&raw.0) + .unwrap() + .set_len((SECTORS * PHYSICAL - removed) as u64) + .unwrap(); + assert!(matches!( + sniff_sacd_image(&raw.0), + Err(SacdError::InvalidImageLength(_)) + )); + assert!(matches!( + read_area(&raw.0), + Err(SacdError::InvalidImageLength(_)) + )); + assert!(matches!( + SacdFrameReader::open(&raw.0, &track), + Err(SacdError::InvalidImageLength(_)) + )); + } + let raw = TestImage::write(&fixture(false), true); + std::fs::OpenOptions::new() + .write(true) + .open(&raw.0) + .unwrap() + .set_len((510 * PHYSICAL + 12 + 8) as u64) + .unwrap(); + assert!(matches!( + sniff_sacd_image(&raw.0), + Err(SacdError::InvalidImageLength(_)) + )); +} + +#[test] +fn raw_geometry_cannot_point_into_a_missing_whole_sector() { + let raw = TestImage::write(&fixture(false), true); + std::fs::OpenOptions::new() + .write(true) + .open(&raw.0) + .unwrap() + .set_len((610 * PHYSICAL) as u64) + .unwrap(); + assert!(matches!(read_area(&raw.0), Err(SacdError::MalformedToc(_)))); +} + +#[test] +fn raw_conflicting_area_copies_are_still_rejected() { + let mut bytes = fixture(false); + bytes[550 * LOGICAL + 8 + 1020 + 2] = 3; + let raw = TestImage::write(&bytes, true); + assert!(matches!( + read_area(&raw.0), + Err(SacdError::ConflictingAreaTocs) + )); +} + +#[test] +fn raw_images_cannot_choose_between_two_signed_layouts() { + let raw = TestImage::write(&fixture(false), true); + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(&raw.0) + .unwrap(); + file.seek(SeekFrom::Start(510 * 2048)).unwrap(); + file.write_all(b"SACDMTOC").unwrap(); + assert!(matches!( + sniff_sacd_image(&raw.0), + Err(SacdError::AmbiguousSectorLayout) + )); +} + +#[test] +fn raw_reads_keep_the_changed_file_guard() { + let raw = TestImage::write(&fixture(false), true); + let mut reader = SectorImage::open(&raw.0).unwrap(); + std::fs::OpenOptions::new() + .append(true) + .open(&raw.0) + .unwrap() + .write_all(&[0]) + .unwrap(); + assert!(matches!( + reader.read_sectors(0, 350), + Err(SacdError::ImageChangedDuringRead(_)) + )); +} + +#[test] +fn raw_sized_unsigned_files_are_not_sacd_images() { + let raw = TestImage::write(&vec![0; SECTORS * LOGICAL], true); + assert_eq!(sniff_sacd_image(&raw.0).unwrap(), SacdSniff::NotSacd); +} diff --git a/crates/qbz-disc/src/store.rs b/crates/qbz-disc/src/store.rs new file mode 100644 index 000000000..2a32eecd9 --- /dev/null +++ b/crates/qbz-disc/src/store.rs @@ -0,0 +1,415 @@ +//! Remembering a disc after it has been ejected. +//! +//! A disc is the one medium QBZ can play that leaves nothing behind: no path, +//! no row, no file. Everything the app learns about it — the album, the track +//! titles, WHICH pressing it is, where its cover landed — is thrown away the +//! moment the tray opens, and re-derived from the network the next time. +//! +//! That is fine for names that came from MusicBrainz. It is NOT fine for names +//! that came from the user. A single DiscID can name several pressings (the +//! owner's *Fear Inoculum* answers with four), so the moment there is a button +//! to pick the right one, or a rip wizard that lets you fix a title before it +//! is written, that choice has to outlive the eject. Otherwise correcting a +//! disc is a toy: you fix it, you take it out, you put it back, it is wrong +//! again. +//! +//! So this is a CACHE, not a second source of truth, with one rule that makes +//! it safe: **a row the user edited is never overwritten by an automatic +//! lookup.** `put_auto` refuses to touch an edited row; only `put_user` can +//! replace one. +//! +//! IDENTITY is the TOC fingerprint ([`crate::cdda::Toc::fingerprint`], and the +//! SACD equivalent): pure disc geometry, no network, no privileges, available +//! before anything has been looked up. It names the GEOMETRY, not the +//! pressing — which is exactly the point, because "for this geometry the user +//! chose pressing #2" is the fact worth remembering. +//! +//! Frontend-agnostic (ADR-006) and GLOBAL, not per-user: which record is in +//! the drive does not depend on who is logged in. + +use std::path::PathBuf; +use std::sync::OnceLock; + +use rusqlite::Connection; + +/// One remembered track. Only the fields a human can be wrong about — the +/// geometry is read from the disc every time and is never stored, because a +/// stored start_lsn that disagrees with the disc in the drive is a way to play +/// the wrong audio. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct TrackMemory { + pub number: u32, + pub title: String, + pub artist: String, +} + +/// What is remembered about one disc. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DiscMemory { + pub album: String, + pub album_artist: String, + pub year: Option<u32>, + pub tracks: Vec<TrackMemory>, + /// The pressing this naming came from. Kept so the cover re-resolves to + /// the SAME image rather than to whatever the group answers today, and so + /// a user's choice among several pressings survives. + pub release_id: Option<String>, + pub release_group_id: Option<String>, + /// Where the cover already sits on disk. The artwork cache owns the file; + /// this only remembers which one belongs to this disc, so a re-insert + /// paints immediately instead of waiting on the Cover Art Archive again. + pub cover_path: Option<String>, + /// A human corrected this. Automatic lookups must leave it alone. + pub edited: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("no data directory")] + NoDataDir, + #[error("disc store: {0}")] + Db(#[from] rusqlite::Error), + #[error("disc store: {0}")] + Io(#[from] std::io::Error), + #[error("disc store: {0}")] + Json(#[from] serde_json::Error), +} + +static DB_OVERRIDE: OnceLock<PathBuf> = OnceLock::new(); + +/// Point the store somewhere else. For tests, and for a future portable mode. +/// Only the FIRST call takes effect — a store that moved mid-run would leave +/// half the session's writes in the old file. +pub fn set_db_path(path: PathBuf) { + let _ = DB_OVERRIDE.set(path); +} + +fn db_path() -> Result<PathBuf, StoreError> { + if let Some(p) = DB_OVERRIDE.get() { + return Ok(p.clone()); + } + let dir = dirs::data_local_dir().ok_or(StoreError::NoDataDir)?.join("qbz"); + Ok(dir.join("discs.db")) +} + +fn open() -> Result<Connection, StoreError> { + let path = db_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(&path)?; + migrate(&conn)?; + Ok(conn) +} + +fn migrate(conn: &Connection) -> Result<(), StoreError> { + // WAL for the same reason ADR-002 gives the library: the app opens several + // connections and a reader must never block on the writer. + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS discs ( + fingerprint TEXT PRIMARY KEY, + disc_id TEXT, + album TEXT NOT NULL DEFAULT '', + album_artist TEXT NOT NULL DEFAULT '', + year INTEGER, + release_id TEXT, + release_group_id TEXT, + cover_path TEXT, + edited INTEGER NOT NULL DEFAULT 0, + tracks_json TEXT NOT NULL DEFAULT '[]', + last_seen INTEGER NOT NULL DEFAULT 0 + );", + )?; + Ok(()) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// What this disc is called, if we have met it before. +/// +/// Every failure — no data directory, an unreadable file, a row that will not +/// parse — answers `None`. A disc cache that can refuse to open a session is +/// worse than no disc cache: the names are a convenience and the audio is not. +pub fn get(fingerprint: &str) -> Option<DiscMemory> { + match read(fingerprint) { + Ok(m) => m, + Err(e) => { + log::warn!("[disc-store] read failed: {e}"); + None + } + } +} + +fn read(fingerprint: &str) -> Result<Option<DiscMemory>, StoreError> { + let conn = open()?; + let mut stmt = conn.prepare( + "SELECT album, album_artist, year, release_id, release_group_id, + cover_path, edited, tracks_json + FROM discs WHERE fingerprint = ?1", + )?; + let mut rows = stmt.query([fingerprint])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + let tracks_json: String = row.get(7)?; + let tracks: Vec<TrackMemory> = serde_json::from_str(&tracks_json).unwrap_or_default(); + Ok(Some(DiscMemory { + album: row.get(0)?, + album_artist: row.get(1)?, + year: row.get::<_, Option<i64>>(2)?.and_then(|y| u32::try_from(y).ok()), + release_id: row.get(3)?, + release_group_id: row.get(4)?, + cover_path: row.get(5)?, + edited: row.get::<_, i64>(6)? != 0, + tracks, + })) +} + +/// Remember what a LOOKUP found. Refuses to touch a row a human has corrected +/// — that is the whole contract, and it is enforced here rather than at the +/// call sites so a future third lookup path cannot forget it. +/// +/// Returns whether anything was written. +pub fn put_auto(fingerprint: &str, disc_id: Option<&str>, memory: &DiscMemory) -> bool { + match write(fingerprint, disc_id, memory, false) { + Ok(written) => written, + Err(e) => { + log::warn!("[disc-store] auto write failed: {e}"); + false + } + } +} + +/// Remember what the USER decided. Always wins, and stamps the row as edited +/// so no later lookup can undo it. +pub fn put_user(fingerprint: &str, disc_id: Option<&str>, memory: &DiscMemory) -> bool { + match write(fingerprint, disc_id, memory, true) { + Ok(written) => written, + Err(e) => { + log::warn!("[disc-store] user write failed: {e}"); + false + } + } +} + +fn write( + fingerprint: &str, + disc_id: Option<&str>, + memory: &DiscMemory, + by_user: bool, +) -> Result<bool, StoreError> { + if fingerprint.is_empty() { + return Ok(false); + } + let conn = open()?; + if !by_user { + let edited: Option<i64> = conn + .query_row( + "SELECT edited FROM discs WHERE fingerprint = ?1", + [fingerprint], + |r| r.get(0), + ) + .ok(); + if edited == Some(1) { + return Ok(false); + } + } + let tracks_json = serde_json::to_string(&memory.tracks)?; + conn.execute( + "INSERT INTO discs + (fingerprint, disc_id, album, album_artist, year, release_id, + release_group_id, cover_path, edited, tracks_json, last_seen) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(fingerprint) DO UPDATE SET + disc_id = excluded.disc_id, + album = excluded.album, + album_artist = excluded.album_artist, + year = excluded.year, + release_id = excluded.release_id, + release_group_id = excluded.release_group_id, + -- A write that carries no cover must not ERASE the one already + -- remembered: the naming and the artwork arrive on different + -- clocks, and the metadata answer is usually first. + cover_path = COALESCE(excluded.cover_path, discs.cover_path), + edited = excluded.edited, + tracks_json = excluded.tracks_json, + last_seen = excluded.last_seen", + rusqlite::params![ + fingerprint, + disc_id, + memory.album, + memory.album_artist, + memory.year.map(|y| y as i64), + memory.release_id, + memory.release_group_id, + memory.cover_path, + i64::from(by_user || memory.edited), + tracks_json, + now_secs(), + ], + )?; + Ok(true) +} + +/// Attach a resolved cover to a remembered disc, creating the row if the +/// naming has not landed yet. Separate from [`put_auto`] because the cover +/// resolves on its own clock — seconds after the titles, from a different +/// service — and making the caller re-send the whole naming to record one path +/// is how the naming gets clobbered by a stale copy. +pub fn set_cover(fingerprint: &str, cover_path: &str) { + if fingerprint.is_empty() || cover_path.is_empty() { + return; + } + if let Err(e) = write_cover(fingerprint, cover_path) { + log::warn!("[disc-store] cover write failed: {e}"); + } +} + +fn write_cover(fingerprint: &str, cover_path: &str) -> Result<(), StoreError> { + let conn = open()?; + conn.execute( + "INSERT INTO discs (fingerprint, cover_path, last_seen) + VALUES (?1, ?2, ?3) + ON CONFLICT(fingerprint) DO UPDATE SET + cover_path = excluded.cover_path, + last_seen = excluded.last_seen", + rusqlite::params![fingerprint, cover_path, now_secs()], + )?; + Ok(()) +} + +/// Forget one disc. The escape hatch for a row whose correction turned out +/// wrong — without it, a bad `put_user` is permanent. +pub fn forget(fingerprint: &str) { + let Ok(conn) = open() else { return }; + let _ = conn.execute("DELETE FROM discs WHERE fingerprint = ?1", [fingerprint]); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One process, one `OnceLock`, so every test in this module shares the + /// same temp database. They use DIFFERENT fingerprints instead of trying + /// to isolate the file. + fn init() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let dir = std::env::temp_dir().join(format!("qbz-disc-store-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + set_db_path(dir.join("discs.db")); + }); + } + + fn memory(album: &str) -> DiscMemory { + DiscMemory { + album: album.to_string(), + album_artist: "Tool".to_string(), + year: Some(2019), + tracks: vec![TrackMemory { + number: 1, + title: "Fear Inoculum".to_string(), + artist: "Tool".to_string(), + }], + release_id: Some("997490e6".to_string()), + release_group_id: Some("76203bd0".to_string()), + cover_path: None, + edited: false, + } + } + + #[test] + fn a_disc_that_was_never_seen_is_none() { + init(); + assert!(get("never-seen-fingerprint").is_none()); + } + + #[test] + fn what_a_lookup_found_comes_back() { + init(); + assert!(put_auto("fp-roundtrip", Some("disc-1"), &memory("Fear Inoculum"))); + let got = get("fp-roundtrip").expect("remembered"); + assert_eq!(got.album, "Fear Inoculum"); + assert_eq!(got.year, Some(2019)); + assert_eq!(got.tracks.len(), 1); + assert_eq!(got.tracks[0].title, "Fear Inoculum"); + assert!(!got.edited); + } + + /// THE rule this store exists for. + #[test] + fn an_automatic_lookup_never_overwrites_a_human() { + init(); + let mut mine = memory("The Right Pressing"); + mine.release_id = Some("5c027aef".to_string()); + assert!(put_user("fp-edited", Some("disc-2"), &mine)); + + // MusicBrainz answers again tomorrow with its own first pick. + let written = put_auto("fp-edited", Some("disc-2"), &memory("The Wrong Pressing")); + assert!(!written, "an auto lookup must refuse to touch an edited row"); + + let got = get("fp-edited").expect("remembered"); + assert_eq!(got.album, "The Right Pressing"); + assert_eq!(got.release_id.as_deref(), Some("5c027aef")); + assert!(got.edited); + } + + #[test] + fn a_human_can_correct_an_automatic_row() { + init(); + assert!(put_auto("fp-upgrade", None, &memory("Auto Name"))); + assert!(put_user("fp-upgrade", None, &memory("Human Name"))); + let got = get("fp-upgrade").expect("remembered"); + assert_eq!(got.album, "Human Name"); + assert!(got.edited); + } + + /// The naming and the cover arrive on different clocks; neither may erase + /// the other. + #[test] + fn the_cover_and_the_naming_do_not_clobber_each_other() { + init(); + set_cover("fp-cover", "/cache/art/cd-x.jpg"); + assert_eq!( + get("fp-cover").unwrap().cover_path.as_deref(), + Some("/cache/art/cd-x.jpg") + ); + + // A naming write with no cover in hand must leave the cover alone. + assert!(put_auto("fp-cover", None, &memory("Late Naming"))); + let got = get("fp-cover").expect("remembered"); + assert_eq!(got.album, "Late Naming"); + assert_eq!(got.cover_path.as_deref(), Some("/cache/art/cd-x.jpg")); + + // And a cover landing after the naming must leave the naming alone. + set_cover("fp-cover", "/cache/art/cd-y.jpg"); + let got = get("fp-cover").expect("remembered"); + assert_eq!(got.album, "Late Naming"); + assert_eq!(got.cover_path.as_deref(), Some("/cache/art/cd-y.jpg")); + } + + #[test] + fn forgetting_a_bad_correction_is_possible() { + init(); + assert!(put_user("fp-forget", None, &memory("Wrong"))); + forget("fp-forget"); + assert!(get("fp-forget").is_none()); + // And the row is genuinely gone, not just flagged: an auto lookup can + // write again. + assert!(put_auto("fp-forget", None, &memory("Auto"))); + assert_eq!(get("fp-forget").unwrap().album, "Auto"); + } + + #[test] + fn an_empty_fingerprint_is_refused_rather_than_stored_under_one_key() { + init(); + assert!(!put_auto("", None, &memory("Nameless"))); + assert!(get("").is_none()); + } +} diff --git a/crates/qbz-disc/src/toc.rs b/crates/qbz-disc/src/toc.rs new file mode 100644 index 000000000..7d1494522 --- /dev/null +++ b/crates/qbz-disc/src/toc.rs @@ -0,0 +1,99 @@ +//! What a CD's table of contents IS, independent of how it was read. +//! +//! Linux asks the kernel through ioctls; macOS mounts the disc and leaves a +//! `.TOC.plist` next to the audio files. The two reads share nothing, and the +//! ANSWER has to be identical or the disc store, the MusicBrainz lookup and +//! the rip log would need a second version each. +//! +//! MEASURED, both platforms, the owner's Tool — *Fear Inoculum* (2026-08-21): +//! the Linux ioctl and the macOS plist (minus its 150-sector lead-in) produce +//! byte-identical answers — DiscID `BeNBMsD8Du5NO2W61Yk.B2jwwIs-` and +//! fingerprint `38bef21351f7fca3` from both. That equality is the whole reason +//! these types live here instead of inside one platform's reader. + +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum CdError { + #[error("no optical drive found")] + NoDrive, + #[error("cannot open {0}: {1}")] + Open(PathBuf, std::io::Error), + #[error("no disc in the drive")] + NoDisc, + #[error("the disc has no audio tracks")] + NotAudio, + #[error("the drive refused to read audio at sector {lsn}: {source}")] + Read { + lsn: u32, + #[source] + source: std::io::Error, + }, + #[error("reading the table of contents failed: {0}")] + Toc(std::io::Error), +} + +/// One entry of the disc's table of contents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TocTrack { + pub number: u8, + pub start_lsn: u32, + /// Sectors up to the next track (or the lead-out for the last one). + pub sectors: u32, + /// False for the data track of a mixed-mode disc. + pub is_audio: bool, +} + +impl TocTrack { + pub fn duration_secs(&self) -> u64 { + crate::sectors_to_secs(self.sectors) + } + /// PCM frames (sample pairs) this track decodes to. + pub fn frames(&self) -> u64 { + self.sectors as u64 * 588 + } +} + +#[derive(Debug, Clone)] +pub struct Toc { + pub device: PathBuf, + pub tracks: Vec<TocTrack>, + pub leadout_lsn: u32, +} + +impl Toc { + /// The AUDIO tracks, in disc order. A caller that wants to play something + /// should use this and never `tracks` — the data track of an enhanced CD + /// lives in the latter. + pub fn audio_tracks(&self) -> impl Iterator<Item = &TocTrack> { + self.tracks.iter().filter(|t| t.is_audio) + } + + /// A stable identity for the MEDIUM, not the drive. + /// + /// `/dev/sr0` names a piece of hardware; two different discs in it are two + /// different sessions, and a persisted queue that resolves against the + /// drive would happily play track 3 of whatever is loaded now. The digest + /// is over every track's start and length plus the lead-out, which is what + /// a disc actually is. (The disc's own MCN would be tidier — the owner's + /// Fear Inoculum reports all zeros, so it is not dependable.) + pub fn fingerprint(&self) -> String { + // FNV-1a: no dependency, and this only has to be stable and + // collision-resistant enough to tell two albums apart. + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut eat = |v: u32| { + for b in v.to_le_bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x1000_0000_01b3); + } + }; + for t in &self.tracks { + eat(t.start_lsn); + eat(t.sectors); + eat(t.number as u32); + } + eat(self.leadout_lsn); + format!("{h:016x}") + } +} + diff --git a/crates/qbz-dsd/Cargo.toml b/crates/qbz-dsd/Cargo.toml index 65b9a4b8c..be5ab9cf2 100644 --- a/crates/qbz-dsd/Cargo.toml +++ b/crates/qbz-dsd/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true description = "DSD (DSF/DFF) demuxing and DSD-to-PCM conversion" [dependencies] +qbz-disc = { path = "../qbz-disc" } thiserror = { workspace = true } log = { workspace = true } id3 = "1" +dst-decoder = "=0.1.2" diff --git a/crates/qbz-dsd/examples/sacd_decode_dump.rs b/crates/qbz-dsd/examples/sacd_decode_dump.rs new file mode 100644 index 000000000..61259e75d --- /dev/null +++ b/crates/qbz-dsd/examples/sacd_decode_dump.rs @@ -0,0 +1,67 @@ +//! Decode a SACD track with the REAL converter under each candidate layout, +//! with and without frame sync, and dump interleaved f32 for analysis. +//! +//! This exists because the previous two instruments could not falsify +//! anything: a "where does the energy land" ratio and a "how much does the +//! 64-bit local mean swing" statistic both answered the same for scrambled and +//! clean bits. Music and noise are told apart by the SHAPE of the audible +//! spectrum, so this dumps real PCM and lets a spectrum analyser decide. +//! +//! Run: cargo run --release -p qbz-dsd --example sacd_decode_dump -- \ +//! <image.iso> <track> <seconds> <out-dir> + +use qbz_dsd::{ChannelLayout, DsdPcmConverter, SacdDemuxer}; + +fn main() { + let a: Vec<String> = std::env::args().skip(1).collect(); + if a.len() < 4 { + eprintln!("usage: sacd_decode_dump <image.iso> <track> <seconds> <out-dir>"); + std::process::exit(2); + } + let path = std::path::PathBuf::from(&a[0]); + let track_no: u8 = a[1].parse().expect("track"); + let secs: f64 = a[2].parse().expect("seconds"); + let out_dir = std::path::PathBuf::from(&a[3]); + std::fs::create_dir_all(&out_dir).expect("out dir"); + + let area = qbz_disc::sacd::read_area(&path).expect("read_area"); + let track = area + .tracks + .iter() + .find(|t| t.number == track_no) + .expect("track"); + + for layout in [ChannelLayout::BlockPerChannel, ChannelLayout::ByteInterleaved] { + for sync in [false, true] { + let demux = SacdDemuxer::open_with(&path, track, layout, sync).expect("open"); + let mut conv = DsdPcmConverter::new(Box::new(demux), 0.0).expect("converter"); + let rate = conv.output_rate(); + let ch = conv.channels() as usize; + let want = (rate as f64 * secs) as usize * ch; + let mut pcm: Vec<f32> = Vec::with_capacity(want); + while pcm.len() < want { + match conv.next_block() { + Ok(Some(block)) => pcm.extend_from_slice(&block), + Ok(None) => break, + Err(e) => { + eprintln!("decode failed: {e}"); + break; + } + } + } + pcm.truncate(want); + let name = format!( + "t{track_no}-{}-{}.f32", + match layout { + ChannelLayout::BlockPerChannel => "block", + ChannelLayout::ByteInterleaved => "interleaved", + }, + if sync { "synced" } else { "unsynced" } + ); + let file = out_dir.join(&name); + let bytes: Vec<u8> = pcm.iter().flat_map(|s| s.to_le_bytes()).collect(); + std::fs::write(&file, &bytes).expect("write"); + println!("{name}: {} samples @ {rate} Hz, {ch} ch", pcm.len() / ch); + } + } +} diff --git a/crates/qbz-dsd/examples/sacd_layout.rs b/crates/qbz-dsd/examples/sacd_layout.rs new file mode 100644 index 000000000..2d4872463 --- /dev/null +++ b/crates/qbz-dsd/examples/sacd_layout.rs @@ -0,0 +1,50 @@ +//! Settle the SACD channel layout with the REAL converter, and leave a WAV of +//! each hypothesis for a human to judge. +//! `cargo run -p qbz-dsd --example sacd_layout -- <disc.iso> <track#> <outdir>` +fn main() { + let mut a = std::env::args().skip(1); + let iso = std::path::PathBuf::from(a.next().expect("iso")); + let tno: u8 = a.next().expect("track").parse().unwrap(); + let outdir = std::path::PathBuf::from(a.next().unwrap_or_else(|| ".".into())); + + let area = qbz_disc::sacd::read_area(&iso).expect("area"); + let track = area.tracks.iter().find(|t| t.number == tno).expect("track"); + println!("track {}: {} ({:.1}s)", track.number, + track.title.as_deref().unwrap_or("—"), track.duration_secs); + + for (name, layout) in [ + ("A_block", qbz_dsd::ChannelLayout::BlockPerChannel), + ("B_interleaved", qbz_dsd::ChannelLayout::ByteInterleaved), + ] { + let demux = qbz_dsd::SacdDemuxer::open(&iso, track, layout).expect("demux"); + let mut conv = qbz_dsd::DsdPcmConverter::new(Box::new(demux), qbz_dsd::DEFAULT_GAIN_DB) + .expect("converter"); + let ch = conv.channels(); + let rate = conv.output_rate(); + // ~12 seconds is plenty to hear an orchestra or to hear noise. + let want = (rate as u64 * 12) as usize; + let mut pcm: Vec<f32> = Vec::new(); + while pcm.len() < want * ch as usize { + match conv.next_block() { + Ok(Some(f)) => pcm.extend_from_slice(&f), + _ => break, + } + } + let frames = (pcm.len() / ch as usize) as u64; + // What a human would call "is there music here". + let peak = pcm.iter().fold(0f32, |m, s| m.max(s.abs())); + let rms = (pcm.iter().map(|s| s * s).sum::<f32>() / pcm.len().max(1) as f32).sqrt(); + // Adjacent-sample difference vs signal energy: real audio at 88.2 kHz + // moves slowly between samples; scrambled bits do not. + let d: f32 = pcm.windows(2).map(|w| (w[1] - w[0]).powi(2)).sum(); + let e: f32 = pcm.iter().map(|s| s * s).sum(); + println!("{name:16} {frames} frames peak {peak:.4} rms {rms:.5} roughness {:.4}", + if e > 0.0 { d / e } else { 0.0 }); + + let path = outdir.join(format!("sacd_{name}.wav")); + let mut out = qbz_dsd::wav_header(frames, ch, rate); + qbz_dsd::frames_to_pcm24(&pcm, &mut out); + std::fs::write(&path, out).expect("write"); + println!(" -> {}", path.display()); + } +} diff --git a/crates/qbz-dsd/examples/sacd_verify.rs b/crates/qbz-dsd/examples/sacd_verify.rs new file mode 100644 index 000000000..00b6dd37b --- /dev/null +++ b/crates/qbz-dsd/examples/sacd_verify.rs @@ -0,0 +1,179 @@ +//! Decode complete SACD tracks, check their exact DSD64 byte count and print +//! deterministic per-channel hashes without writing the copyrighted audio. +//! +//! `cargo run --release -p qbz-dsd --example sacd_verify -- <image.iso> [track]` + +use qbz_dsd::{DsdDemuxer, SacdDemuxer}; + +const BYTES_PER_CHANNEL_FRAME: u64 = 4_704; +const BITS_PER_CHANNEL_FRAME: u64 = BYTES_PER_CHANNEL_FRAME * 8; +const READ_FRAMES: usize = 64; +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let mut args = std::env::args().skip(1); + let image = std::path::PathBuf::from( + args.next() + .ok_or("usage: sacd_verify <image.iso> [track]")?, + ); + let selected = args.next().map(|value| value.parse::<u8>()).transpose()?; + if args.next().is_some() { + return Err("usage: sacd_verify <image.iso> [track]".into()); + } + + let area = qbz_disc::sacd::read_area(&image)?; + let tracks = area + .tracks + .iter() + .filter(|track| selected.is_none_or(|number| track.number == number)) + .collect::<Vec<_>>(); + if tracks.is_empty() { + return Err(format!("track {} does not exist", selected.unwrap_or_default()).into()); + } + + for track in tracks { + let mut demux = SacdDemuxer::open_default(&image, track)?; + let expected = u64::from(track.duration_frames) * BYTES_PER_CHANNEL_FRAME; + let midpoint_frame = u64::from(track.duration_frames / 2); + let suffix_start = midpoint_frame * BYTES_PER_CHANNEL_FRAME; + let mut total = 0u64; + let mut hashes = [FNV_OFFSET; 2]; + let mut suffix_hashes = [FNV_OFFSET; 2]; + let mut first_frame_hashes = None; + let mut last_frame_hashes = [FNV_OFFSET; 2]; + let mut output = vec![Vec::new(), Vec::new()]; + loop { + output.iter_mut().for_each(Vec::clear); + let read = + demux.read_planar(&mut output, READ_FRAMES * BYTES_PER_CHANNEL_FRAME as usize)?; + if read == 0 { + break; + } + if output.iter().any(|channel| channel.len() != read) { + return Err( + format!("track {} returned unequal channel lengths", track.number).into(), + ); + } + total = total + .checked_add(read as u64) + .ok_or("decoded byte count overflow")?; + let suffix_offset = suffix_start.saturating_sub(total - read as u64) as usize; + for (channel_index, ((hash, suffix_hash), channel)) in hashes + .iter_mut() + .zip(&mut suffix_hashes) + .zip(&output) + .enumerate() + { + *hash = hash_bytes(*hash, channel); + if suffix_offset < channel.len() { + *suffix_hash = hash_bytes(*suffix_hash, &channel[suffix_offset..]); + } + last_frame_hashes[channel_index] = hash_bytes( + FNV_OFFSET, + &channel[channel.len() - BYTES_PER_CHANNEL_FRAME as usize..], + ); + } + if first_frame_hashes.is_none() { + first_frame_hashes = Some([ + hash_bytes(FNV_OFFSET, &output[0][..BYTES_PER_CHANNEL_FRAME as usize]), + hash_bytes(FNV_OFFSET, &output[1][..BYTES_PER_CHANNEL_FRAME as usize]), + ]); + } + } + if total != expected { + return Err(format!( + "track {} decoded {total} bytes/channel, expected {expected}", + track.number + ) + .into()); + } + println!( + "track {:>3}: {total} bytes/channel fnv1a64={:016x}/{:016x}", + track.number, hashes[0], hashes[1] + ); + + // A selected-track run also proves that the seek estimate reacquires + // the exact timecoded frame even when DST compression varies across + // the track. Compare the suffix to a fresh decoder, which exercises + // decoder reset as well as container positioning. + if selected.is_some() { + let mut seeked = SacdDemuxer::open_default(&image, track)?; + seeked.seek_to_bit(midpoint_frame * BITS_PER_CHANNEL_FRAME)?; + let mut seek_total = 0u64; + let mut seek_hashes = [FNV_OFFSET; 2]; + loop { + output.iter_mut().for_each(Vec::clear); + let read = seeked + .read_planar(&mut output, READ_FRAMES * BYTES_PER_CHANNEL_FRAME as usize)?; + if read == 0 { + break; + } + seek_total += read as u64; + for (hash, channel) in seek_hashes.iter_mut().zip(&output) { + *hash = hash_bytes(*hash, channel); + } + } + if seek_total != expected - suffix_start || seek_hashes != suffix_hashes { + return Err(format!( + "track {} midpoint seek disagrees with its linear suffix: bytes={seek_total}/{} hash={:016x}/{:016x} expected={:016x}/{:016x}", + track.number, + expected - suffix_start, + seek_hashes[0], + seek_hashes[1], + suffix_hashes[0], + suffix_hashes[1], + ) + .into()); + } + println!(" midpoint seek verified at frame {midpoint_frame}"); + + let last_frame = u64::from(track.duration_frames) + .checked_sub(1) + .ok_or("zero-duration track")?; + seeked.seek_to_bit(last_frame * BITS_PER_CHANNEL_FRAME)?; + output.iter_mut().for_each(Vec::clear); + let read = seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)?; + let last_seek_hashes = [ + hash_bytes(FNV_OFFSET, &output[0]), + hash_bytes(FNV_OFFSET, &output[1]), + ]; + if read as u64 != BYTES_PER_CHANNEL_FRAME || last_seek_hashes != last_frame_hashes { + return Err(format!("track {} last-frame seek disagrees", track.number).into()); + } + output.iter_mut().for_each(Vec::clear); + if seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)? != 0 { + return Err(format!("track {} emitted data past EOF", track.number).into()); + } + + seeked.seek_to_bit(expected * 8)?; + output.iter_mut().for_each(Vec::clear); + if seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)? != 0 { + return Err(format!("track {} EOF seek emitted data", track.number).into()); + } + + seeked.seek_to_bit(0)?; + output.iter_mut().for_each(Vec::clear); + let read = seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)?; + let start_seek_hashes = [ + hash_bytes(FNV_OFFSET, &output[0]), + hash_bytes(FNV_OFFSET, &output[1]), + ]; + if read as u64 != BYTES_PER_CHANNEL_FRAME + || Some(start_seek_hashes) != first_frame_hashes + { + return Err(format!("track {} repeated start seek disagrees", track.number).into()); + } + println!(" last-frame, EOF and repeated-start seeks verified"); + } + } + Ok(()) +} diff --git a/crates/qbz-dsd/src/demux.rs b/crates/qbz-dsd/src/demux.rs index d0f036c3f..514110a1c 100644 --- a/crates/qbz-dsd/src/demux.rs +++ b/crates/qbz-dsd/src/demux.rs @@ -73,6 +73,11 @@ impl DsdStreamInfo { pub trait DsdDemuxer: Send { fn info(&self) -> &DsdStreamInfo; + /// Reposition to a DSD bit offset per channel. Container alignment may + /// round the request down by less than one byte/frame; UI seeks are whole + /// seconds and therefore land exactly on byte boundaries at every + /// supported DSD rate. + fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError>; /// Append up to `max_bytes_per_ch` DSD bytes per channel to `out[ch]` /// (planar). Returns the byte count appended to EACH channel (always /// equal across channels); 0 means end of stream. @@ -85,8 +90,30 @@ pub trait DsdDemuxer: Send { const VALID_RATES: [u32; 4] = [2_822_400, 5_644_800, 11_289_600, 22_579_200]; -/// Open a DSD file, sniffing DSF vs DFF from the leading magic. +/// Open a DSD source: a DSF or DFF file, or a track inside a SACD image. +/// +/// The SACD arm is answered FIRST, before `File::open`. A `sacd:` reference is +/// not a filesystem path — opening it would fail, and sniffing DSF magic at +/// its first byte is meaningless. Routing here rather than at every call site +/// is what lets the PCM, DoP, native and gapless paths inherit disc images +/// without one line changing in the player: all six of them already call this. pub fn open_dsd(path: &Path) -> Result<Box<dyn DsdDemuxer>, DsdError> { + let as_str = path.to_string_lossy(); + if qbz_disc::SacdRef::is_sacd_path(&as_str) { + let r = qbz_disc::SacdRef::parse(&as_str) + .ok_or_else(|| DsdError::Corrupt(format!("malformed sacd reference: {as_str}")))?; + let area = qbz_disc::sacd::read_area(&r.image) + .map_err(|e| DsdError::Corrupt(e.to_string()))?; + let track = area + .tracks + .iter() + .find(|t| t.number == r.track) + .ok_or_else(|| DsdError::Corrupt(format!("no track {} on this image", r.track)))?; + return Ok(Box::new(crate::sacd_source::SacdDemuxer::open_default( + &r.image, track, + )?)); + } + let mut file = File::open(path)?; let mut magic = [0u8; 4]; file.read_exact(&mut magic)?; @@ -162,8 +189,12 @@ struct DsfReader { file: File, info: DsdStreamInfo, block_size: usize, + data_start: u64, + total_bytes_per_ch: u64, /// Valid (non-padding) DSD bytes remaining per channel. remaining_per_ch: u64, + /// Bytes to discard from each channel's first physical block after seek. + first_block_skip: usize, } impl DsfReader { @@ -239,10 +270,14 @@ impl DsfReader { DsdTags::default() }; + let total_bytes_per_ch = sample_count.div_ceil(8); Ok(Self { file, block_size: block_size as usize, - remaining_per_ch: sample_count.div_ceil(8), + data_start, + total_bytes_per_ch, + remaining_per_ch: total_bytes_per_ch, + first_block_skip: 0, info: DsdStreamInfo { dsd_rate: sampling_frequency, channels: channel_num as u16, @@ -259,6 +294,18 @@ impl DsdDemuxer for DsfReader { &self.info } + fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> { + let target_byte = (bit_per_channel / 8).min(self.total_bytes_per_ch); + let block = target_byte / self.block_size as u64; + self.first_block_skip = (target_byte % self.block_size as u64) as usize; + let physical = self.data_start.saturating_add( + block.saturating_mul(self.block_size as u64 * self.info.channels as u64), + ); + self.file.seek(SeekFrom::Start(physical))?; + self.remaining_per_ch = self.total_bytes_per_ch - target_byte; + Ok(()) + } + fn read_planar( &mut self, out: &mut [Vec<u8>], @@ -272,10 +319,13 @@ impl DsdDemuxer for DsfReader { let mut block = vec![0u8; self.block_size]; while appended < max_bytes_per_ch && self.remaining_per_ch > 0 { // One block group: block_size bytes for each channel in order. - let valid = (self.remaining_per_ch as usize).min(self.block_size); + let available = self.block_size - self.first_block_skip; + let valid = (self.remaining_per_ch as usize).min(available); for ch in 0..self.info.channels as usize { match self.file.read_exact(&mut block) { - Ok(()) => out[ch].extend_from_slice(&block[..valid]), + Ok(()) => out[ch].extend_from_slice( + &block[self.first_block_skip..self.first_block_skip + valid], + ), Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { // Truncated file: stop at what we got. self.remaining_per_ch = 0; @@ -284,6 +334,7 @@ impl DsdDemuxer for DsfReader { Err(e) => return Err(e.into()), } } + self.first_block_skip = 0; self.remaining_per_ch -= valid as u64; appended += valid; } @@ -298,6 +349,8 @@ impl DsdDemuxer for DsfReader { struct DffReader { file: File, info: DsdStreamInfo, + data_offset: u64, + data_size: u64, /// Bytes (all channels interleaved) remaining in the DSD data chunk. remaining_total: u64, } @@ -403,6 +456,8 @@ impl DffReader { Ok(Self { file, + data_offset, + data_size, remaining_total: data_size, info: DsdStreamInfo { dsd_rate, @@ -420,6 +475,17 @@ impl DsdDemuxer for DffReader { &self.info } + fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> { + let channels = self.info.channels as u64; + let total_per_channel = self.data_size / channels; + let target_per_channel = (bit_per_channel / 8).min(total_per_channel); + let target_total = target_per_channel.saturating_mul(channels); + self.file + .seek(SeekFrom::Start(self.data_offset + target_total))?; + self.remaining_total = self.data_size - target_total; + Ok(()) + } + fn read_planar( &mut self, out: &mut [Vec<u8>], diff --git a/crates/qbz-dsd/src/dop.rs b/crates/qbz-dsd/src/dop.rs index f688a750a..a1ee5e00f 100644 --- a/crates/qbz-dsd/src/dop.rs +++ b/crates/qbz-dsd/src/dop.rs @@ -218,6 +218,10 @@ mod io_error_tests { fn info(&self) -> &DsdStreamInfo { &self.info } + fn seek_to_bit(&mut self, _bit_per_channel: u64) -> Result<(), DsdError> { + self.left = 1; + Ok(()) + } fn read_planar( &mut self, out: &mut [Vec<u8>], diff --git a/crates/qbz-dsd/src/lib.rs b/crates/qbz-dsd/src/lib.rs index 089dc6acf..8ca2ec5ae 100644 --- a/crates/qbz-dsd/src/lib.rs +++ b/crates/qbz-dsd/src/lib.rs @@ -13,12 +13,14 @@ mod demux; mod dop; mod dsd2pcm; mod native; +mod sacd_source; mod wav; pub use convert::{DsdPcmConverter, DEFAULT_GAIN_DB, OUTPUT_RATE}; pub use demux::{open_dsd, DsdDemuxer, DsdError, DsdStreamInfo, DsdTags}; pub use dop::{dop_carrier_rate, DopPacker, DopStream}; pub use native::{native_u32_rate, NativeDsdStream, NATIVE_DSD_SILENCE_U32}; +pub use sacd_source::{ChannelLayout, SacdDemuxer}; pub use wav::{frames_to_pcm24, wav_header, wav_total_size}; /// Common surface of the boxed DSD word streams (DoP and native): an diff --git a/crates/qbz-dsd/src/sacd_source.rs b/crates/qbz-dsd/src/sacd_source.rs new file mode 100644 index 000000000..25101cf32 --- /dev/null +++ b/crates/qbz-dsd/src/sacd_source.rs @@ -0,0 +1,403 @@ +//! A SACD track presented as a [`DsdDemuxer`], so every DSD delivery this +//! player already has — PCM conversion, DoP, native — plays a disc image +//! without knowing what one is. +//! +//! The dependency runs qbz-dsd -> qbz-disc and never the other way: qbz-disc +//! owns bytes and geometry, this file owns the audio contract. + +use dst_decoder::decoder::DstDecoder; +use qbz_disc::sacd::{SacdFrameKind, SacdFrameReader, SacdTrack, SacdTrackReader}; + +use crate::demux::{DsdDemuxer, DsdError, DsdStreamInfo, DsdTags}; + +/// DSD64: the only rate the Scarlet Book stereo area uses. +const DSD64: u32 = 2_822_400; +/// One uncompressed stereo frame, and 1/75 s of audio. +const FRAME: usize = 9408; +/// Bytes ONE channel contributes to a frame. +const FRAME_PER_CH: usize = FRAME / 2; +/// Sectors pulled from the image per legacy diagnostic refill. +const REFILL_SECTORS: usize = 200; + +fn decode_dst_frame( + decoder: &mut DstDecoder, + scratch: &mut [u8], + payload: &[u8], + pending: &mut Vec<u8>, + track: u8, + frame_number: u32, + start_lsn: u64, +) -> Result<(), DsdError> { + scratch.fill(0); + let written = decoder.decode_frame(payload, scratch).map_err(|error| { + DsdError::Corrupt(format!( + "SACD track {track} DST frame {frame_number} at LSN {start_lsn}: {error}" + )) + })?; + if written != FRAME { + return Err(DsdError::Corrupt(format!( + "SACD track {track} DST frame {frame_number} at LSN {start_lsn} decoded {written} bytes, expected {FRAME}" + ))); + } + // Upstream writes 0x55 into `scratch` on Err. Publication is deliberately + // after both Ok and the exact-size check, so synthetic silence cannot + // escape into PCM, DoP or native output. + pending.extend_from_slice(&scratch[..written]); + Ok(()) +} + +/// How the two channels sit inside one frame. +/// +/// Both schemes exist in the wild — DFF interleaves per BYTE, DSF per block — +/// so this was not obvious from first principles and was not left to a guess. +/// +/// IT WAS ANSWERED WRONG ONCE, AND THE WRONG ANSWER SHIPPED. The first +/// measurement converted twelve seconds of track 4 and looked at where the +/// ENERGY landed: +/// +/// BlockPerChannel 77 % of energy in the audible band, 23 % above it +/// ByteInterleaved 99.8 % audible, 0.2 % above +/// +/// and reasoned that a real DSD64 stream must carry a large ultrasonic +/// component, so the layout with one had to be right. **That argument is +/// backwards for this pipeline.** `DsdPcmConverter` low-passes and decimates +/// to 88.2 kHz, so it REMOVES sigma-delta noise shaping by design — a correct +/// decode has nothing above 24 kHz. The 23 % was aliased garbage from bits +/// read out of order, which is to say the measurement was reading the +/// symptom as the proof. Neither does the ratio discriminate: scrambled bits +/// and shaped noise both put energy up high. +/// +/// RE-MEASURED with a statistic that CAN falsify — decode with the real +/// converter, then look at the shape of the audible band and at whether the +/// two channels are a stereo pair at all. Music has a peaky spectrum (low +/// spectral flatness) and two channels of one orchestra are positively +/// correlated; two independent noise streams are flat and uncorrelated. +/// Tracks 1, 4 and 13 of disc 1 and track 5 of disc 2: +/// +/// layout spectral flatness L/R correlation +/// BlockPerChannel 0.038 … 0.107 -0.17 … -0.06 +/// ByteInterleaved 0.0036 … 0.0093 +0.24 … +0.48 +/// +/// A negative correlation between the channels of an orchestral recording is +/// not a subtle hint, and a spectrum 10-20x flatter is noise. The layout is +/// `ByteInterleaved`, on every track of both discs. +/// +/// (Three statistical probes tried before any of this — per-frame level +/// variance, L/R envelope correlation and a crude spectral centroid — came +/// back inconclusive. So did a fourth, which measured how far the 64-bit local +/// mean of the raw bit stream swings: it answers the same for clean and +/// scrambled bits, because the local mean of a byte-level mix of two +/// correlated channels is still roughly the audio. A measurement that cannot +/// come out differently is not a measurement.) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelLayout { + /// `[ch0 4704][ch1 4704]` — one contiguous block per channel per frame. + BlockPerChannel, + /// `ch0 ch1 ch0 ch1 …` one byte at a time. + ByteInterleaved, +} + +pub struct SacdDemuxer { + reader: SacdReader, + info: DsdStreamInfo, + layout: ChannelLayout, + /// Audio payload read but not yet split into channels. Only whole frames + /// are consumed, so a partial frame waits here for the next refill. + pending: Vec<u8>, + dst_decoder: Option<DstDecoder>, + decode_scratch: Vec<u8>, + track_number: u8, + frame_number: u32, + done: bool, +} + +enum SacdReader { + /// Playback: exact TRL2-bounded frames for both flat DSD and DST. + Framed(SacdFrameReader), + /// Kept only for the `sync=false` diagnostic that reproduces the old bug. + Legacy(SacdTrackReader), +} + +impl Default for ChannelLayout { + /// The measured layout. A caller that does not care gets the right one. + fn default() -> Self { + Self::ByteInterleaved + } +} + +impl SacdDemuxer { + /// Open with the measured layout. + pub fn open_default(image: &std::path::Path, track: &SacdTrack) -> Result<Self, DsdError> { + Self::open(image, track, ChannelLayout::default()) + } + + pub fn open( + image: &std::path::Path, + track: &SacdTrack, + layout: ChannelLayout, + ) -> Result<Self, DsdError> { + Self::open_with(image, track, layout, true) + } + + /// The probe's door: `sync = false` reproduces the stream that shipped + /// before the frame boundary was read, which is what lets a measurement + /// show the difference instead of asserting it. + pub fn open_with( + image: &std::path::Path, + track: &SacdTrack, + layout: ChannelLayout, + sync: bool, + ) -> Result<Self, DsdError> { + let reader = if sync { + SacdReader::Framed( + SacdFrameReader::open(image, track) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + ) + } else { + SacdReader::Legacy( + SacdTrackReader::open_with( + image, + track, + qbz_disc::sacd::FrameSync::FromPayloadStart, + ) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + ) + }; + let dst_decoder = if track.encoding == qbz_disc::sacd::SacdEncoding::Dst { + Some( + DstDecoder::new(2, DSD64 as usize) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + ) + } else { + None + }; + Ok(Self { + reader, + info: DsdStreamInfo { + dsd_rate: DSD64, + channels: 2, + // Bits per channel. The area TOC gives a duration, and DSD64 + // is exactly 2 822 400 bits/s, so this is the count rather + // than an estimate. + sample_count: u64::from(track.duration_frames) * u64::from(DSD64 / 75), + // SACD is MSB-first, like DFF and unlike DSF's usual LSB-first. + // Getting this backwards does not fail — it plays, as noise. + lsb_first: false, + tags: DsdTags { + title: track.title.clone(), + track_number: Some(track.number as u32), + ..Default::default() + }, + }, + layout, + pending: Vec::new(), + dst_decoder, + decode_scratch: vec![0u8; FRAME], + track_number: track.number, + frame_number: 0, + done: false, + }) + } + + fn refill(&mut self) -> Result<(), DsdError> { + if self.done { + return Ok(()); + } + match &mut self.reader { + SacdReader::Legacy(reader) => { + let mut chunk = Vec::new(); + reader + .next_chunk(&mut chunk, REFILL_SECTORS) + .map_err(|error| DsdError::Corrupt(error.to_string()))?; + self.pending.extend_from_slice(&chunk); + self.done = reader.finished(); + } + SacdReader::Framed(reader) => match reader + .next_frame() + .map_err(|error| DsdError::Corrupt(error.to_string()))? + { + Some(frame) => { + match frame.kind { + SacdFrameKind::Dsd => { + if frame.payload.len() != FRAME { + return Err(DsdError::Corrupt(format!( + "SACD track {} frame {} at LSN {} produced {} DSD bytes, expected {FRAME}", + self.track_number, + self.frame_number, + frame.start_lsn, + frame.payload.len(), + ))); + } + self.pending.extend_from_slice(&frame.payload); + } + SacdFrameKind::Dst => { + let decoder = self.dst_decoder.as_mut().ok_or_else(|| { + DsdError::Corrupt( + "DST frame reached a stream without a decoder".to_string(), + ) + })?; + decode_dst_frame( + decoder, + &mut self.decode_scratch, + &frame.payload, + &mut self.pending, + self.track_number, + self.frame_number, + frame.start_lsn, + )?; + } + } + self.frame_number = self.frame_number.saturating_add(1); + } + None => self.done = true, + }, + } + Ok(()) + } +} + +impl DsdDemuxer for SacdDemuxer { + fn info(&self) -> &DsdStreamInfo { + &self.info + } + + fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> { + let target = bit_per_channel.min(self.info.sample_count); + match &mut self.reader { + SacdReader::Framed(reader) => reader + .seek_to_fraction(target, self.info.sample_count) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + SacdReader::Legacy(reader) => reader.seek_to_fraction(target, self.info.sample_count), + } + if self.dst_decoder.is_some() { + self.dst_decoder = Some( + DstDecoder::new(2, DSD64 as usize) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + ); + } + self.pending.clear(); + self.frame_number = ((u128::from(target) * 75) / u128::from(DSD64)) as u32; + self.done = match &self.reader { + SacdReader::Framed(reader) => reader.finished(), + SacdReader::Legacy(reader) => reader.finished(), + }; + Ok(()) + } + + fn read_planar( + &mut self, + out: &mut [Vec<u8>], + max_bytes_per_ch: usize, + ) -> Result<usize, DsdError> { + if out.len() < 2 { + return Err(DsdError::UnsupportedChannels(out.len() as u16)); + } + // Work in whole frames: a frame is the unit the channels are split on, + // and splitting a partial one puts the two channels out of step for + // the rest of the track. + let want_frames = (max_bytes_per_ch / FRAME_PER_CH).max(1); + while self.pending.len() < want_frames * FRAME && !self.done { + self.refill()?; + } + let have = (self.pending.len() / FRAME).min(want_frames); + if have == 0 { + return Ok(0); + } + + for i in 0..have { + let frame = &self.pending[i * FRAME..(i + 1) * FRAME]; + match self.layout { + ChannelLayout::BlockPerChannel => { + out[0].extend_from_slice(&frame[..FRAME_PER_CH]); + out[1].extend_from_slice(&frame[FRAME_PER_CH..]); + } + ChannelLayout::ByteInterleaved => { + for pair in frame.chunks_exact(2) { + out[0].push(pair[0]); + out[1].push(pair[1]); + } + } + } + } + self.pending.drain(..have * FRAME); + Ok(have * FRAME_PER_CH) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_frame_splits_into_equal_channel_halves() { + assert_eq!(FRAME, 9408); + assert_eq!(FRAME_PER_CH, 4704); + // Each channel contributes exactly 1/75 s of DSD64: 2822400/75 bits + // = 37632 bits = 4704 bytes. If this ever stops holding, the frame + // size or the rate has changed and the split is wrong. + assert_eq!(FRAME_PER_CH * 8, (DSD64 / 75) as usize); + } + + #[test] + fn the_two_layouts_disagree_which_is_why_one_had_to_be_measured() { + // A deliberately asymmetric frame: the first half all 0xAA, the + // second all 0x55. + let mut frame = vec![0xAAu8; FRAME_PER_CH]; + frame.extend(std::iter::repeat(0x55).take(FRAME_PER_CH)); + + let block0: Vec<u8> = frame[..FRAME_PER_CH].to_vec(); + let inter0: Vec<u8> = frame.chunks_exact(2).map(|p| p[0]).collect(); + assert!(block0.iter().all(|b| *b == 0xAA)); + // Byte-interleaving the same frame gives a channel that is half 0xAA + // and half 0x55 — a completely different signal, which is why picking + // the wrong one is audible rather than subtle. + assert!(inter0.iter().any(|b| *b == 0x55)); + } + + /// THE regression guard for the 2026-08-21 correction. + /// + /// A default is not a detail here: `open_default` is what playback uses, + /// and getting it wrong does not fail — it plays, as noise with the music + /// audible behind it, in every delivery mode at once. The measurement + /// that settled it is in `ChannelLayout`'s own doc; this pins the answer + /// so a future tidy-up cannot quietly put the old one back. + #[test] + fn the_default_layout_is_the_one_that_was_measured() { + assert_eq!(ChannelLayout::default(), ChannelLayout::ByteInterleaved); + } + + /// The split has to be exact in BOTH directions, or one channel silently + /// carries the other's bits. + #[test] + fn byte_interleaving_separates_the_channels_without_losing_a_byte() { + // Even bytes are channel 0, odd bytes channel 1 — build a frame that + // says so unambiguously. + let frame: Vec<u8> = (0..FRAME) + .map(|i| if i % 2 == 0 { 0x11 } else { 0x22 }) + .collect(); + let l: Vec<u8> = frame.chunks_exact(2).map(|p| p[0]).collect(); + let r: Vec<u8> = frame.chunks_exact(2).map(|p| p[1]).collect(); + assert_eq!(l.len(), FRAME_PER_CH); + assert_eq!(r.len(), FRAME_PER_CH); + assert!(l.iter().all(|b| *b == 0x11), "channel 0 is the even bytes"); + assert!(r.iter().all(|b| *b == 0x22), "channel 1 is the odd bytes"); + assert_eq!(l.len() + r.len(), FRAME, "no byte is dropped or doubled"); + } + + #[test] + fn a_corrupt_dst_frame_publishes_none_of_upstreams_synthetic_silence() { + let mut decoder = DstDecoder::new(2, DSD64 as usize).unwrap(); + let mut scratch = vec![0u8; FRAME]; + let mut pending = vec![0x12, 0x34]; + let before = pending.clone(); + + let result = decode_dst_frame(&mut decoder, &mut scratch, &[], &mut pending, 1, 7, 1234); + + assert!(result.is_err()); + assert_eq!(pending, before, "no decoded bytes may be appended on Err"); + assert!( + scratch.iter().all(|byte| *byte == 0x55), + "the test must exercise upstream's error-fill behavior" + ); + } +} diff --git a/crates/qbz-dsd/tests/demux_convert.rs b/crates/qbz-dsd/tests/demux_convert.rs index 538d321f6..6dca5f495 100644 --- a/crates/qbz-dsd/tests/demux_convert.rs +++ b/crates/qbz-dsd/tests/demux_convert.rs @@ -1,7 +1,7 @@ //! Integration tests over synthesized DSF/DFF files. use qbz_dsd::{open_dsd, DsdError, DsdPcmConverter}; -use std::io::Write; +use std::io::{Seek, SeekFrom, Write}; use std::path::PathBuf; fn tmp(name: &str) -> PathBuf { @@ -213,6 +213,49 @@ fn dff_parses_stereo() { assert!(!info.lsb_first); } +#[test] +fn dsf_seek_handles_channel_blocks_and_an_in_block_offset() { + const DATA_START: u64 = 28 + 52 + 12; + const BLOCK: u64 = 4096; + let path = write_dsf("seek-blocks.dsf", 2, 2_822_400, 2, None); + let mut file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + // Second block-group, byte 7 in each channel block. + file.seek(SeekFrom::Start(DATA_START + 2 * BLOCK + 7)) + .unwrap(); + file.write_all(&[0x12]).unwrap(); + file.seek(SeekFrom::Start(DATA_START + 3 * BLOCK + 7)) + .unwrap(); + file.write_all(&[0x34]).unwrap(); + + let mut demux = open_dsd(&path).unwrap(); + demux.seek_to_bit((BLOCK + 7) * 8).unwrap(); + let mut planar = vec![Vec::new(), Vec::new()]; + assert!(demux.read_planar(&mut planar, BLOCK as usize).unwrap() > 0); + assert_eq!(planar[0][0], 0x12); + assert_eq!(planar[1][0], 0x34); +} + +#[test] +fn dff_seek_preserves_interleaved_channel_alignment() { + let path = write_dff("seek-interleaved.dff", 2, 2_822_400, 128, b"DSD "); + let bytes = std::fs::read(&path).unwrap(); + let data_header = bytes + .windows(4) + .rposition(|window| window == b"DSD ") + .unwrap(); + let data_start = data_header as u64 + 12; + let mut file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + file.seek(SeekFrom::Start(data_start + 34)).unwrap(); + file.write_all(&[0x56, 0x78]).unwrap(); + + let mut demux = open_dsd(&path).unwrap(); + demux.seek_to_bit(17 * 8).unwrap(); + let mut planar = vec![Vec::new(), Vec::new()]; + assert_eq!(demux.read_planar(&mut planar, 4).unwrap(), 4); + assert_eq!(planar[0][0], 0x56); + assert_eq!(planar[1][0], 0x78); +} + #[test] fn dff_dst_rejected() { let path = write_dff("dst.dff", 2, 2_822_400, 128, b"DST "); diff --git a/crates/qbz-external-reco/Cargo.toml b/crates/qbz-external-reco/Cargo.toml index f86adde2c..fa709232f 100644 --- a/crates/qbz-external-reco/Cargo.toml +++ b/crates/qbz-external-reco/Cargo.toml @@ -19,7 +19,7 @@ serde = { workspace = true } serde_json = { workspace = true } # Cache (per-user SQLite, WAL per ADR-002) -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } # Logging log = { workspace = true } diff --git a/crates/qbz-external-reco/src/lib.rs b/crates/qbz-external-reco/src/lib.rs index f179d1643..cfa5e582a 100644 --- a/crates/qbz-external-reco/src/lib.rs +++ b/crates/qbz-external-reco/src/lib.rs @@ -28,9 +28,17 @@ use qbz_models::{Album, Artist, Track}; pub use cache::RecoCache; pub use carousels::{compose_artist_rails, ArtistRailComposition, ARTIST_DISPLAY_CAP}; +// Shared catalog text rules. The Library release-replacement picker needs the +// same bracket/edition stripping and similarity semantics as recommendation +// validation; exporting the two pure helpers prevents a third drifting copy. +pub use matching::{normalize as normalize_catalog_name, similarity as catalog_similarity}; +// `validate` stays a private module — only this one helper is public, because +// the unavailable-track replacement flow (qbz-qt) needs the ISRC -> streamable +// -track lookup and nothing else in it. pub use types::{ - AlbumReco, ArtistReco, ExternalCarousels, ExtHistory, LocalHistory, RecoSource, TrackReco, + AlbumReco, ArtistReco, ExtHistory, ExternalCarousels, LocalHistory, RecoSource, TrackReco, }; +pub use validate::find_by_isrc; /// The Qobuz catalog operations the engine needs. Implemented by the frontend /// over its own `QbzCore`. Every method swallows errors to an empty result. diff --git a/crates/qbz-external-reco/src/matching.rs b/crates/qbz-external-reco/src/matching.rs index 6d084215c..3c5f25dd4 100644 --- a/crates/qbz-external-reco/src/matching.rs +++ b/crates/qbz-external-reco/src/matching.rs @@ -5,7 +5,7 @@ //! duration/isrc) against a `qbz_models::Track`. Kept self-contained here so the //! engine has no dependency on the importer crate; the algorithm is identical //! (ISRC short-circuit -> title*0.6 + artist*0.3 + album*0.1 + duration bonus, -//! `!streamable` skipped, hi-res tiebreak). +//! `!is_streamable()` skipped, hi-res tiebreak). use qbz_models::Track; @@ -33,7 +33,10 @@ pub fn select_best_match<'a>(input: &MatchInput, candidates: &'a [Track]) -> (Op let mut best_quality = 0.0f32; for candidate in candidates { - if !candidate.streamable { + // Only an explicit `streamable: false` disqualifies; an endpoint that + // omits the key must not silently empty the candidate set. Kept + // identical to the importer's scorer, which this file mirrors. + if !candidate.is_streamable() { continue; } let score = score_candidate(input, candidate); diff --git a/crates/qbz-external-reco/src/types.rs b/crates/qbz-external-reco/src/types.rs index 3e28a2f0d..ac653fde2 100644 --- a/crates/qbz-external-reco/src/types.rs +++ b/crates/qbz-external-reco/src/types.rs @@ -76,6 +76,11 @@ pub struct AlbumReco { pub artwork_url: String, #[serde(default)] pub subtitle: String, + /// Genre name from the resolving Qobuz album — feeds the card hover + /// overlay. Defaulted for backward compatibility with cached JSON + /// written before the field existed. + #[serde(default)] + pub genre: String, #[serde(default = "default_source")] pub source: RecoSource, } diff --git a/crates/qbz-external-reco/src/validate.rs b/crates/qbz-external-reco/src/validate.rs index e2bc59833..f9fcf4647 100644 --- a/crates/qbz-external-reco/src/validate.rs +++ b/crates/qbz-external-reco/src/validate.rs @@ -95,10 +95,27 @@ fn build_track_reco(track: &Track, source: RecoSource) -> TrackReco { } } -async fn find_by_isrc(catalog: &dyn RecoCatalog, isrc: &str) -> Option<Track> { +/// ISRC -> the streamable Qobuz track carrying it, or `None`. +/// +/// Public because it is the FIRST thing the "find available version" flow tries +/// on a track Qobuz pulled from the catalogue: a dead row keeps its `isrc` (the +/// 2026-08-17 capture of album `0886443985094` confirms it), and licensing churn +/// most often re-publishes the SAME recording under a new track id with the same +/// ISRC. A hit there is an exact relink, and it skips the fuzzy text search and +/// the human judgement entirely. +/// +/// Qobuz has no ISRC endpoint — this is a free-text search FOR the ISRC string +/// plus an exact, case-folded verification of the returned `isrc`, which is why +/// it can answer `None` for an ISRC that does exist. Treat a miss as "no exact +/// relink", never as "the recording is gone". +pub async fn find_by_isrc(catalog: &dyn RecoCatalog, isrc: &str) -> Option<Track> { let results = catalog.search_tracks(isrc, 5).await; results.into_iter().find(|t| { - t.streamable + // `is_streamable()`, not the raw field: a search result that simply did + // not report availability is still a valid relink target, and this + // lookup is the cheapest, most certain path there is (same ISRC = same + // recording under a new id). Only an explicit false is rejected. + t.is_streamable() && t.isrc .as_deref() .map(|c| c.eq_ignore_ascii_case(isrc)) @@ -284,6 +301,11 @@ pub fn build_album_reco(album: &Album, subtitle: String, source: RecoSource) -> quality_label, artwork_url: album.image.best().cloned().unwrap_or_default(), subtitle, + genre: album + .genre + .as_ref() + .map(|g| g.name.clone()) + .unwrap_or_default(), source, } } diff --git a/crates/qbz-i18n/src/lib.rs b/crates/qbz-i18n/src/lib.rs index 2ae545344..fa472db25 100644 --- a/crates/qbz-i18n/src/lib.rs +++ b/crates/qbz-i18n/src/lib.rs @@ -1,8 +1,8 @@ //! qbz-i18n — frontend-agnostic gettext-style translation catalog. //! -//! Reads the same gettext `.po` files Slint bundles, keyed by -//! `msgid = English source string` (no `msgctxt`). Reusable by any frontend -//! (Slint / TUI / headless) — no slint or tauri dependencies (ADR-006). +//! Reads QBZ's gettext `.po` files, keyed by `msgid = English source string` +//! (no `msgctxt`). Reusable by Qt, TUI, or headless consumers, without a +//! frontend dependency (ADR-006). pub mod plural; pub mod po; @@ -16,16 +16,21 @@ use std::sync::OnceLock; /// Supported language codes, indexed by the value stored in [`CURRENT`]. const LANGS: [&str; 8] = ["en", "es", "de", "fr", "pt", "ru", "ja", "nl"]; -/// Embedded `.po` sources. Path is relative to this file -/// (`crates/qbz-i18n/src/lib.rs`): `../` = `qbz-i18n/`, `../../` = `crates/`. -const PO_EN: &str = include_str!("../../qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po"); -const PO_ES: &str = include_str!("../../qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po"); -const PO_DE: &str = include_str!("../../qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po"); -const PO_FR: &str = include_str!("../../qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po"); -const PO_PT: &str = include_str!("../../qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po"); -const PO_RU: &str = include_str!("../../qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po"); -const PO_JA: &str = include_str!("../../qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po"); -const PO_NL: &str = include_str!("../../qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po"); +/// Embedded `.po` sources, OWNED BY THIS CRATE. Path is relative to this file +/// (`crates/qbz-i18n/src/lib.rs`): `../` = `qbz-i18n/`. +/// +/// These catalogs are intentionally owned here so deleting or replacing a +/// frontend cannot break the shared translation crate through hidden +/// `include_str!` paths. The historical `qbz-ui.po` filename is an on-disk +/// catalog name, not a dependency on the retired frontend. +const PO_EN: &str = include_str!("../translations/en/LC_MESSAGES/qbz-ui.po"); +const PO_ES: &str = include_str!("../translations/es/LC_MESSAGES/qbz-ui.po"); +const PO_DE: &str = include_str!("../translations/de/LC_MESSAGES/qbz-ui.po"); +const PO_FR: &str = include_str!("../translations/fr/LC_MESSAGES/qbz-ui.po"); +const PO_PT: &str = include_str!("../translations/pt/LC_MESSAGES/qbz-ui.po"); +const PO_RU: &str = include_str!("../translations/ru/LC_MESSAGES/qbz-ui.po"); +const PO_JA: &str = include_str!("../translations/ja/LC_MESSAGES/qbz-ui.po"); +const PO_NL: &str = include_str!("../translations/nl/LC_MESSAGES/qbz-ui.po"); /// Current language index (0=en, 1=es, 2=de, 3=fr, 4=pt, 5=ru, 6=ja, 7=nl). Defaults to en. static CURRENT: AtomicU8 = AtomicU8::new(0); diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po new file mode 100644 index 000000000..c1e048edb --- /dev/null +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -0,0 +1,6845 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: qbz-slint\n" +"PO-Revision-Date: 2026-09-05 19:52-0600\n" +"Last-Translator: QBZ contributors\n" +"Language-Team: QBZ de translators\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "System Colors" +msgstr "Systemfarben" + +msgid "Wallpaper Sync" +msgstr "Wallpaper Sync" + +msgid "Custom Image" +msgstr "Eigenes Bild" + +msgid "Auto" +msgstr "Automatisch" + +msgid "Disabled" +msgstr "Deaktiviert" + +msgid "Replace current queue" +msgstr "Aktuelle Warteschlange ersetzen" + +msgid "Play next" +msgstr "Als Nächstes abspielen" + +msgid "Add to queue" +msgstr "Zur Warteschlange" + +msgid "Remember last" +msgstr "Letzte merken" + +msgid "Album Reactive" +msgstr "Album-Reaktiv" + +msgid "Static" +msgstr "Statisch" + +msgid "Coverflow" +msgstr "Coverflow" + +msgid "Spectrum" +msgstr "Spektrum" + +msgid "Lyrics" +msgstr "Songtexte" + +msgid "Queue" +msgstr "Warteschlange" + +msgid "Auto (opposite to window controls)" +msgstr "Automatisch (gegenuber den Fenstersteuerungen)" + +msgid "Left" +msgstr "Links" + +msgid "Right" +msgstr "Rechts" + +msgid "Rectangular" +msgstr "Rechteckig" + +msgid "Full-height rounded" +msgstr "Abgerundet (volle Höhe)" + +msgid "Circular" +msgstr "Kreisförmig" + +msgid "Square" +msgstr "Quadratisch" + +msgid "Small" +msgstr "Klein" + +msgid "Normal" +msgstr "Normal" + +msgid "Large" +msgstr "Groß" + +msgid "Default" +msgstr "Standard" + +msgid "Monochrome" +msgstr "Monochrom" + +msgid "Custom" +msgstr "Benutzerdefiniert" + +msgid "Remember last used" +msgstr "Letzte Ansicht merken" + +msgid "Micro" +msgstr "Mikro" + +msgid "Compact" +msgstr "Kompakt" + +msgid "Artwork" +msgstr "Kunstwerk" + +msgid "Home" +msgstr "Start" + +msgid "Where you left off" +msgstr "Wo du aufgehört hast" + +msgid "Mono light" +msgstr "Mono hell" + +msgid "Mono dark" +msgstr "Mono dunkel" + +msgid "Color" +msgstr "Farbe" + +msgid "Vinyl" +msgstr "Vinyl" + +msgid "Visualizer" +msgstr "Visualizer" + +msgid "Neon Flow" +msgstr "Neon-Fluss" + +msgid "Tunnel" +msgstr "Tunnel" + +msgid "Comet" +msgstr "Komet" + +msgid "Split: Lyrics" +msgstr "Geteilt: Liedtexte" + +msgid "Split: Track Info" +msgstr "Geteilt: Titelinfo" + +msgid "Split: Suggestions" +msgstr "Geteilt: Vorschläge" + +msgid "Split: Queue" +msgstr "Geteilt: Warteschlange" + +msgid "Full" +msgstr "Voll" + +msgid "Lite" +msgstr "Leicht" + +msgid "Off" +msgstr "Aus" + +msgid "versions" +msgstr "Versionen" + +msgid "Search tracks..." +msgstr "Titel suchen..." + +msgid "Title" +msgstr "Titel" + +msgid "Quality" +msgstr "Qualität" + +msgid "Disc" +msgstr "Disc" + +msgid "Loading…" +msgstr "Wird geladen…" + +msgid "Failed to load booklet" +msgstr "Booklet konnte nicht geladen werden" + +msgid "Add cover" +msgstr "Cover hinzufügen" + +msgid "Change cover" +msgstr "Cover ändern" + +msgid "Remove cover" +msgstr "Cover entfernen" + +msgid "Open in browser" +msgstr "Im Browser öffnen" + +msgid "Save as…" +msgstr "Speichern unter…" + +msgid "Read more" +msgstr "Mehr anzeigen" + +msgid "Loading album…" +msgstr "Album wird geladen…" + +msgid "Select all" +msgstr "Alle auswählen" + +msgid "Add to playlist" +msgstr "Zur Playlist hinzufügen" + +msgid "Add to Library" +msgstr "Zur Bibliothek hinzufügen" + +msgid "Make available offline" +msgstr "Für offline verfügbar machen" + +msgid "Clear" +msgstr "Löschen" + +msgid "LABEL" +msgstr "LABEL" + +msgid "AWARDS" +msgstr "AUSZEICHNUNGEN" + +msgid "EXTERNAL LINKS" +msgstr "EXTERNE LINKS" + +msgid "About this album" +msgstr "Über dieses Album" + +msgid "Released by" +msgstr "Erschienen bei" + +msgid "on" +msgstr "am" + +msgid "Credits" +msgstr "Credits" + +msgid "Review" +msgstr "Rezension" + +msgid "Failed to load album credits" +msgstr "Album-Credits konnten nicht geladen werden" + +msgid "Loading credits..." +msgstr "Credits werden geladen..." + +msgid "Loading track info..." +msgstr "Titelinfos werden geladen..." + +msgid "Failed to load track info" +msgstr "Titelinfos konnten nicht geladen werden" + +msgid "Label" +msgstr "Label" + +msgid "Newest" +msgstr "Neueste" + +msgid "Oldest" +msgstr "Alteste" + +msgid "A–Z" +msgstr "A–Z" + +msgid "Z–A" +msgstr "Z–A" + +msgid "Failed to load Library" +msgstr "Bibliothek konnte nicht geladen werden" + +msgid "Retry" +msgstr "Erneut versuchen" + +msgid "No releases found." +msgstr "Keine Veröffentlichungen gefunden." + +msgid "See discography" +msgstr "Diskografie anzeigen" + +msgid "Load more" +msgstr "Mehr laden" + +msgid "Add image" +msgstr "Bild hinzufügen" + +msgid "Change image" +msgstr "Bild ändern" + +msgid "Remove image" +msgstr "Bild entfernen" + +msgid "Create Artist Collection" +msgstr "Künstlersammlung erstellen" + +msgid "Artist Scene" +msgstr "Künstler-Szene" + +msgid "Share" +msgstr "Teilen" + +msgid "Show artist" +msgstr "Künstler anzeigen" + +msgid "Blacklist artist" +msgstr "Künstler sperren" + +msgid "This artist is hidden from discovery" +msgstr "Dieser Künstler ist von der Entdeckung ausgeblendet" + +msgid "Loading artist…" +msgstr "Künstler wird geladen…" + +msgid "Popular Tracks" +msgstr "Beliebte Titel" + +msgid "Play all next" +msgstr "Alle als Nächstes wiedergeben" + +msgid "Play all later" +msgstr "Alle später abspielen" + +msgid "Add all to queue" +msgstr "Alle zur Warteschlange hinzufügen" + +msgid "Shuffle all" +msgstr "Alle zufällig wiedergeben" + +msgid "Add all to playlist" +msgstr "Alle zur Wiedergabeliste hinzufügen" + +msgid "View less" +msgstr "Weniger anzeigen" + +msgid "Latest release" +msgstr "Neueste Veröffentlichung" + +msgid "Appears On" +msgstr "Erscheint auf" + +msgid "Hide" +msgstr "Ausblenden" + +msgid "Show" +msgstr "Anzeigen" + +msgid "Network" +msgstr "Netzwerk" + +msgid "Magazine" +msgstr "Magazin" + +msgid "Loading origin…" +msgstr "Herkunft wird geladen…" + +msgid "BORN" +msgstr "GEBOREN" + +msgid "FOUNDED" +msgstr "GEGRÜNDET" + +msgid "BORN IN" +msgstr "GEBOREN IN" + +msgid "FOUNDED IN" +msgstr "GEGRÜNDET IN" + +msgid "DIED" +msgstr "GESTORBEN" + +msgid "DISBANDED" +msgstr "AUFGELÖST" + +msgid "LABELS" +msgstr "LABELS" + +msgid "No label info" +msgstr "Keine Label-Informationen" + +msgid "SIMILAR ARTISTS" +msgstr "ÄHNLICHE KÜNSTLER" + +msgid "RELATIONSHIPS" +msgstr "BEZIEHUNGEN" + +msgid "Members & Former" +msgstr "Mitglieder & ehemalige" + +msgid "Member Of" +msgstr "Mitglied von" + +msgid "Collaborators" +msgstr "Mitwirkende" + +msgid "YOU MAY ALSO LIKE" +msgstr "DAS KÖNNTE DIR AUCH GEFALLEN" + +msgid "No stories for this artist" +msgstr "Keine Geschichten für diesen Künstler" + +msgid "About this artist" +msgstr "Über diesen Künstler" + +msgid "Source" +msgstr "Quelle" + +msgid "Following" +msgstr "Folge ich" + +msgid "Follow" +msgstr "Folgen" + +msgid "View all" +msgstr "Alle sehen" + +msgid "View all →" +msgstr "Alle anzeigen →" + +msgid "Filter by genre" +msgstr "Nach Genre filtern" + +msgid "Remember selection" +msgstr "Auswahl merken" + +msgid "Advanced view" +msgstr "Erweiterte Ansicht" + +msgid "Search genres…" +msgstr "Genres suchen…" + +msgid "Clear filter" +msgstr "Filter löschen" + +msgid "Qobuz Mixes" +msgstr "Qobuz Mixes" + +msgid "Elevate your day with a customized selection of music." +msgstr "Bereichere deinen Tag mit einer personalisierten Musikauswahl." + +msgid "Take a weekly journey with a fresh mix every Friday." +msgstr "Erleben Sie jede Woche eine neue Mischung – jeden Freitag." + +msgid "A fresh shuffle from your personal library." +msgstr "Eine frische Mischung aus deinen persoenlichen Favoriten." + +msgid "Discover new music from your most-played playlists." +msgstr "Entdecken Sie neue Musik aus Ihren meistgespielten Playlists." + +msgid "Open album" +msgstr "Album öffnen" + +msgid "Open containing album" +msgstr "Zugehöriges Album öffnen" + +msgid "Play" +msgstr "Abspielen" + +msgid "Search…" +msgstr "Suchen…" + +msgid "1 genre" +msgstr "1 Genre" + +msgid "{} genres" +msgstr "{} Genres" + +msgid "No results found." +msgstr "Keine Ergebnisse gefunden." + +msgid "Editor's Picks" +msgstr "Redaktionsauswahl" + +msgid "For You" +msgstr "Für dich" + +msgid "Qobuz Playlists" +msgstr "Qobuz-Playlists" + +msgid "No playlists match the selected categories." +msgstr "Keine Playlists entsprechen den ausgewählten Kategorien." + +msgid "Recently Played Albums" +msgstr "Zuletzt gespielte Alben" + +msgid "Albums you play will appear here." +msgstr "Alben, die Sie abspielen, erscheinen hier." + +msgid "Recently Played Tracks" +msgstr "Zuletzt gespielte Titel" + +msgid "Tracks you play will appear here." +msgstr "Titel, die Sie abspielen, erscheinen hier." + +msgid "Popular albums" +msgstr "Beliebte Alben" + +msgid "{} selected" +msgstr "{} ausgewählt" + +msgid "Filter by category" +msgstr "Nach Kategorie filtern" + +msgid "All categories" +msgstr "Alle Kategorien" + +msgid "Customize" +msgstr "Anpassen" + +msgid "Enabling more sections increases load time." +msgstr "Das Aktivieren weiterer Bereiche erhöht die Ladezeit." + +msgid "{} of {} enabled" +msgstr "{} von {} aktiviert" + +msgid "Reset to defaults" +msgstr "Auf Standard zurücksetzen" + +msgid "Radio Stations" +msgstr "Radiosender" + +msgid "RADIO" +msgstr "RADIO" + +msgid "Your Top Artists" +msgstr "Deine Top-Künstler" + +msgid "Artists to Follow" +msgstr "Künstler zum Folgen" + +msgid "Pinned" +msgstr "Angeheftet" + +msgid "Pin" +msgstr "Anheften" + +msgid "Unpin" +msgstr "Lösen" + +msgid "Showing {} of {} track." +msgid_plural "Showing {} of {} tracks." +msgstr[0] "Zeige {} von {} Titel." +msgstr[1] "Zeige {} von {} Titeln." + +msgid "Spotlight" +msgstr "Spotlight" + +msgid "Shine a light on one of your favourite artists." +msgstr "Rücke einen deiner Lieblingskünstler ins Rampenlicht." + +msgid "ARTIST" +msgstr "KÜNSTLER" + +msgid "Top tracks" +msgstr "Top-Titel" + +msgid "By {}" +msgstr "Von {}" + +msgid "TOP TRACKS" +msgstr "TOP TRACKS" + +msgid "Qobuz Radio Station" +msgstr "Qobuz Radio Station" + +msgid "Available offline" +msgstr "Offline verfügbar" + +msgid "Tracks" +msgstr "Titel" + +msgid "Albums" +msgstr "Alben" + +msgid "Artists" +msgstr "Künstler" + +msgid "Playlists" +msgstr "Playlists" + +msgid "Labels" +msgstr "Labels" + +msgid "Favorite" +msgstr "Favorit" + +msgid "Library" +msgstr "Bibliothek" + +msgid "Search" +msgstr "Suchen" + +msgid "Group: Off" +msgstr "Gruppe: Aus" + +msgid "Group: Album" +msgstr "Gruppe: Album" + +msgid "Group: Artist" +msgstr "Gruppe: Künstler" + +msgid "Group: Name" +msgstr "Gruppe: Name" + +msgid "Group: A-Z" +msgstr "Gruppe: A-Z" + +msgid "Title A-Z" +msgstr "Titel A-Z" + +msgid "Title Z-A" +msgstr "Titel Z-A" + +msgid "Artist A-Z" +msgstr "Kunstler A-Z" + +msgid "Couldn't load your Library." +msgstr "Bibliothek konnte nicht geladen werden." + +msgid "Remove from Library" +msgstr "Aus der Bibliothek entfernen" + +msgid "No tracks in your Library yet." +msgstr "Noch keine Titel in deiner Bibliothek." + +msgid "No tracks match your search." +msgstr "Keine Titel entsprechen deiner Suche." + +msgid "No albums in your Library yet." +msgstr "Noch keine Alben in deiner Bibliothek." + +msgid "No albums match your search." +msgstr "Keine Alben entsprechen Ihrer Suche." + +msgid "No artists in your Library yet." +msgstr "Noch keine Künstler in deiner Bibliothek." + +msgid "No artists match your search." +msgstr "Keine Künstler entsprechen Ihrer Suche." + +msgid "No playlists match your search." +msgstr "Keine Wiedergabelisten entsprechen Ihrer Suche." + +msgid "No followed playlists yet." +msgstr "Noch keine abonnierten Wiedergabelisten." + +msgid "No playlists in your Library yet." +msgstr "Noch keine Playlists in deiner Bibliothek." + +msgid "No labels in your Library yet." +msgstr "Noch keine Labels in deiner Bibliothek." + +msgid "No labels match your search." +msgstr "Keine Labels entsprechen Ihrer Suche." + +msgid "Select an artist to see their albums." +msgstr "Wählen Sie einen Künstler, um dessen Alben anzuzeigen." + +msgid "Couldn't load albums." +msgstr "Alben konnten nicht geladen werden." + +msgid "No albums found." +msgstr "Keine Alben gefunden." + +msgid "Go to artist page" +msgstr "Zur Künstlerseite" + +msgid "albums" +msgstr "Alben" + +msgid "Releases" +msgstr "Veröffentlichungen" + +msgid "Search this label…" +msgstr "Dieses Label durchsuchen…" + +msgid "No results" +msgstr "Keine Ergebnisse" + +msgid "More labels" +msgstr "Weitere Labels" + +msgid "About" +msgstr "Über" + +msgid "Folders" +msgstr "Ordner" + +msgid "tracks" +msgstr "Titel" + +msgid "Play all" +msgstr "Alle abspielen" + +msgid "Clear session" +msgstr "Sitzung beenden" + +msgid "No playable tracks in this folder." +msgstr "Keine abspielbaren Titel in diesem Ordner." + +msgid "Play this album" +msgstr "Dieses Album abspielen" + +msgid "Local Library" +msgstr "Lokale Bibliothek" + +msgid "Artist Z-A" +msgstr "Künstler Z-A" + +msgid "Year (newest)" +msgstr "Jahr (neueste)" + +msgid "Year (oldest)" +msgstr "Jahr (älteste)" + +msgid "No grouping" +msgstr "Keine Gruppierung" + +msgid "Group by letter" +msgstr "Nach Buchstabe gruppieren" + +msgid "Group by artist" +msgstr "Nach Kunstler gruppieren" + +msgid "By album" +msgstr "Nach Album" + +msgid "By artist" +msgstr "Nach Künstler" + +msgid "By name" +msgstr "Nach Name" + +msgid "Search artists" +msgstr "Künstler suchen" + +msgid "Couldn't load your local albums." +msgstr "Ihre lokalen Alben konnten nicht geladen werden." + +msgid "No albums in your local library yet." +msgstr "Noch keine Alben in Ihrer lokalen Bibliothek." + +msgid "Couldn't load your local tracks." +msgstr "Ihre lokalen Titel konnten nicht geladen werden." + +msgid "No tracks in your local library yet." +msgstr "Noch keine Titel in Ihrer lokalen Bibliothek." + +msgid "Add to Mixtape/Collection" +msgstr "Zu Mixtape/Collection hinzufügen" + +msgid "No folders in your local library yet." +msgstr "Noch keine Ordner in Ihrer lokalen Bibliothek." + +msgid "No folders match your search." +msgstr "Keine Ordner entsprechen Ihrer Suche." + +msgid "Search folders" +msgstr "Ordner suchen" + +msgid "sel" +msgstr "ausgew." + +msgid "Check all" +msgstr "Alle auswählen" + +msgid "Select a folder to see its contents." +msgstr "Wählen Sie einen Ordner aus, um seinen Inhalt anzuzeigen." + +msgid "Search albums..." +msgstr "Alben suchen..." + +msgid "This folder has no tracks." +msgstr "Dieser Ordner enthält keine Titel." + +msgid "No artists in your local library yet." +msgstr "Noch keine Künstler in Ihrer lokalen Bibliothek." + +msgid "artists" +msgstr "Künstler" + +msgid "Select an artist to view their albums" +msgstr "Wählen Sie einen Künstler, um seine Alben anzuzeigen" + +msgid "No albums found" +msgstr "Keine Alben gefunden" + +msgid "Local Library port in progress" +msgstr "Portierung der lokalen Bibliothek läuft" + +msgid "This tab is coming next." +msgstr "Dieser Tab folgt als Nächstes." + +msgid "Filter" +msgstr "Filter" + +msgid "Hi-Res" +msgstr "Hi-Res" + +msgid "CD" +msgstr "CD" + +msgid "Lossy" +msgstr "Verlustbehaftet" + +msgid "Other" +msgstr "Andere" + +msgid "Local" +msgstr "Lokal" + +msgid "Offline cache" +msgstr "Offline-Cache" + +msgid "Scene" +msgstr "Szene" + +msgid "No artists found for this scene." +msgstr "Keine Künstler für diese Szene gefunden." + +msgid "QBZ" +msgstr "QBZ" + +msgid "QOBUZ™ PLAYER" +msgstr "QOBUZ™ PLAYER" + +msgid "I have read the" +msgstr "Ich habe die" + +msgid "Qobuz™ Terms of Service" +msgstr "Qobuz™-Nutzungsbedingungen" + +msgid "Sign in via Browser" +msgstr "Im Browser anmelden" + +msgid "Use your system browser instead" +msgstr "Stattdessen Systembrowser verwenden" + +msgid "No internet connection — you can start in offline mode with your local library and downloads" +msgstr "Keine Internetverbindung — Sie können im Offline-Modus mit Ihrer lokalen Bibliothek und Downloads starten" + +msgid "A network sign-in page may be blocking the connection" +msgstr "Eine Netzwerk-Anmeldeseite blockiert möglicherweise die Verbindung" + +msgid "Could not restore your session" +msgstr "Ihre Sitzung konnte nicht wiederhergestellt werden" + +msgid "Start offline (no access to Qobuz™ services)" +msgstr "Offline starten (kein Zugriff auf Qobuz™-Dienste)" + +msgid "QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™." +msgstr "QBZ erfordert ein aktives Qobuz™-Abonnement. Ihre Anmeldedaten werden direkt an Qobuz™ gesendet." + +msgid "This application uses the Qobuz API but is not certified by Qobuz." +msgstr "Diese Anwendung verwendet die Qobuz-API, ist jedoch nicht von Qobuz zertifiziert." + +msgid "Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz." +msgstr "Qobuz™ ist eine Marke von Qobuz. QBZ ist eine Open-Source-Anwendung unter der MIT-Lizenz und ist nicht mit Qobuz verbunden, wird nicht von Qobuz unterstützt oder zertifiziert." + +msgid "No tracks for this mix." +msgstr "Keine Titel für diesen Mix." + +msgid "Album" +msgstr "Album" + +msgid "Bands & Projects" +msgstr "Bands" + +msgid "No album appearances found." +msgstr "Keine Album-Beteiligungen gefunden." + +msgid "You're offline" +msgstr "Sie sind offline" + +msgid "Offline mode is enabled. Disable it in Settings to use Qobuz." +msgstr "Der Offline-Modus ist aktiviert. Deaktivieren Sie ihn in den Einstellungen, um Qobuz zu nutzen." + +msgid "No internet connection. Your local library and downloads keep working." +msgstr "Keine Internetverbindung. Ihre lokale Bibliothek und Downloads funktionieren weiterhin." + +msgid "Open Settings" +msgstr "Einstellungen öffnen" + +msgid "Offline Cache" +msgstr "Offline-Cache" + +msgid "{} track" +msgid_plural "{} tracks" +msgstr[0] "{} Titel" +msgstr[1] "{} Titel" + +msgid "Sort: A-Z" +msgstr "Sortieren: A-Z" + +msgid "Sort: Recent" +msgstr "Sortieren: Neueste" + +msgid "Sort: Largest" +msgstr "Sortieren: Größte" + +msgid "Sort: Smallest" +msgstr "Sortieren: Kleinste" + +msgid "Failed only" +msgstr "Nur fehlgeschlagene" + +msgid " selected" +msgstr " ausgewählt" + +msgid "Re-download" +msgstr "Erneut herunterladen" + +msgid "Remove" +msgstr "Entfernen" + +msgid "All artists" +msgstr "Alle Künstler" + +msgid "No offline tracks yet" +msgstr "Noch keine Offline-Titel" + +msgid "Search playlists…" +msgstr "Playlists suchen…" + +msgid "1 playlist" +msgstr "1 Playlist" + +msgid "{} playlists" +msgstr "{} Playlists" + +msgid "Move to folder" +msgstr "In Ordner verschieben" + +msgid "No folder" +msgstr "Kein Ordner" + +msgid "Playlist manager" +msgstr "Playlist-Manager" + +msgid "Visible" +msgstr "Sichtbar" + +msgid "Hidden" +msgstr "Versteckt" + +msgid "All" +msgstr "Alle" + +msgid "Recent" +msgstr "Neueste" + +msgid "Play Count" +msgstr "Abspielanzahl" + +msgid "Track Count" +msgstr "Titelanzahl" + +msgid "Name A-Z" +msgstr "Name A-Z" + +msgid "New folder" +msgstr "Neuer Ordner" + +msgid "{} folders, {} playlists" +msgstr "{} Ordner, {} Playlists" + +msgid "Use the arrows to reorder playlists" +msgstr "Verwenden Sie die Pfeile, um Playlists neu anzuordnen" + +msgid "Folders ({})" +msgstr "Ordner ({})" + +msgid "Playlists ({})" +msgstr "Playlists ({})" + +msgid "No playlists found." +msgstr "Keine Playlists gefunden." + +msgid "Artist" +msgstr "Künstler" + +msgid "Playlist" +msgstr "Playlist" + +msgid "Search tracks" +msgstr "Titel suchen" + +msgid "Sort" +msgstr "Sortieren" + +msgid "This playlist is empty." +msgstr "Diese Wiedergabeliste ist leer." + +msgid "Upload to Qobuz?" +msgstr "Zu Qobuz hochladen?" + +msgid "This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy." +msgstr "Dadurch wird die Wiedergabeliste mit ihren Qobuz-Titeln auf Qobuz erstellt, lokale Dateien bleiben verknüpft und die rein lokale Kopie wird entfernt." + +msgid "Cancel" +msgstr "Abbrechen" + +msgid "Upload" +msgstr "Hochladen" + +msgid "About this playlist" +msgstr "Über diese Wiedergabeliste" + +msgid "ITEM" +msgstr "ELEMENT" + +msgid "TYPE" +msgstr "TYP" + +msgid "SOURCE" +msgstr "QUELLE" + +msgid "QUALITY" +msgstr "QUALITÄT" + +msgid "TRACKS" +msgstr "TITEL" + +msgid "YEAR" +msgstr "JAHR" + +msgid "Folder name" +msgstr "Ordnername" + +msgid "Creating..." +msgstr "Wird erstellt..." + +msgid "Create" +msgstr "Erstellen" + +msgid "Edit folder" +msgstr "Ordner bearbeiten" + +msgid "Enter folder name" +msgstr "Ordnernamen eingeben" + +msgid "Icon" +msgstr "Icon" + +msgid "Hide from sidebar" +msgstr "Aus Seitenleiste ausblenden" + +msgid "Delete" +msgstr "Löschen" + +msgid "Save" +msgstr "Speichern" + +msgid "JUMP TO" +msgstr "SPRINGEN ZU" + +msgid "Search this page…" +msgstr "Diese Seite durchsuchen…" + +msgid "Something is already playing" +msgstr "Es wird bereits etwas wiedergegeben" + +msgid "Add this to the current queue, or clear the queue and start playing it?" +msgstr "Dies zur aktuellen Warteschlange hinzufügen oder die Warteschlange leeren und die Wiedergabe starten?" + +msgid "Clear queue and play" +msgstr "Warteschlange leeren und wiedergeben" + +msgid "Edit metadata" +msgstr "Metadaten bearbeiten" + +msgid "Album title" +msgstr "Albumtitel" + +msgid "Album artist" +msgstr "Albumkünstler" + +msgid "Year" +msgstr "Jahr" + +msgid "Genre" +msgstr "Genre" + +msgid "Catalog number" +msgstr "Katalognummer" + +msgid "Total discs" +msgstr "Datenträger gesamt" + +msgid "Searching…" +msgstr "Suche läuft…" + +msgid "No results, try a different search" +msgstr "Keine Ergebnisse, versuchen Sie eine andere Suche" + +msgid "Apply" +msgstr "Anwenden" + +msgid "Album folder" +msgstr "Albumordner" + +msgid "Sidecar file (recommended)" +msgstr "Sidecar-Datei (empfohlen)" + +msgid "Write to audio files" +msgstr "In Audiodateien schreiben" + +msgid "This writes tags directly into your audio files." +msgstr "Dadurch werden Tags direkt in Ihre Audiodateien geschrieben." + +msgid "Direct write is unavailable for CUE-based albums." +msgstr "Direktes Schreiben ist für CUE-basierte Alben nicht verfügbar." + +msgid "Writing" +msgstr "Wird geschrieben" + +msgid "Saving…" +msgstr "Wird gespeichert…" + +msgid "Edit playlist" +msgstr "Playlist bearbeiten" + +msgid "Playlist name" +msgstr "Playlist-Name" + +msgid "Description" +msgstr "Beschreibung" + +msgid "Offline-only playlist" +msgstr "Nur-Offline-Playlist" + +msgid "New playlist" +msgstr "Neue Playlist" + +msgid "Name" +msgstr "Name" + +msgid "My Playlist" +msgstr "Meine Playlist" + +msgid "Description (optional)" +msgstr "Beschreibung (optional)" + +msgid "Folder" +msgstr "Ordner" + +msgid "Make playlist public" +msgstr "Playlist öffentlich machen" + +msgid "Created locally — you're offline" +msgstr "Lokal erstellt – Sie sind offline" + +msgid "Add to mixtape" +msgstr "Zum Mixtape hinzufügen" + +msgid "Share Qobuz link" +msgstr "Qobuz-Link teilen" + +msgid "Share Album.link" +msgstr "Album.link teilen" + +msgid "Refresh offline copy" +msgstr "Offline-Kopie aktualisieren" + +msgid "Some of these tracks were already added" +msgstr "Einige dieser Titel wurden bereits hinzugefügt" + +msgid "This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates." +msgstr "Diese Playlist enthält bereits {} der {} Titel, die Sie hinzufügen. Fügen Sie nur die neuen hinzu oder alles einschließlich Duplikate." + +msgid "Adding..." +msgstr "Wird hinzugefügt..." + +msgid "Add all tracks" +msgstr "Alle Titel hinzufügen" + +msgid "Add only new tracks" +msgstr "Nur neue Titel hinzufügen" + +msgid "Add to Playlist" +msgstr "Zur Playlist hinzufügen" + +msgid "Choose playlist" +msgstr "Playlist wählen" + +msgid "Adding {} local track(s)" +msgstr "{} lokale(r) Titel werden hinzugefügt" + +msgid "Adding {} track(s)" +msgstr "{} Titel werden hinzugefügt" + +msgid "+ Create new playlist" +msgstr "+ Neue Playlist erstellen" + +msgid "Search playlists..." +msgstr "Playlists suchen..." + +msgid "Playlist Name" +msgstr "Playlist-Name" + +msgid "Saving..." +msgstr "Speichern..." + +msgid "Create & Add" +msgstr "Erstellen und hinzufügen" + +msgid "Add" +msgstr "Hinzufügen" + +msgid "No playlists found" +msgstr "Keine Playlists gefunden" + +msgid "{} more playlist(s)..." +msgstr "{} weitere Playlist(s)..." + +msgid "Import Playlist" +msgstr "Playlist importieren" + +msgid "This feature requires internet connection" +msgstr "Diese Funktion erfordert eine Internetverbindung" + +msgid "Playlist URL" +msgstr "Playlist-URL" + +msgid "ALLOWED SOURCES" +msgstr "ZULÄSSIGE QUELLEN" + +msgid "Conversion progress" +msgstr "Konvertierungsfortschritt" + +msgid "Summary" +msgstr "Zusammenfassung" + +msgid "Close" +msgstr "Schließen" + +msgid "Importing..." +msgstr "Wird importiert..." + +msgid "Import" +msgstr "Importieren" + +msgid "Fetching..." +msgstr "Wird abgerufen..." + +msgid "Fetch playlist" +msgstr "Playlist abrufen" + +msgid "Shuffle" +msgstr "Mischen" + +msgid "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" +msgstr "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" + +msgid "24-bit / 44.1 kHz — Billie Eilish · LUNCH" +msgstr "24-bit / 44.1 kHz — Billie Eilish · LUNCH" + +msgid "24-bit / 96 kHz — Iron Maiden · Stratego" +msgstr "24-bit / 96 kHz — Iron Maiden · Stratego" + +msgid "24-bit / 192 kHz — Toto · Africa" +msgstr "24-bit / 192 kHz — Toto · Africa" + +msgid "Welcome" +msgstr "Willkommen" + +msgid "Check" +msgstr "Prüfen" + +msgid "DACs" +msgstr "DACs" + +msgid "Configure" +msgstr "Konfigurieren" + +msgid "Test" +msgstr "Testen" + +msgid "Done" +msgstr "Fertig" + +msgid "HiFi Wizard — DAC Setup" +msgstr "HiFi-Assistent — DAC-Einrichtung" + +msgid "Guided DAC Setup" +msgstr "Geführte DAC-Einrichtung" + +msgid "This wizard helps you configure your DAC for bit-perfect playback on Linux. QBZ never changes your system automatically — it auto-detects your DACs, shows you the exact commands and config to copy, and lets you test playback right here." +msgstr "Dieser Assistent hilft Ihnen, Ihren DAC für bit-perfekte Wiedergabe unter Linux einzurichten. QBZ ändert Ihr System nie automatisch – es erkennt Ihre DACs, zeigt Ihnen die genauen Befehle und Konfigurationen zum Kopieren und lässt Sie die Wiedergabe direkt hier testen." + +msgid "You are responsible for your own backups. This is a helper, not a system recovery tool." +msgstr "Sie sind für Ihre eigenen Sicherungen verantwortlich. Dies ist ein Hilfsmittel, kein Systemwiederherstellungstool." + +msgid "What to test with" +msgstr "Womit testen" + +msgid "The built-in test plays four tracks spanning different bit-depths and sample rates (16/44.1, 24/44.1, 24/96, 24/192) so you can see your DAC switch. If you'd rather use your own queue, add one track of each kind first so every rate is exercised." +msgstr "Der integrierte Test spielt vier Titel mit unterschiedlichen Bittiefen und Abtastraten (16/44.1, 24/44.1, 24/96, 24/192), damit Sie das Umschalten Ihres DAC sehen. Wenn Sie lieber Ihre eigene Warteschlange verwenden möchten, fügen Sie zuerst je einen Titel jeder Art hinzu, damit alle Raten geprüft werden." + +msgid "I understand and accept these terms" +msgstr "Ich verstehe und akzeptiere diese Bedingungen" + +msgid "Check Audio Stack" +msgstr "Audio-Stack prüfen" + +msgid "Sandboxed — host check unavailable" +msgstr "In Sandbox – Host-Prüfung nicht verfügbar" + +msgid "QBZ is packaged as Flatpak/Snap and can't inspect your host audio stack. If playback already works, you're set. Pick your distribution and init system below for the right setup commands." +msgstr "QBZ ist als Flatpak/Snap verpackt und kann Ihren Host-Audio-Stack nicht prüfen. Wenn die Wiedergabe bereits funktioniert, sind Sie startklar. Wählen Sie unten Ihre Distribution und Ihr Init-System für die richtigen Einrichtungsbefehle." + +msgid "Audio stack ready" +msgstr "Audio-Stack bereit" + +msgid "Some packages are missing" +msgstr "Einige Pakete fehlen" + +msgid "Your distribution" +msgstr "Ihre Distribution" + +msgid "Init system (service commands)" +msgstr "Init-System (Dienstbefehle)" + +msgid "Which DACs do you want to configure?" +msgstr "Welche DACs möchten Sie konfigurieren?" + +msgid "Detecting your DACs…" +msgstr "DACs werden erkannt…" + +msgid "default" +msgstr "Standard" + +msgid "Hide manual entry" +msgstr "Manuelle Eingabe ausblenden" + +msgid "Can't see your DAC?" +msgstr "DAC nicht sichtbar?" + +msgid "Find your DAC's PipeWire node name and paste it here:" +msgstr "Ermitteln Sie den PipeWire-Knotennamen Ihres DAC und fügen Sie ihn hier ein:" + +msgid "1. Find your DAC under 'Audio > Sinks' and note its number" +msgstr "1. Suchen Sie Ihren DAC unter 'Audio > Sinks' und notieren Sie dessen Nummer" + +msgid "2. Run with that number to read the node.name" +msgstr "2. Mit dieser Nummer ausführen, um den node.name auszulesen" + +msgid "Valid node name" +msgstr "Gültiger Knotenname" + +msgid "Invalid — should contain alsa_output or alsa_input" +msgstr "Ungültig — sollte alsa_output oder alsa_input enthalten" + +msgid "Review & Apply" +msgstr "Prüfen und anwenden" + +msgid "QBZ never writes these files" +msgstr "QBZ schreibt diese Dateien nie" + +msgid "QBZ already pins rate, sink and exclusive mode at runtime. These files only change system-wide behavior — copy and run them yourself if you want them. Back up first." +msgstr "QBZ legt Rate, Sink und Exklusivmodus bereits zur Laufzeit fest. Diese Dateien ändern nur das systemweite Verhalten — kopieren und führen Sie sie selbst aus, wenn Sie sie möchten. Erstellen Sie zuerst eine Sicherung." + +msgid "1. Back up your current PipeWire/WirePlumber config" +msgstr "1. Sichern Sie Ihre aktuelle PipeWire/WirePlumber-Konfiguration" + +msgid "I created a backup" +msgstr "Ich habe eine Sicherung erstellt" + +msgid "Sample-rate switching (PipeWire)" +msgstr "Abtastraten-Umschaltung (PipeWire)" + +msgid "Per-app bit-perfect (PipeWire client)" +msgstr "Bit-perfect pro App (PipeWire-Client)" + +msgid "Pin the DAC + rates (WirePlumber)" +msgstr "DAC + Raten festlegen (WirePlumber)" + +msgid "I created the configuration files" +msgstr "Ich habe die Konfigurationsdateien erstellt" + +msgid "3. Restart the audio services to load the config" +msgstr "3. Starten Sie die Audiodienste neu, um die Konfiguration zu laden" + +msgid "I restarted the audio services" +msgstr "Ich habe die Audiodienste neu gestartet" + +msgid "Test Playback" +msgstr "Wiedergabe testen" + +msgid "Plays four tracks at different bit-depths and sample rates through the selected DAC. Watch your DAC's display switch rates — and confirm it below." +msgstr "Spielt vier Titel mit unterschiedlichen Bittiefen und Abtastraten über den ausgewählten DAC ab. Beobachten Sie, wie das Display Ihres DAC die Raten wechselt — und bestätigen Sie es unten." + +msgid "Stop" +msgstr "Stopp" + +msgid "Play test" +msgstr "Test abspielen" + +msgid "Use my current queue" +msgstr "Aktuelle Warteschlange verwenden" + +msgid "All set" +msgstr "Alles bereit" + +msgid "Your DAC is ready for bit-perfect playback. If you applied the config files, restart the audio services (or log out and back in) for them to take effect." +msgstr "Ihr DAC ist bereit für die Bit-perfect-Wiedergabe. Wenn Sie die Konfigurationsdateien angewendet haben, starten Sie die Audiodienste neu (oder melden Sie sich ab und wieder an), damit sie wirksam werden." + +msgid "Config files you can create:" +msgstr "Konfigurationsdateien, die Sie erstellen können:" + +msgid "Back" +msgstr "Zurück" + +msgid "Start" +msgstr "Starten" + +msgid "Finish" +msgstr "Fertigstellen" + +msgid "Next" +msgstr "Weiter" + +msgid "Play now" +msgstr "Jetzt abspielen" + +msgid "Start playing now?" +msgstr "Jetzt mit der Wiedergabe beginnen?" + +msgid "Not now" +msgstr "Jetzt nicht" + +msgid "Show location" +msgstr "Speicherort anzeigen" + +msgid "Remove from Local Library" +msgstr "Aus lokaler Bibliothek entfernen" + +msgid "Forget download record" +msgstr "Download-Eintrag vergessen" + +msgid "Could not open that folder." +msgstr "Dieser Ordner konnte nicht geöffnet werden." + +msgid "Could not remove that library folder." +msgstr "Dieser Bibliotheksordner konnte nicht entfernt werden." + +msgid "Could not forget that download." +msgstr "Dieser Download konnte nicht vergessen werden." + +msgid "Create radio" +msgstr "Radio erstellen" + +msgid "Add to library" +msgstr "Zur Bibliothek hinzufügen" + +msgid "Remove from playlist" +msgstr "Aus Wiedergabeliste entfernen" + +msgid "Share Song.link" +msgstr "Song.link teilen" + +msgid "Remove offline copy" +msgstr "Offline-Kopie entfernen" + +msgid "Go to album" +msgstr "Zum Album" + +msgid "Go to artist" +msgstr "Zum Künstler" + +msgid "Track info" +msgstr "Titelinfo" + +msgid "selected" +msgstr "ausgewählt" + +msgid "Artist is blacklisted" +msgstr "Künstler ist auf der Sperrliste" + +msgid "Main Artist" +msgstr "Hauptkünstler" + +msgid "Performer" +msgstr "Künstler" + +msgid "Composer" +msgstr "Komponist" + +msgid "Release Name" +msgstr "Album-/Track-Name" + +msgid "Most popular" +msgstr "Top-Ergebnis" + +msgid "No results." +msgstr "Keine Ergebnisse." + +msgid "No results for" +msgstr "Keine Ergebnisse für" + +msgid "Top result" +msgstr "Bestes Ergebnis" + +msgid "View more" +msgstr "Mehr anzeigen" + +msgid "QOBUZ CONNECT" +msgstr "QOBUZ CONNECT" + +msgid "Connect diagnostics" +msgstr "Connect-Diagnose" + +msgid "Live session topology and a rolling event log for debugging Qobuz Connect at runtime." +msgstr "Live-Sitzungstopologie und ein fortlaufendes Ereignisprotokoll zur Fehlersuche bei Qobuz Connect zur Laufzeit." + +msgid "Open diagnostics" +msgstr "Diagnose öffnen" + +msgid "PLAYBACK" +msgstr "WIEDERGABE" + +msgid "Continue playback after track ends" +msgstr "Wiedergabe nach Ende des Titels fortsetzen" + +msgid "Keep playing the rest of the album or playlist instead of stopping." +msgstr "Den Rest des Albums oder der Playlist weiter abspielen, anstatt zu stoppen." + +msgid "Show track playing context" +msgstr "Wiedergabekontext des Titels anzeigen" + +msgid "Display the context-stack icon in the player." +msgstr "Das Kontextstapel-Symbol im Player anzeigen." + +msgid "Gapless playback" +msgstr "Lückenlose Wiedergabe" + +msgid "Play consecutive same-format tracks without a gap." +msgstr "Aufeinanderfolgende Titel im selben Format ohne Pause abspielen." + +msgid "SESSION" +msgstr "SITZUNG" + +msgid "Restore session on startup" +msgstr "Sitzung beim Start wiederherstellen" + +msgid "Restore the queue and current track on the next launch." +msgstr "Die Warteschlange und den aktuellen Titel beim nächsten Start wiederherstellen." + +msgid "Resume playback position" +msgstr "Wiedergabeposition fortsetzen" + +msgid "Also seek back to where the saved track left off." +msgstr "Außerdem zu der Stelle zurückspringen, an der der gespeicherte Titel unterbrochen wurde." + +msgid "STREAMING" +msgstr "STREAMING" + +msgid "Stream uncached tracks" +msgstr "Nicht zwischengespeicherte Titel streamen" + +msgid "Start uncached tracks via streaming instead of waiting for the full download." +msgstr "Nicht zwischengespeicherte Titel per Streaming starten, anstatt den vollständigen Download abzuwarten." + +msgid "Initial buffer size" +msgstr "Anfängliche Puffergröße" + +msgid "Seconds of audio buffered before streaming playback starts." +msgstr "Sekunden an Audio, die gepuffert werden, bevor die Streaming-Wiedergabe beginnt." + +msgid "s" +msgstr "s" + +msgid "Streaming only" +msgstr "Nur Streaming" + +msgid "Skip writing tracks to the local cache while streaming." +msgstr "Während des Streamings keine Titel in den lokalen Cache schreiben." + +msgid "When quality retries fail" +msgstr "Wenn Qualitaetswiederholungen fehlschlagen" + +msgid "What to do when every quality tier for a track is unavailable." +msgstr "Was zu tun ist, wenn keine Qualitätsstufe für einen Titel verfügbar ist." + +msgid "Folder settings" +msgstr "Ordnereinstellungen" + +msgid "Folder settings saved" +msgstr "Ordnereinstellungen gespeichert" + +msgid "Could not save the folder settings." +msgstr "Die Ordnereinstellungen konnten nicht gespeichert werden." + +msgid "Folder location" +msgstr "Ordnerspeicherort" + +msgid "Change" +msgstr "Ändern" + +msgid "Checking..." +msgstr "Überprüfung..." + +msgid "Folder is accessible" +msgstr "Ordner ist zugänglich" + +msgid "Folder is not accessible" +msgstr "Ordner ist nicht zugänglich" + +msgid "Display name" +msgstr "Anzeigename" + +msgid "Optional friendly name" +msgstr "Optionaler Anzeigename" + +msgid "Shown instead of the full path in the folder list." +msgstr "Wird in der Ordnerliste anstelle des vollständigen Pfads angezeigt." + +msgid "Enabled" +msgstr "Aktiviert" + +msgid "Disabled folders are skipped during scans." +msgstr "Deaktivierte Ordner werden bei Scans übersprungen." + +msgid "Network folder" +msgstr "Netzwerkordner" + +msgid "Mark this folder as a network share (excluded when offline)." +msgstr "Diesen Ordner als Netzwerkfreigabe markieren (offline ausgeschlossen)." + +msgid "Network filesystem type" +msgstr "Netzwerkdateisystemtyp" + +msgid "Auto-detect" +msgstr "Automatisch erkennen" + +msgid "Last scanned" +msgstr "Zuletzt gescannt" + +msgid "Scan this folder" +msgstr "Diesen Ordner scannen" + +msgid "FOLDER" +msgstr "ORDNER" + +msgid "LAST SCAN" +msgstr "LETZTER SCAN" + +msgid "STATUS" +msgstr "STATUS" + +msgid "Unavailable" +msgstr "Nicht verfügbar" + +msgid "Active" +msgstr "Aktiv" + +msgid "LIBRARY FOLDERS" +msgstr "BIBLIOTHEKSORDNER" + +msgid "Browse..." +msgstr "Durchsuchen..." + +msgid "Choose a music folder" +msgstr "Musikordner auswählen" + +msgid "Browse for a music folder, or type the full path." +msgstr "Nach einem Musikordner suchen oder den vollständigen Pfad eingeben." + +msgid "Filter folders..." +msgstr "Ordner filtern..." + +msgid "No folders yet. Add a folder to build your local library." +msgstr "Noch keine Ordner. Fügen Sie einen Ordner hinzu, um Ihre lokale Bibliothek aufzubauen." + +msgid "No folders match your filter." +msgstr "Keine Ordner entsprechen Ihrem Filter." + +msgid "Scanning" +msgstr "Wird gescannt" + +msgid "MAINTENANCE" +msgstr "WARTUNG" + +msgid "Cleanup missing files" +msgstr "Fehlende Dateien bereinigen" + +msgid "Remove tracks whose files no longer exist on disk." +msgstr "Titel entfernen, deren Dateien nicht mehr auf der Festplatte vorhanden sind." + +msgid "Cleaning up..." +msgstr "Wird bereinigt..." + +msgid "Cleanup" +msgstr "Bereinigen" + +msgid "DANGER ZONE" +msgstr "GEFAHRENZONE" + +msgid "Clear library database" +msgstr "Bibliotheksdatenbank leeren" + +msgid "Remove all indexed tracks. Your audio files are not deleted." +msgstr "Alle indizierten Titel entfernen. Ihre Audiodateien werden nicht gelöscht." + +msgid "Clearing..." +msgstr "Löschen..." + +msgid "Clear all" +msgstr "Alle löschen" + +msgid "PLEX" +msgstr "PLEX" + +msgid "Connect a Plex Media Server on your local network." +msgstr "Verbinden Sie einen Plex Media Server in Ihrem lokalen Netzwerk." + +msgid "Server address" +msgstr "Serveradresse" + +msgid "Only local network servers are supported." +msgstr "Es werden nur Server im lokalen Netzwerk unterstützt." + +msgid "Enter a valid server address." +msgstr "Geben Sie eine gültige Serveradresse ein." + +msgid "http://127.0.0.1:32400" +msgstr "http://127.0.0.1:32400" + +msgid "Authorize" +msgstr "Autorisieren" + +msgid "Generate a code and sign in to Plex in your browser." +msgstr "Generieren Sie einen Code und melden Sie sich in Ihrem Browser bei Plex an." + +msgid "Working..." +msgstr "Wird ausgeführt..." + +msgid "Generate code" +msgstr "Code generieren" + +msgid "Link code" +msgstr "Verknüpfungscode" + +msgid "Enter this code at the Plex sign-in page." +msgstr "Geben Sie diesen Code auf der Plex-Anmeldeseite ein." + +msgid "Copy code" +msgstr "Code kopieren" + +msgid "Open Plex sign-in" +msgstr "Plex-Anmeldung öffnen" + +msgid "Connected to Plex" +msgstr "Mit Plex verbunden" + +msgid "The code expired. Generate a new one." +msgstr "Der Code ist abgelaufen. Erzeugen Sie einen neuen." + +msgid "Ping the saved server and report what answers." +msgstr "Den gespeicherten Server anpingen und melden, was antwortet." + +msgid "Connected to {}" +msgstr "Verbunden mit {}" + +msgid "Could not reach the Plex server." +msgstr "Der Plex-Server war nicht erreichbar." + +msgid "Plex is not configured." +msgstr "Plex ist nicht konfiguriert." + +msgid "Enter token manually" +msgstr "Token manuell eingeben" + +msgid "Use an existing X-Plex-Token instead of the PIN flow." +msgstr "Vorhandenen X-Plex-Token statt des PIN-Verfahrens verwenden." + +msgid "Token" +msgstr "Token" + +msgid "X-Plex-Token" +msgstr "X-Plex-Token" + +msgid "Check connection" +msgstr "Verbindung prüfen" + +msgid "Verify the server is reachable." +msgstr "Prüfen, ob der Server erreichbar ist." + +msgid "Libraries" +msgstr "Bibliotheken" + +msgid "Fetch your Plex music libraries." +msgstr "Plex-Musikbibliotheken abrufen." + +msgid "Get libraries" +msgstr "Bibliotheken abrufen" + +msgid "MUSIC LIBRARIES" +msgstr "MUSIKBIBLIOTHEKEN" + +msgid "({} tracks)" +msgstr "({} Titel)" + +msgid "Write metadata to Plex (experimental)" +msgstr "Metadaten in Plex schreiben (experimentell)" + +msgid "Allow QBZ to write track metadata back to your Plex server." +msgstr "QBZ erlauben, Titel-Metadaten zurück auf den Plex-Server zu schreiben." + +msgid "PLEX DANGER ZONE" +msgstr "PLEX-GEFAHRENZONE" + +msgid "Disconnect" +msgstr "Verbindung trennen" + +msgid "Sign out of Plex and clear the local cache." +msgstr "Von Plex abmelden und lokalen Cache löschen." + +msgid "Clear cache" +msgstr "Cache leeren" + +msgid "Remove cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Im Cache gespeicherte Plex-Bibliotheken und -Titel entfernen. Ihre Anmeldung bleibt erhalten." + +msgid "OFFLINE MODE" +msgstr "OFFLINE-MODUS" + +msgid "Status" +msgstr "Status" + +msgid "Captive portal detected — sign in to the network to get online." +msgstr "Captive Portal erkannt – im Netzwerk anmelden, um online zu gehen." + +msgid "Offline mode enabled" +msgstr "Offline-Modus aktiviert" + +msgid "No connection" +msgstr "Keine Verbindung" + +msgid "Online" +msgstr "Online" + +msgid "Check now" +msgstr "Jetzt pruefen" + +msgid "Enable Offline Mode" +msgstr "Offline-Modus aktivieren" + +msgid "Test the connection now instead of waiting for the next automatic check." +msgstr "Die Verbindung jetzt testen, statt auf die nächste automatische Prüfung zu warten." + +msgid "Include sign-in credentials" +msgstr "Anmeldedaten einschließen" + +msgid "The bundle will contain your tokens. Anyone who opens the file can sign in as you." +msgstr "Das Paket enthält Ihre Tokens. Wer die Datei öffnet, kann sich als Sie anmelden." + +msgid "Manually switch to offline mode even with internet." +msgstr "Auch bei vorhandenem Internet manuell in den Offline-Modus wechseln." + +msgid "OFFLINE CACHE" +msgstr "OFFLINE-CACHE" + +msgid "Manage offline cache" +msgstr "Offline-Cache verwalten" + +msgid "Browse and manage your downloaded tracks and albums." +msgstr "Heruntergeladene Titel und Alben durchsuchen und verwalten." + +msgid "Open manager" +msgstr "Manager öffnen" + +msgid "Cache folder" +msgstr "Cache-Ordner" + +msgid "Open the folder where offline tracks are stored on disk." +msgstr "Den Ordner öffnen, in dem Offline-Titel auf der Festplatte gespeichert werden." + +msgid "Open folder" +msgstr "Ordner öffnen" + +msgid "Clear offline cache" +msgstr "Offline-Cache leeren" + +msgid "Remove every downloaded track. This cannot be undone." +msgstr "Alle heruntergeladenen Titel entfernen. Dies kann nicht rückgängig gemacht werden." + +msgid "LYRICS" +msgstr "LIEDTEXTE" + +msgid "Clear lyrics cache" +msgstr "Liedtext-Cache leeren" + +msgid "{} entries using {}" +msgstr "{} Einträge mit {}" + +msgid "Manage" +msgstr "Verwalten" + +msgid "Artist Blacklist" +msgstr "Künstler-Blacklist" + +msgid "{} artist(s) blocked" +msgstr "{} Künstler blockiert" + +msgid "{} artist(s) blocked (disabled)" +msgstr "{} Künstler blockiert (deaktiviert)" + +msgid "Settings" +msgstr "Einstellungen" + +msgid "Audio" +msgstr "Audio" + +msgid "Playback" +msgstr "Wiedergabe" + +msgid "Appearance" +msgstr "Darstellung" + +msgid "Offline" +msgstr "Offline" + +msgid "Blacklist" +msgstr "Blacklist" + +msgid "Integrations" +msgstr "Integrationen" + +msgid "Developer" +msgstr "Entwickler" + +msgid "Loading settings..." +msgstr "Einstellungen werden geladen..." + +msgid "Added {}" +msgstr "{} hinzugefügt" + +msgid "Blacklisted artists are hidden from search results, radio, suggestions, and similar artists." +msgstr "Gesperrte Künstler sind in Suchergebnissen, Radio, Empfehlungen und ähnlichen Künstlern verborgen." + +msgid "Clear All" +msgstr "Alles löschen" + +msgid "{} artists" +msgstr "{} Künstler" + +msgid "Blacklist filtering is disabled. Blacklisted artists are shown everywhere until you re-enable it." +msgstr "Die Blacklist-Filterung ist deaktiviert. Künstler auf der Blacklist werden überall angezeigt, bis Sie sie wieder aktivieren." + +msgid "Loading blacklist…" +msgstr "Sperrliste wird geladen…" + +msgid "No blacklisted artists" +msgstr "Keine gesperrten Künstler" + +msgid "To hide an artist, go to their page and use the menu." +msgstr "Um einen Künstler auszublenden, öffnen Sie dessen Seite und verwenden Sie das Menü." + +msgid "No results for \"{}\"" +msgstr "Keine Ergebnisse für \"{}\"" + +msgid "Clear Blacklist?" +msgstr "Blacklist löschen?" + +msgid "This will remove all {} blacklisted artists. This cannot be undone." +msgstr "Dadurch werden alle {} gesperrten Künstler entfernt. Dies kann nicht rückgängig gemacht werden." + +msgid "SCROBBLERS" +msgstr "SCROBBLER" + +msgid "Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services." +msgstr "Senden Sie Ihre Wiedergaben an Last.fm und ListenBrainz. Funktioniert für Qobuz-, lokale und Plex-Titel. Dieser Schalter entscheidet auch, ob Empfehlungen Ihren Hörverlauf von diesen Diensten lesen dürfen." + +msgid "LAST.FM" +msgstr "LAST.FM" + +msgid "Scrobble to Last.fm" +msgstr "Auf Last.fm scrobbeln" + +msgid "Signed in as {}." +msgstr "Angemeldet als {}." + +msgid "Connect your Last.fm account to enable scrobbling." +msgstr "Verbinden Sie Ihr Last.fm-Konto, um das Scrobbeln zu aktivieren." + +msgid "Connect" +msgstr "Verbinden" + +msgid "Authorize QBZ in your browser, then click Finish." +msgstr "Autorisieren Sie QBZ in Ihrem Browser und klicken Sie dann auf Fertigstellen." + +msgid "Connect Last.fm" +msgstr "Last.fm verbinden" + +msgid "Open authorize page" +msgstr "Autorisierungsseite öffnen" + +msgid "Disconnect Last.fm" +msgstr "Last.fm trennen" + +msgid "Sign out of Last.fm." +msgstr "Von Last.fm abmelden." + +msgid "LISTENBRAINZ" +msgstr "LISTENBRAINZ" + +msgid "Scrobble to ListenBrainz" +msgstr "Auf ListenBrainz scrobbeln" + +msgid "Paste your ListenBrainz user token to enable scrobbling." +msgstr "Fügen Sie Ihren ListenBrainz-Benutzertoken ein, um das Scrobbeln zu aktivieren." + +msgid "User token" +msgstr "Benutzer-Token" + +msgid "From listenbrainz.org/settings." +msgstr "Von listenbrainz.org/settings." + +msgid "ListenBrainz token" +msgstr "ListenBrainz-Token" + +msgid "Save token" +msgstr "Token speichern" + +msgid "Validates the token against ListenBrainz." +msgstr "Überprüft den Token bei ListenBrainz." + +msgid "Validating..." +msgstr "Wird überprüft..." + +msgid "Connect ListenBrainz" +msgstr "ListenBrainz verbinden" + +msgid "Disconnect ListenBrainz" +msgstr "ListenBrainz trennen" + +msgid "Sign out of ListenBrainz." +msgstr "Von ListenBrainz abmelden." + +msgid "DISCORD" +msgstr "DISCORD" + +msgid "Discord Rich Presence" +msgstr "Discord Rich Presence" + +msgid "Show what you're listening to as your Discord status. Opt-in — Discord must be running." +msgstr "Zeigt, was Sie gerade hören, als Discord-Status an. Opt-in – Discord muss laufen." + +msgid "Flatpak socket access" +msgstr "Flatpak-Socket-Zugriff" + +msgid "" +"Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" +msgstr "" +"Flatpak-Installation und die Präsenz wird nicht angezeigt? Gewähren Sie Zugriff auf den IPC-Socket von Discord und starten Sie QBZ dann neu:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" + +msgid "Streaming quality" +msgstr "Streaming-Qualität" + +msgid "The quality tier QBZ requests for playback." +msgstr "Die Qualitätsstufe, die QBZ für die Wiedergabe anfordert." + +msgid "Limit quality to device" +msgstr "Qualität auf Gerät begrenzen" + +msgid "Cap streaming quality at the output device's maximum sample rate." +msgstr "Begrenzt die Streaming-Qualität auf die maximale Abtastrate des Ausgabegeräts." + +msgid "Maximum sample rate" +msgstr "Maximale Abtastrate" + +msgid "The highest sample rate streamed tracks may use." +msgstr "Die höchste Abtastrate, die gestreamte Titel verwenden dürfen." + +msgid "OUTPUT" +msgstr "AUSGABE" + +msgid "Audio backend" +msgstr "Audio-Backend" + +msgid "The audio stack QBZ routes playback through." +msgstr "Der Audio-Stack, über den QBZ die Wiedergabe leitet." + +msgid "Output device" +msgstr "Ausgabegerät" + +msgid "The DAC or sound device that receives audio." +msgstr "Das DAC oder Audiogerät, das das Audiosignal empfängt." + +msgid "ALSA plugin" +msgstr "ALSA-Plugin" + +msgid "How ALSA opens the device — hw is bit-perfect, plughw converts." +msgstr "Wie ALSA das Gerät öffnet – hw ist bit-perfect, plughw konvertiert." + +msgid "Hardware volume control" +msgstr "Hardware-Lautstärkeregelung" + +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Steuert die Lautstärke über einen Mixer, den das ausgewählte ALSA-Gerät bereitstellt. Ist keiner verfügbar, bleibt die direkte Wiedergabe fest auf 100 %." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Dieses ALSA-Gerät hat keine kompatible Hardware-Lautstärkeregelung. Die direkte Wiedergabe bleibt fest auf 100 %." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ konnte das vorherige Audiogerät nicht vollständig freigeben. Details stehen im Audioprotokoll." + +msgid "BIT-PERFECT" +msgstr "BIT-PERFECT" + +msgid "Exclusive mode" +msgstr "Exklusivmodus" + +msgid "Lock the device so no other app can resample it." +msgstr "Sperrt das Gerät, sodass keine andere App es neu abtasten kann." + +msgid "Reserve DAC while running" +msgstr "DAC während des Betriebs reservieren" + +msgid "Hold the device reserved so other apps can't grab it." +msgstr "Hält das Gerät reserviert, sodass andere Apps es nicht belegen können." + +msgid "DAC passthrough" +msgstr "DAC-Passthrough" + +msgid "Send the bitstream untouched to the DAC." +msgstr "Sendet den Bitstream unverändert an das DAC." + +msgid "Force bit-perfect" +msgstr "Bit-perfect erzwingen" + +msgid "Pin the PipeWire quantum and rate for the active track." +msgstr "Fixiert das PipeWire-Quantum und die Rate für den aktiven Titel." + +msgid "Allow quality fallback" +msgstr "Qualitäts-Fallback zulassen" + +msgid "Drop to a lower tier when the requested one is unavailable." +msgstr "Wechselt zu einer niedrigeren Stufe, wenn die angeforderte nicht verfügbar ist." + +msgid "HiFi Wizard" +msgstr "HiFi-Assistent" + +msgid "Auto-detect your DACs and set up bit-perfect playback, step by step." +msgstr "Erkennt Ihre DACs automatisch und richtet die Bit-perfect-Wiedergabe Schritt für Schritt ein." + +msgid "Open Wizard" +msgstr "Assistent öffnen" + +msgid "STARTUP" +msgstr "START" + +msgid "Sync audio settings on startup" +msgstr "Audio-Einstellungen beim Start synchronisieren" + +msgid "Reload saved audio settings into the player when QBZ launches." +msgstr "Lädt beim Start von QBZ die gespeicherten Audioeinstellungen erneut in den Player." + +msgid "Lock output device" +msgstr "Ausgabegerät sperren" + +msgid "Keep external routing intact — skip switching the default sink." +msgstr "Behält das externe Routing bei – wechselt den Standard-Sink nicht." + +msgid "THEME" +msgstr "DESIGN" + +msgid "Theme" +msgstr "Design" + +msgid "Album header gradient" +msgstr "Farbverlauf im Album-Header" + +msgid "Use artwork-derived blur as a backdrop in album and artist detail views." +msgstr "Verwendet eine aus dem Cover abgeleitete Unschärfe als Hintergrund in den Detailansichten von Album und Künstler." + +msgid "Intelligent Search" +msgstr "Intelligente Suche" + +msgid "Smart search cache, ranking, and the search preview dropdown. On by default." +msgstr "Intelligenter Such-Cache, Ranking und das Dropdown-Menü mit der Suchvorschau. Standardmäßig aktiviert." + +msgid "Generate a color theme from your system wallpaper or a custom image" +msgstr "Erstellt ein Farbschema aus deinem System-Hintergrundbild oder einem eigenen Bild" + +msgid "Select Image..." +msgstr "Bild auswahlen..." + +msgid "Detected: " +msgstr "Erkannt: " + +msgid "Experimental: theme may not match your system exactly." +msgstr "Experimentell: Das Theme stimmt moglicherweise nicht exakt mit Ihrem System uberein." + +msgid "Regenerate" +msgstr "Neu generieren" + +msgid "TYPOGRAPHY & LANGUAGE" +msgstr "TYPOGRAFIE & SPRACHE" + +msgid "Language" +msgstr "Sprache" + +msgid "UI Scale" +msgstr "UI-Skalierung" + +msgid "Immersive search" +msgstr "Immersive Suche" + +msgid "What selecting a result in the Immersive search does. Disabled turns the in-immersive search off." +msgstr "Was beim Auswählen eines Ergebnisses in der immersiven Suche passiert. Deaktiviert schaltet die Suche im immersiven Modus aus." + +msgid "Immersive default view" +msgstr "Standard-Ansicht im Immersiv-Modus" + +msgid "Which immersive view opens by default. 'Remember last' restores your last view." +msgstr "Welche immersive Ansicht standardmäßig geöffnet wird. „Letzte merken“ stellt Ihre letzte Ansicht wieder her." + +msgid "LIBRARY & VISUALS" +msgstr "BIBLIOTHEK & VISUALS" + +msgid "Show navigation in sidebar" +msgstr "Navigation in der Seitenleiste anzeigen" + +msgid "Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar." +msgstr "Verschiebt die Bereiche Entdecken, Bibliothek, Lokale Bibliothek und Mein QBZ aus der Kopfzeile in die Seitenleiste." + +msgid "My QBZ" +msgstr "My QBZ" + +msgid "Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default." +msgstr "Benennt den Mein-QBZ-Hub um. Lassen Sie das Feld leer (oder klicken Sie auf Zurücksetzen), um den Standard wiederherzustellen." + +msgid "Show playlist cover collages in sidebar" +msgstr "Playlist-Cover-Collagen in der Seitenleiste anzeigen" + +msgid "Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images." +msgstr "Zeigt eine 2×2-Miniaturansicht der Titel-Cover neben jeder Playlist. Auf leistungsschwachen Geräten deaktivieren, um zusätzliche Bilder zu überspringen." + +msgid "Show album artwork in Local Library track list" +msgstr "Album-Cover in Local-Library-Titelliste anzeigen" + +msgid "Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost." +msgstr "Zeigt die Cover-Miniatur neben jedem Titel in den Titel- und Ordneransichten. Standardmäßig aus — große Bibliotheken zahlen pro Zeile einen Bild-Decode-Aufwand." + +msgid "NOTIFICATIONS" +msgstr "BENACHRICHTIGUNGEN" + +msgid "In-app toasts notifications" +msgstr "In-App-Benachrichtigungen" + +msgid "System Notifications" +msgstr "Systembenachrichtigungen" + +msgid "WINDOW TITLE" +msgstr "FENSTERTITEL" + +msgid "Show track in window title" +msgstr "Titel im Fenstertitel anzeigen" + +msgid "Title template" +msgstr "Titelvorlage" + +msgid "Placeholders: {{artist}}, {{track}}, {{album}}" +msgstr "Platzhalter: {{artist}}, {{track}}, {{album}}" + +msgid "TITLE BAR" +msgstr "TITELLEISTE" + +msgid "Use system title bar" +msgstr "System-Titelleiste verwenden" + +msgid "Replace QBZ custom title bar with your system's native window decorations. Changing this will restart the app." +msgstr "QBZ-eigene Titelleiste durch die native Fensterdekoration des Systems ersetzen. Änderung startet die App neu." + +msgid "Hide title bar" +msgstr "Titelleiste ausblenden" + +msgid "Hide the title bar completely (for tiling window manager users)" +msgstr "Titelleiste komplett ausblenden (für Tiling-Window-Manager-Nutzer)" + +msgid "WINDOW CONTROLS" +msgstr "FENSTERSTEUERUNG" + +msgid "Window controls position" +msgstr "Position der Fenstersteuerung" + +msgid "Place the window control buttons on the left or right side of the title bar" +msgstr "Fenstersteuerungstasten links oder rechts in der Titelleiste platzieren" + +msgid "Show window controls" +msgstr "Fenstersteuerungen anzeigen" + +msgid "Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these." +msgstr "Minimieren-, Maximieren- und Schliessen-Schaltflachen in der Titelleiste anzeigen. Deaktivieren, wenn Ihr Fenstermanager diese verwaltet." + +msgid "PLAYER VIEWS" +msgstr "PLAYER-ANSICHTEN" + +msgid "Show volume +/- buttons" +msgstr "Lautstärketasten +/- anzeigen" + +msgid "Add discrete plus and minus buttons next to the volume slider in the player bar." +msgstr "Fügt separate Plus- und Minus-Tasten neben dem Lautstärkeregler in der Player-Leiste hinzu." + +msgid "Mini player default view" +msgstr "Standardansicht des Miniplayers" + +msgid "Startup page" +msgstr "Startseite" + +msgid "Choose which page to show when the app starts" +msgstr "Wähle welche Seite beim App-Start angezeigt wird" + +msgid "Show Purchases" +msgstr "Einkäufe anzeigen" + +msgid "Show the Purchases section in the sidebar for browsing and downloading your purchased music" +msgstr "Einkäufe-Bereich in der Seitenleiste zum Durchsuchen und Herunterladen gekaufter Musik anzeigen" + +msgid "MENU BAR" +msgstr "MENÜLEISTE" + +msgid "SYSTEM TRAY" +msgstr "SYSTEM-TRAY" + +msgid "Enable tray icon" +msgstr "Tray-Symbol aktivieren" + +msgid "Show icon in system tray (requires restart)" +msgstr "Symbol im Systemtray anzeigen (Neustart erforderlich)" + +msgid "Close to tray" +msgstr "In den Systemtray schließen" + +msgid "Keep playing in the menu bar instead of quitting when you close the window" +msgstr "Spielt in der Menüleiste weiter, anstatt beim Schließen des Fensters zu beenden" + +msgid "Keep playing in the tray instead of quitting when you close the window" +msgstr "Spielt im Tray weiter, anstatt beim Schließen des Fensters zu beenden" + +msgid "Hide Dock icon when closed to menu bar" +msgstr "Dock-Symbol ausblenden, wenn in die Menüleiste geschlossen" + +msgid "Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)" +msgstr "Bei geschlossenem Fenster als reine Menüleisten-App ausführen. Aus: Dock-Symbol bleibt erhalten (wie Spotify)" + +msgid "Tray icon variant" +msgstr "Symbolvariante" + +msgid "Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo" +msgstr "Wähle ein monochromes Symbol passend zur Leiste (Plasma, die dauerhaft dunkle GNOME-Topbar) oder das farbige Vinyl-Logo" + +msgid "IMMERSIVE" +msgstr "IMMERSIV" + +msgid "Background effect" +msgstr "Hintergrundeffekt" + +msgid "Controls how the blurred artwork background is rendered in immersive mode." +msgstr "Steuert, wie der unscharfe Artwork-Hintergrund im Immersiv-Modus gerendert wird." + +msgid "Immersive Panels" +msgstr "Immersive Panels" + +msgid "Enable or disable individual immersive view panels" +msgstr "Einzelne Panels der immersiven Ansicht ein- oder ausschalten" + +msgid "Disabled panels are hidden from the visualizer menu so you don't land on them by mistake." +msgstr "Deaktivierte Panels werden im Visualizer-Menü ausgeblendet, damit du nicht versehentlich auf ihnen landest." + +msgid "Animation Frame Rate" +msgstr "Animationsbildrate" + +msgid "Per-animation FPS control for immersive mode" +msgstr "FPS-Steuerung pro Animation im Immersiv-Modus" + +msgid "Configure frame rate per animation. Lower values save CPU. Requires re-entering immersive mode to apply." +msgstr "Bildrate pro Animation konfigurieren. Niedrigere Werte sparen CPU. Erfordert erneutes Aufrufen des Immersiv-Modus." + +msgid "PLAYLISTS" +msgstr "PLAYLISTS" + +msgid "No playlists yet." +msgstr "Noch keine Playlists." + +msgid "Manage playlists" +msgstr "Playlists verwalten" + +msgid "Remove from queue" +msgstr "Aus Warteschlange entfernen" + +msgid "Stop after this" +msgstr "Danach stoppen" + +msgid "Remove all after" +msgstr "Alles danach entfernen" + +msgid "History" +msgstr "Verlauf" + +msgid "NOW PLAYING" +msgstr "JETZT LÄUFT" + +msgid "UP NEXT ({} of {}, {} left)" +msgstr "ALS NÄCHSTES ({} von {}, {} verbleibend)" + +msgid "Page {} of {}" +msgstr "Seite {} von {}" + +msgid "Your queue is empty" +msgstr "Deine Warteschlange ist leer" + +msgid "Play an album or track to get started" +msgstr "Album oder Titel abspielen, um zu beginnen" + +msgid "No tracks match your search" +msgstr "Keine Titel entsprechen deiner Suche" + +msgid "RECENTLY PLAYED" +msgstr "ZULETZT GESPIELT" + +msgid "Nothing played yet" +msgstr "Noch nichts abgespielt" + +msgid "Search queue" +msgstr "Warteschlange durchsuchen" + +msgid "Auto-follow" +msgstr "Auto-Scroll" + +msgid "Font" +msgstr "Schrift" + +msgid "System" +msgstr "System" + +msgid "LINE Seed JP" +msgstr "LINE Seed JP" + +msgid "Montserrat" +msgstr "Montserrat" + +msgid "Noto Sans" +msgstr "Noto Sans" + +msgid "Source Sans 3" +msgstr "Source Sans 3" + +msgid "Size" +msgstr "Größe" + +msgid "S" +msgstr "S" + +msgid "M" +msgstr "M" + +msgid "L" +msgstr "L" + +msgid "XL" +msgstr "XL" + +msgid "Active color" +msgstr "Aktive Farbe" + +msgid "Uppercase" +msgstr "Großbuchstaben" + +msgid "Dimming" +msgstr "Abblenden" + +msgid "Soft" +msgstr "Leicht" + +msgid "Strong" +msgstr "Stark" + +msgid "Copy lyrics" +msgstr "Liedtext kopieren" + +msgid "Reset" +msgstr "Zurücksetzen" + +msgid "Previous" +msgstr "Vorherige" + +msgid "Pause" +msgstr "Pause" + +msgid "Repeat" +msgstr "Wiederholen" + +msgid "Add to…" +msgstr "Hinzufügen zu…" + +msgid "Add album to collection" +msgstr "Album zur Sammlung hinzufügen" + +msgid "Remove album from collection" +msgstr "Album aus der Sammlung entfernen" + +msgid "Remove from library" +msgstr "Aus Bibliothek entfernen" + +msgid "{} (not playable)" +msgstr "{} (nicht abspielbar)" + +msgid "Cast" +msgstr "Streamen" + +msgid "Connected to" +msgstr "Verbunden mit" + +msgid "Streaming {} · {}" +msgstr "Streaming {} · {}" + +msgid "No devices found" +msgstr "Keine Geräte gefunden" + +msgid "Looking for Chromecast & DLNA on your network" +msgstr "Suche nach Chromecast & DLNA in deinem Netzwerk" + +msgid "Display settings" +msgstr "Anzeigeeinstellungen" + +msgid "Fetching lyrics..." +msgstr "Songtexte werden abgerufen..." + +msgid "No lyrics available" +msgstr "Keine Songtexte verfügbar" + +msgid "Missing lyrics? You can contribute them to LRCLIB:" +msgstr "Fehlende Liedtexte? Du kannst sie zu LRCLIB beitragen:" + +msgid "Lyrics unavailable offline" +msgstr "Liedtexte offline nicht verfügbar" + +msgid "Lyrics source" +msgstr "Quelle der Liedtexte" + +msgid "Qobuz Connect" +msgstr "Qobuz Connect" + +msgid "Connected" +msgstr "Verbunden" + +msgid "Disconnected" +msgstr "Getrennt" + +msgid "Playing on: {}" +msgstr "Wird abgespielt auf: {}" + +msgid "Looking for devices…" +msgstr "Suche nach Geräten…" + +msgid "Make sure your renderer is on the same network" +msgstr "Stelle sicher, dass sich dein Renderer im selben Netzwerk befindet" + +msgid "Play here" +msgstr "Hier abspielen" + +msgid "Audio settings" +msgstr "Audioeinstellungen" + +msgid "Normalization" +msgstr "Normalisierung" + +msgid "Level loudness across tracks" +msgstr "Lautstärke über Titel hinweg angleichen" + +msgid "Gapless" +msgstr "Lückenlos" + +msgid "Seamless track transitions" +msgstr "Nahtlose Titelübergänge" + +msgid "Now Playing view" +msgstr "Ansicht „Aktuelle Wiedergabe“" + +msgid "New" +msgstr "Neu" + +msgid "Classic" +msgstr "Klassisch" + +msgid "Miniplayer" +msgstr "Mini-Player" + +msgid "Immersive" +msgstr "Immersiv" + +msgid "Unmute" +msgstr "Stummschaltung aufheben" + +msgid "Mute" +msgstr "Stumm" + +msgid "Volume" +msgstr "Lautstärke" + +msgid "Qobuz Connect: On" +msgstr "Qobuz Connect: Ein" + +msgid "Qobuz Connect: Off" +msgstr "Qobuz Connect: Aus" + +msgid "Volume down" +msgstr "Lautstärke verringern" + +msgid "Volume up" +msgstr "Lautstärke erhöhen" + +msgid "Nothing playing" +msgstr "Nichts wird wiedergegeben" + +msgid "Go to where this is playing from" +msgstr "Zur Wiedergabequelle wechseln" + +msgid "Casting" +msgstr "Wird übertragen" + +msgid "Output mode" +msgstr "Ausgabemodus" + +msgid "Move to root" +msgstr "Zum Root wechseln" + +msgid "No folders yet." +msgstr "Noch keine Ordner." + +msgid "Discover" +msgstr "Entdecken" + +msgid "Mixtapes" +msgstr "Mixtapes" + +msgid "Collections" +msgstr "Collections" + +msgid "Search playlists" +msgstr "Playlists durchsuchen" + +msgid "Sort by" +msgstr "Sortieren nach" + +msgid "Name (A-Z)" +msgstr "Name (A-Z)" + +msgid "# of tracks" +msgstr "Anzahl der Titel" + +msgid "Refresh" +msgstr "Aktualisieren" + +msgid "Logged out" +msgstr "Abgemeldet" + +msgid "Offline (manual)" +msgstr "Offline (manuell)" + +msgid "Offline (hard)" +msgstr "Offline (erzwungen)" + +msgid "You're signed out. Sign-in needs a connection." +msgstr "Sie sind abgemeldet. Für die Anmeldung ist eine Verbindung erforderlich." + +msgid "Connection available — sign back in to Qobuz." +msgstr "Verbindung verfügbar – melden Sie sich wieder bei Qobuz an." + +msgid "You're signed out — sign back in to Qobuz." +msgstr "Sie sind abgemeldet – melden Sie sich wieder bei Qobuz an." + +msgid "No internet connection — your local library and downloads keep working." +msgstr "Keine Internetverbindung – Ihre lokale Bibliothek und Downloads funktionieren weiterhin." + +msgid "Sign in" +msgstr "Anmelden" + +msgid "Offline mode" +msgstr "Offline-Modus" + +msgid "Enter" +msgstr "Eingabetaste" + +msgid "Guest" +msgstr "Gast" + +msgid "Not signed in" +msgstr "Nicht angemeldet" + +msgid "Keyboard Shortcuts" +msgstr "Tastenkürzel" + +msgid "Documentation" +msgstr "Dokumentation" + +msgid "Report an Issue" +msgstr "Problem melden" + +msgid "Log Out" +msgstr "Abmelden" + +msgid "Create Mixtape" +msgstr "Mixtape erstellen" + +msgid "Create Collection" +msgstr "Collection erstellen" + +msgid "NAME" +msgstr "NAME" + +msgid "KIND" +msgstr "ART" + +msgid "+ New Mixtape" +msgstr "+ Neues Mixtape" + +msgid "+ New Collection" +msgstr "+ Neue Collection" + +msgid "Rename" +msgstr "Umbenennen" + +msgid "Edit description" +msgstr "Beschreibung bearbeiten" + +msgid "Delete \"{}\"? This cannot be undone." +msgstr "\"{}\" löschen? Dies kann nicht rückgängig gemacht werden." + +msgid "All ({})" +msgstr "Alle ({})" + +msgid "Random queue" +msgstr "Zufällige Warteschlange" + +msgid "Create a random queue with songs from this collection. How many songs do you want?" +msgstr "Erstelle eine zufällige Warteschlange mit Songs aus dieser Sammlung. Wie viele Songs möchtest du?" + +msgid "Number of songs" +msgstr "Anzahl der Songs" + +msgid "{} songs available" +msgstr "{} Songs verfügbar" + +msgid "No mixtapes yet" +msgstr "Noch keine Mixtapes" + +msgid "Position" +msgstr "Reihenfolge" + +msgid "Items" +msgstr "Elemente" + +msgid "Recently updated" +msgstr "Zuletzt aktualisiert" + +msgid "No results found" +msgstr "Keine Ergebnisse gefunden" + +msgid "No collections yet" +msgstr "Noch keine Collections" + +msgid "All kinds" +msgstr "Alle Typen" + +msgid "Artist Collections" +msgstr "Artist Collections" + +msgid "Remove from collection" +msgstr "Aus Sammlung entfernen" + +msgid "Not found" +msgstr "Nicht gefunden" + +msgid "Upload cover" +msgstr "Cover hochladen" + +msgid "Clear custom cover" +msgstr "Eigenes Cover entfernen" + +msgid "Album shuffle" +msgstr "Alben-Zufallsmodus" + +msgid "In order" +msgstr "Der Reihe nach" + +msgid "Convert to Collection" +msgstr "In Collection umwandeln" + +msgid "Convert to Mixtape" +msgstr "In Mixtape umwandeln" + +msgid "No items yet. Add albums, tracks, or playlists from their detail pages." +msgstr "Noch keine Elemente. Fügen Sie Alben, Titel oder Wiedergabelisten über deren Detailseiten hinzu." + +msgid "1 filter" +msgstr "1 Filter" + +msgid "{} filters" +msgstr "{} Filter" + +msgid "Type" +msgstr "Typ" + +msgid "All types" +msgstr "Alle" + +msgid "Track" +msgstr "Titel" + +msgid "Qobuz" +msgstr "Qobuz" + +msgid "Plex" +msgstr "Plex" + +msgid "Item" +msgstr "Element" + +msgid "Already added" +msgstr "Bereits hinzugefügt" + +msgid "ADD TO MIXTAPE/COLLECTION" +msgstr "ZU MIXTAPE/SAMMLUNG HINZUFÜGEN" + +msgid "No matches." +msgstr "Keine Treffer." + +msgid "No mixtapes or collections yet." +msgstr "Noch keine Mixtapes oder Sammlungen." + +msgid "EP" +msgstr "EP" + +msgid "Single" +msgstr "Single" + +msgid "Live" +msgstr "Live" + +msgid "Compilation" +msgstr "Compilation" + +msgid "Use auto-detected" +msgstr "Auto-erkannten verwenden" + +msgid "alternate" +msgstr "Alternative" + +msgid "compilation" +msgstr "Compilation" + +msgid "BUILD ARTIST COLLECTION" +msgstr "KÜNSTLERSAMMLUNG ERSTELLEN" + +msgid "ORDER BY" +msgstr "SORTIEREN NACH" + +msgid "Release date" +msgstr "Veröffentlichungsdatum" + +msgid "Manual" +msgstr "Manuell" + +msgid "Loading..." +msgstr "Lädt..." + +msgid "ALBUM" +msgstr "ALBUM" + +msgid "{} of {} album selected" +msgid_plural "{} of {} albums selected" +msgstr[0] "{} von {} Album ausgewählt" +msgstr[1] "{} von {} Alben ausgewählt" + +msgid "Now Playing" +msgstr "Aktuell abgespielt" + +msgid "No lyrics" +msgstr "Kein Liedtext" + +msgid "No track selected" +msgstr "Kein Titel ausgewählt" + +msgid "Loading suggestions..." +msgstr "Vorschläge werden geladen..." + +msgid "Failed to load suggestions" +msgstr "Vorschläge konnten nicht geladen werden" + +msgid "Recommended tracks" +msgstr "Empfohlene Titel" + +msgid "Exit Immersive" +msgstr "Immersiv-Modus beenden" + +msgid "FOCUS" +msgstr "FOKUS" + +msgid "Plasma" +msgstr "Plasma" + +msgid "Aurora" +msgstr "Aurora" + +msgid "SPLIT" +msgstr "TEILEN" + +msgid "Track Info" +msgstr "Titelinformationen" + +msgid "Suggestions" +msgstr "Vorschläge" + +msgid "UP NEXT" +msgstr "ALS NÄCHSTES" + +msgid "Tracks you play will appear here" +msgstr "Von Ihnen abgespielte Titel werden hier angezeigt" + +msgid "No track playing" +msgstr "Kein Titel wird abgespielt" + +msgid "Queue is empty" +msgstr "Warteschlange ist leer" + +msgid "Search albums & tracks…" +msgstr "Alben und Titel suchen…" + +msgid "Acclaimed Releases" +msgstr "Gefeierte Veröffentlichungen" + +msgid "No award-winning releases yet." +msgstr "Noch keine ausgezeichneten Veröffentlichungen." + +msgid "Award" +msgstr "Auszeichnung" + +msgid "Unfollow award" +msgstr "Nicht mehr folgen" + +msgid "Follow award" +msgstr "Auszeichnung folgen" + +msgid "See All" +msgstr "Alle anzeigen" + +msgid "Other awards" +msgstr "Weitere Auszeichnungen" + +msgid "{} item needs attention before bit-perfect playback will work." +msgid_plural "{} items need attention before bit-perfect playback will work." +msgstr[0] "{} Element erfordert Aufmerksamkeit, bevor die bit-perfect-Wiedergabe funktioniert." +msgstr[1] "{} Elemente erfordern Aufmerksamkeit, bevor die bit-perfect-Wiedergabe funktioniert." + +msgid "Removed {} artist from blacklist" +msgid_plural "Removed {} artists from blacklist" +msgstr[0] "{} Künstler aus der Sperrliste entfernt" +msgstr[1] "{} Künstler aus der Sperrliste entfernt" + +msgid "Removed {} folder" +msgid_plural "Removed {} folders" +msgstr[0] "{} Ordner entfernt" +msgstr[1] "{} Ordner entfernt" + +msgid "Scan complete ({} file skipped)" +msgid_plural "Scan complete ({} files skipped)" +msgstr[0] "Scan abgeschlossen ({} Datei übersprungen)" +msgstr[1] "Scan abgeschlossen ({} Dateien übersprungen)" + +msgid "{} album" +msgid_plural "{} albums" +msgstr[0] "{} Album" +msgstr[1] "{} Alben" + +msgid "{} item" +msgid_plural "{} items" +msgstr[0] "{} Element" +msgstr[1] "{} Elemente" + +msgid "{} duplicate skipped" +msgid_plural "{} duplicates skipped" +msgstr[0] "{} Duplikat übersprungen" +msgstr[1] "{} Duplikate übersprungen" + +msgid "Removed {} item" +msgid_plural "Removed {} items" +msgstr[0] "{} Element entfernt" +msgstr[1] "{} Elemente entfernt" + +msgid "Caching {} track offline…" +msgid_plural "Caching {} tracks offline…" +msgstr[0] "{} Titel wird offline zwischengespeichert…" +msgstr[1] "{} Titel werden offline zwischengespeichert…" + +msgid "(Re)start the PipeWire audio services" +msgstr "PipeWire-Audiodienste (neu) starten" + +msgid "({} local)" +msgstr "({} lokal)" + +msgid "176.4 kHz" +msgstr "176.4 kHz" + +msgid "192 kHz" +msgstr "192 kHz" + +msgid "352.8 kHz" +msgstr "352.8 kHz" + +msgid "384 kHz" +msgstr "384 kHz" + +msgid "44.1 kHz" +msgstr "44.1 kHz" + +msgid "48 kHz" +msgstr "48 kHz" + +msgid "88.2 kHz" +msgstr "88.2 kHz" + +msgid "96 kHz" +msgstr "96 kHz" + +msgid "A fresh mix every week." +msgstr "Jede Woche ein frischer Mix." + +msgid "Add a folder before scanning" +msgstr "Fügen Sie vor dem Scannen einen Ordner hinzu" + +msgid "Added to queue" +msgstr "Zur Warteschlange hinzugefügt" + +msgid "Added {} to {}" +msgstr "{} zu {} hinzugefügt" + +msgid "Added {} to {} ({})" +msgstr "{} zu {} hinzugefügt ({})" + +msgid "Adding tracks to playlist..." +msgstr "Titel zur Playlist hinzufugen..." + +msgid "Adding tracks: {} / {}" +msgstr "Titel werden hinzugefügt: {} / {}" + +msgid "Album folder not found on disk" +msgstr "Albumordner nicht auf der Festplatte gefunden" + +msgid "Album metadata saved" +msgstr "Album-Metadaten gespeichert" + +msgid "Album title is required" +msgstr "Albumtitel ist erforderlich" + +msgid "Albums of the Week" +msgstr "Alben der Woche" + +msgid "Albums on Local Library" +msgstr "Alben in der lokalen Bibliothek" + +msgid "Already in {}" +msgstr "Bereits in {}" + +msgid "Always skip track" +msgstr "Immer Titel ueberspringen" + +msgid "Always try lowest quality" +msgstr "Immer niedrigste Qualitaet versuchen" + +msgid "April" +msgstr "April" + +msgid "Are you absolutely sure?" +msgstr "Sind Sie absolut sicher?" + +msgid "Artist Spotlight" +msgstr "Künstler im Fokus" + +msgid "Artists on Local Library" +msgstr "Künstler in der lokalen Bibliothek" + +msgid "Ask me" +msgstr "Mich fragen" + +msgid "August" +msgstr "August" + +msgid "Authorize QBZ in your browser, then click \"Finish\"" +msgstr "Autorisieren Sie QBZ in Ihrem Browser und klicken Sie dann auf \"Fertigstellen\"" + +msgid "Available tracks only — offline" +msgstr "Nur verfügbare Titel — offline" + +msgid "Bit-perfect (Hardware / Digital)" +msgstr "Bit-perfect (Hardware / Digital)" + +msgid "Blacklist disabled" +msgstr "Sperrliste deaktiviert" + +msgid "Blacklist enabled" +msgstr "Sperrliste aktiviert" + +msgid "Booklet open task failed: {}" +msgstr "Öffnen des Booklets fehlgeschlagen: {}" + +msgid "COLLECTION" +msgstr "COLLECTION" + +msgid "Cache cleared" +msgstr "Cache geleert" + +msgid "Cached for offline" +msgstr "Für Offline-Nutzung gespeichert" + +msgid "Cannot convert this kind" +msgstr "Dieser Typ kann nicht konvertiert werden" + +msgid "Checked {} tracks - all OK" +msgstr "{} Titel geprüft - alles in Ordnung" + +msgid "Checking playlist link..." +msgstr "Playlist-Link wird geprüft..." + +msgid "Checking your audio stack…" +msgstr "Ihr Audio-Stack wird geprüft…" + +msgid "Couldn't load the test tracks (offline?)" +msgstr "Die Testtitel konnten nicht geladen werden (offline?)" + +msgid "Choose a cover image" +msgstr "Wählen Sie ein Coverbild" + +msgid "Choose a folder to play" +msgstr "Ordner zum Abspielen auswählen" + +msgid "Choose a {} icon" +msgstr "Wählen Sie ein {}-Symbol" + +msgid "Cleaning up missing files..." +msgstr "Fehlende Dateien werden bereinigt..." + +msgid "Cleanup failed" +msgstr "Bereinigung fehlgeschlagen" + +msgid "Clear Plex cache?" +msgstr "Plex-Cache leeren?" + +msgid "Clear library database?" +msgstr "Bibliotheksdatenbank leeren?" + +msgid "Code copied" +msgstr "Code kopiert" + +msgid "Compilations" +msgstr "Kompilationen" + +msgid "Connected as {}" +msgstr "Verbunden als {}" + +msgid "Connected to {} (v{})" +msgstr "Verbunden mit {} (v{})" + +msgid "Continue Listening" +msgstr "Weiter hören" + +msgid "Converted" +msgstr "Konvertiert" + +msgid "Couldn't add folder" +msgstr "Ordner konnte nicht hinzugefügt werden" + +msgid "Couldn't change folder location (path may already exist)" +msgstr "Ordnerspeicherort konnte nicht geändert werden (Pfad existiert möglicherweise bereits)" + +msgid "Couldn't clean up missing files" +msgstr "Fehlende Dateien konnten nicht bereinigt werden" + +msgid "Couldn't clear the Plex cache" +msgstr "Plex-Cache konnte nicht geleert werden" + +msgid "Couldn't clear the library database" +msgstr "Bibliotheksdatenbank konnte nicht geleert werden" + +msgid "Couldn't clear the offline cache" +msgstr "Offline-Cache konnte nicht geleert werden" + +msgid "Couldn't create the Qobuz playlist" +msgstr "Qobuz-Playlist konnte nicht erstellt werden" + +msgid "Couldn't load library folders" +msgstr "Bibliotheksordner konnten nicht geladen werden" + +msgid "Couldn't load that album" +msgstr "Dieses Album konnte nicht geladen werden" + +msgid "Couldn't load that playlist" +msgstr "Diese Playlist konnte nicht geladen werden" + +msgid "Couldn't load that track" +msgstr "Dieser Titel konnte nicht geladen werden" + +msgid "Couldn't load this album" +msgstr "Dieses Album konnte nicht geladen werden" + +msgid "Couldn't load this artist" +msgstr "Dieser Künstler konnte nicht geladen werden" + +msgid "Couldn't load this collection" +msgstr "Diese Sammlung konnte nicht geladen werden" + +msgid "Couldn't load this playlist" +msgstr "Diese Playlist konnte nicht geladen werden" + +msgid "Couldn't open that folder" +msgstr "Dieser Ordner konnte nicht geöffnet werden" + +msgid "Couldn't save folder settings" +msgstr "Ordnereinstellungen konnten nicht gespeichert werden" + +msgid "Couldn't save metadata: {}" +msgstr "Metadaten konnten nicht gespeichert werden: {}" + +msgid "Cover removed" +msgstr "Cover entfernt" + +msgid "Cover updated" +msgstr "Cover aktualisiert" + +msgid "Creating playlist..." +msgstr "Playlist erstellen..." + +msgid "Critics' Picks" +msgstr "Kritiker-Auswahl" + +msgid "DAC: {} · {} · {} ch" +msgstr "DAC: {} · {} · {} Kan." + +msgid "December" +msgstr "Dezember" + +msgid "Defaults" +msgstr "Standardwerte" + +msgid "Disconnect from Plex?" +msgstr "Verbindung zu Plex trennen?" + +msgid "EPs & Singles" +msgstr "EPs & Singles" + +msgid "Enable PipeWire in your NixOS configuration" +msgstr "PipeWire in Ihrer NixOS-Konfiguration aktivieren" + +msgid "Enter a title or artist to search" +msgstr "Titel oder Interpret zum Suchen eingeben" + +msgid "Enter code {} at the Plex sign-in page" +msgstr "Code {} auf der Plex-Anmeldeseite eingeben" + +msgid "Error: {}" +msgstr "Fehler: {}" + +msgid "Error: {} (did you authorize in the browser?)" +msgstr "Fehler: {} (Haben Sie die Autorisierung im Browser erteilt?)" + +msgid "Essentials by Genre" +msgstr "Essentials nach Genre" + +msgid "Every track needs a title" +msgstr "Jeder Titel benötigt einen Namen" + +msgid "Failed to cast track" +msgstr "Titel konnte nicht gecastet werden" + +msgid "Failed to change play mode" +msgstr "Wiedergabemodus konnte nicht geändert werden" + +msgid "Failed to clear blacklist" +msgstr "Sperrliste konnte nicht geleert werden" + +msgid "Failed to clear lyrics cache" +msgstr "Liedtext-Cache konnte nicht geleert werden" + +msgid "Failed to create temp dir: {}" +msgstr "Temporäres Verzeichnis konnte nicht erstellt werden: {}" + +msgid "Failed to decode booklet page: {}" +msgstr "Booklet-Seite konnte nicht dekodiert werden: {}" + +msgid "Failed to delete" +msgstr "Löschen fehlgeschlagen" + +msgid "Failed to fetch booklet: {}" +msgstr "Booklet konnte nicht abgerufen werden: {}" + +msgid "Failed to fetch metadata" +msgstr "Metadaten konnten nicht abgerufen werden" + +msgid "Failed to read booklet: {}" +msgstr "Booklet konnte nicht gelesen werden: {}" + +msgid "Failed to remove artist" +msgstr "Interpret konnte nicht entfernt werden" + +msgid "Failed to remove cover" +msgstr "Cover konnte nicht entfernt werden" + +msgid "Failed to remove items" +msgstr "Elemente konnten nicht entfernt werden" + +msgid "Failed to rename" +msgstr "Umbenennen fehlgeschlagen" + +msgid "Failed to save description" +msgstr "Beschreibung konnte nicht gespeichert werden" + +msgid "Failed to toggle blacklist" +msgstr "Sperrliste konnte nicht umgeschaltet werden" + +msgid "Failed to upload cover" +msgstr "Cover konnte nicht hochgeladen werden" + +msgid "Failed to write booklet: {}" +msgstr "Booklet konnte nicht geschrieben werden: {}" + +msgid "Library Albums" +msgstr "Alben aus deiner Bibliothek" + +msgid "February" +msgstr "Februar" + +msgid "File not available — is the drive mounted?" +msgstr "Datei nicht verfügbar – ist das Laufwerk eingebunden?" + +msgid "Files not available — is the drive mounted?" +msgstr "Dateien nicht verfügbar – ist das Laufwerk eingebunden?" + +msgid "Folder opened" +msgstr "Ordner geöffnet" + +msgid "Found {} tracks from {}." +msgstr "{} Titel von {} gefunden." + +msgid "From the same artist" +msgstr "Vom selben Interpreten" + +msgid "From your most-played playlists." +msgstr "Aus Ihren meistgespielten Wiedergabelisten." + +msgid "Ideal Discography" +msgstr "Ideale Diskografie" + +msgid "Image" +msgstr "Bild" + +msgid "Import failed: {}" +msgstr "Import fehlgeschlagen: {}" + +msgid "Imported {} of {} tracks into QBZ." +msgstr "{} von {} Titeln in QBZ importiert." + +msgid "Install the ALSA bridge so playback can reach PipeWire" +msgstr "Installieren Sie die ALSA-Brücke, damit die Wiedergabe PipeWire erreicht" + +msgid "Install the PipeWire audio stack" +msgstr "Installieren Sie den PipeWire-Audio-Stack" + +msgid "Install the PipeWire tools (pw-dump)" +msgstr "Installieren Sie die PipeWire-Tools (pw-dump)" + +msgid "Install the Pulse compatibility tools (optional fallback)" +msgstr "Installieren Sie die Pulse-Kompatibilitätstools (optionaler Fallback)" + +msgid "January" +msgstr "Januar" + +msgid "July" +msgstr "Juli" + +msgid "June" +msgstr "Juni" + +msgid "Last.fm disconnected" +msgstr "Last.fm getrennt" + +msgid "Last.fm sign-in failed to start" +msgstr "Die Anmeldung bei Last.fm konnte nicht gestartet werden" + +msgid "Library database cleared" +msgstr "Bibliotheksdatenbank geleert" + +msgid "ListenBrainz disconnected" +msgstr "ListenBrainz getrennt" + +msgid "Listening suggestions" +msgstr "Hörvorschläge" + +msgid "Loaded {} cached tracks" +msgstr "{} zwischengespeicherte Titel geladen" + +msgid "Log in to cache tracks offline" +msgstr "Melden Sie sich an, um Titel offline zu speichern" + +msgid "Lossless: {}" +msgstr "LOSSLESS: {}" + +msgid "Lyrics cache cleared" +msgstr "Liedtext-Cache geleert" + +msgid "Lyrics copied" +msgstr "Liedtext kopiert" + +msgid "MIXTAPE" +msgstr "MIXTAPE" + +msgid "March" +msgstr "März" + +msgid "Matching tracks: {} / {} ({} found)" +msgstr "Übereinstimmende Titel: {} / {} ({} gefunden)" + +msgid "May" +msgstr "Mai" + +msgid "Middle-click to pause" +msgstr "Mittelklick zum Pausieren" + +msgid "Middle-click to play" +msgstr "Mittelklick zum Abspielen" + +msgid "Mixed queue — not cast to Qobuz Connect" +msgstr "Gemischte Warteschlange – nicht an Qobuz Connect übertragen" + +msgid "More From Your Library" +msgstr "Mehr aus Favoriten" + +msgid "More Labels" +msgstr "Weitere Labels" + +msgid "Most Streamed" +msgstr "Am häufigsten gestreamt" + +msgid "" +"Music Player\n" +"Nothing playing" +msgstr "" +"Musikplayer\n" +"Nichts wird wiedergegeben" + +msgid "Network folder detected" +msgstr "Netzwerkordner erkannt" + +msgid "Never" +msgstr "Nie" + +msgid "New Releases" +msgstr "Neuerscheinungen" + +msgid "Next Track" +msgstr "Nächster Titel" + +msgid "No limit" +msgstr "Kein Limit" + +msgid "No matching tracks found." +msgstr "Keine übereinstimmenden Titel gefunden." + +msgid "No sinks detected — reinstall the ALSA UCM profiles, then reboot" +msgstr "Keine Sinks erkannt – installieren Sie die ALSA-UCM-Profile neu und starten Sie dann neu" + +msgid "No top tracks available for this artist" +msgstr "Für diesen Künstler sind keine Top-Titel verfügbar" + +msgid "No tracks available offline" +msgstr "Keine Titel offline verfügbar" + +msgid "Not available offline" +msgstr "Offline nicht verfügbar" + +msgid "Nothing playable in this playlist right now" +msgstr "In dieser Playlist ist derzeit nichts abspielbar" + +msgid "November" +msgstr "November" + +msgid "October" +msgstr "Oktober" + +msgid "Offline cache cleared" +msgstr "Offline-Cache geleert" + +msgid "Offline cache is full — free space or raise the limit" +msgstr "Offline-Cache ist voll — Speicher freigeben oder Limit erhöhen" + +msgid "Offline caching failed" +msgstr "Offline-Caching fehlgeschlagen" + +msgid "Offline listening period expired — reconnect to verify your subscription" +msgstr "Offline-Hörzeitraum abgelaufen — verbinden Sie sich erneut, um Ihr Abonnement zu überprüfen" + +msgid "On Local Library" +msgstr "In lokaler Bibliothek" + +msgid "Opened folder ({} files skipped)" +msgstr "Ordner geöffnet ({} Dateien übersprungen)" + +msgid "Other Outputs" +msgstr "Andere Ausgänge" + +msgid "PLAYLIST" +msgstr "PLAYLIST" + +msgid "Paste your ListenBrainz user token first" +msgstr "Fügen Sie zuerst Ihr ListenBrainz-Benutzertoken ein" + +msgid "Play/Pause" +msgstr "Wiedergabe/Pause" + +msgid "Playing next" +msgstr "Als Nächstes" + +msgid "Playing {} of {}" +msgstr "{} von {} wird abgespielt" + +msgid "Playlist ({} local)" +msgstr "Playlist ({} lokal)" + +msgid "Playlist created in Qobuz™." +msgstr "Playlist in Qobuz™ erstellt." + +msgid "Playlist uploaded to Qobuz" +msgstr "Playlist zu Qobuz hochgeladen" + +msgid "Playlist: {}" +msgstr "Playlist: {}" + +msgid "Plex link failed to start" +msgstr "Plex-Verknüpfung konnte nicht gestartet werden" + +msgid "Plugin Hardware" +msgstr "Plugin-Hardware" + +msgid "Press Accolades" +msgstr "Presseauszeichnungen" + +msgid "Previous Track" +msgstr "Vorheriger Titel" + +msgid "Purchase Only" +msgstr "Nur Kauf" + +msgid "QBZ requesting {} · {}-bit" +msgstr "QBZ fordert {} · {}-bit an" + +msgid "Qobuz Connect is unavailable while offline" +msgstr "Qobuz Connect ist offline nicht verfügbar" + +msgid "Qobuz Connect renderer disconnected" +msgstr "Qobuz Connect-Renderer getrennt" + +msgid "Qobuz Connect renderer unreachable" +msgstr "Qobuz Connect-Renderer nicht erreichbar" + +msgid "Qobuzissimes" +msgstr "Qobuzissimes" + +msgid "Quit QBZ" +msgstr "QBZ beenden" + +msgid "Rate limited, try again shortly" +msgstr "Anfragelimit erreicht, versuchen Sie es in Kürze erneut" + +msgid "Recently Played" +msgstr "Kürzlich gespielt" + +msgid "Rediscover Your Library" +msgstr "Entdecken Sie Ihre Bibliothek neu" + +msgid "Release Watch" +msgstr "Release-Radar" + +msgid "Removed \"{}\" ({})" +msgstr "„{}“ entfernt ({})" + +msgid "Removed album from offline" +msgstr "Album aus Offline entfernt" + +msgid "Removed from offline" +msgstr "Aus Offline entfernt" + +msgid "Removed {} of {} tracks" +msgstr "{} von {} Titeln entfernt" + +msgid "Scan cancelled" +msgstr "Scan abgebrochen" + +msgid "Scan complete" +msgstr "Scan abgeschlossen" + +msgid "Scan failed" +msgstr "Scan fehlgeschlagen" + +msgid "Scanning track paths..." +msgstr "Titelpfade werden gescannt..." + +msgid "Scroll to adjust volume" +msgstr "Zum Anpassen der Lautstärke scrollen" + +msgid "Search failed: {}" +msgstr "Suche fehlgeschlagen: {}" + +msgid "Searching Qobuz catalog..." +msgstr "Qobuz-Katalog durchsuchen..." + +msgid "Select music folder" +msgstr "Musikordner auswählen" + +msgid "September" +msgstr "September" + +msgid "Show/Hide Window" +msgstr "Fenster ein-/ausblenden" + +msgid "Similar to {}" +msgstr "Ähnlich wie {}" + +msgid "Skipped: {}" +msgstr "Übersprungen: {}" + +msgid "Some tracks can't be cast to Qobuz Connect" +msgstr "Einige Titel können nicht an Qobuz Connect gesendet werden" + +msgid "Song Radio" +msgstr "Song-Radio" + +msgid "Split into {} playlists (Qobuz 2000-track limit)" +msgstr "In {} Wiedergabelisten aufgeteilt (Qobuz-Limit von 2000 Titeln)" + +msgid "Start the sign-in first" +msgstr "Starten Sie zuerst die Anmeldung" + +msgid "Starting…" +msgstr "Wird gestartet…" + +msgid "System default" +msgstr "Systemstandard" + +msgid "TRACK" +msgstr "TITEL" + +msgid "These items resolved to 0 playable tracks" +msgstr "Diese Elemente ergaben 0 abspielbare Titel" + +msgid "This action cannot be undone." +msgstr "Diese Aktion kann nicht rückgängig gemacht werden." + +msgid "This album has no playable tracks" +msgstr "Dieses Album hat keine abspielbaren Titel" + +msgid "This collection resolved to 0 playable tracks" +msgstr "Diese Sammlung ergab 0 abspielbare Titel" + +msgid "This item resolved to 0 playable tracks" +msgstr "Dieses Element ergab 0 abspielbare Titel" + +msgid "This modifies your audio files on disk and cannot be undone." +msgstr "Dies ändert Ihre Audiodateien auf der Festplatte und kann nicht rückgängig gemacht werden." + +msgid "This playlist has no playable tracks" +msgstr "Diese Wiedergabeliste hat keine abspielbaren Titel" + +msgid "This removes ALL indexed tracks from the database. Your audio files are NOT deleted. You will need to re-scan your folders afterward." +msgstr "Dies entfernt ALLE indizierten Titel aus der Datenbank. Ihre Audiodateien werden NICHT gelöscht. Sie müssen Ihre Ordner anschließend erneut scannen." + +msgid "This removes cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Dies entfernt zwischengespeicherte Plex-Bibliotheken und -Titel. Ihre Anmeldung bleibt erhalten." + +msgid "This signs out of Plex and clears the locally cached libraries and tracks." +msgstr "Dies meldet Sie von Plex ab und löscht die lokal zwischengespeicherten Bibliotheken und Titel." + +msgid "This track is no longer available" +msgstr "Dieser Titel ist nicht mehr verfügbar" + +msgid "This track is not yet available" +msgstr "Dieser Titel ist noch nicht verfügbar" + +msgid "Track count differs from the result; titles applied by position" +msgstr "Die Titelanzahl weicht vom Ergebnis ab; Titel wurden nach Position zugewiesen" + +msgid "Track not available offline" +msgstr "Titel offline nicht verfügbar" + +msgid "Track not castable to Qobuz Connect" +msgstr "Titel kann nicht an Qobuz Connect gesendet werden" + +msgid "Track unavailable on Qobuz Connect" +msgstr "Titel auf Qobuz Connect nicht verfügbar" + +msgid "Tracks matched: {} / {}" +msgstr "Übereinstimmende Titel: {} / {}" + +msgid "Unavailable Plex track" +msgstr "Plex-Titel nicht verfügbar" + +msgid "Unavailable track" +msgstr "Titel nicht verfügbar" + +msgid "Unknown" +msgstr "Unbekannt" + +msgid "Unknown Artist" +msgstr "Unbekannter Künstler" + +msgid "Unknown source" +msgstr "Unbekannte Quelle" + +msgid "Upcoming" +msgstr "Demnächst" + +msgid "Upload incomplete — local playlist kept" +msgstr "Upload unvollständig — lokale Wiedergabeliste beibehalten" + +msgid "Various Artists" +msgstr "Verschiedene Künstler" + +msgid "Waiting for the DAC to start playing…" +msgstr "Warten, bis der DAC mit der Wiedergabe beginnt…" + +msgid "Write tags to audio files?" +msgstr "Tags in die Audiodateien schreiben?" + +msgid "Writing tags to files isn't supported for CUE albums; use sidecar mode" +msgstr "Das Schreiben von Tags in Dateien wird für CUE-Alben nicht unterstützt; verwenden Sie den Sidecar-Modus" + +msgid "Year must be a number" +msgstr "Das Jahr muss eine Zahl sein" + +msgid "You're offline — try again when connected" +msgstr "Sie sind offline — versuchen Sie es erneut, wenn Sie verbunden sind" + +msgid "Your audio stack is ready for bit-perfect playback." +msgstr "Ihr Audio-Stack ist bereit für bit-perfect Wiedergabe." + +msgid "Your queue is empty — add some tracks first, or press Play test." +msgstr "Ihre Warteschlange ist leer — fügen Sie zuerst einige Titel hinzu oder drücken Sie Test abspielen." + +msgid "by {}" +msgstr "von {}" + +msgid "hw (Direct Hardware)" +msgstr "hw (Direkte Hardware)" + +msgid "pcm (Most compatible)" +msgstr "pcm (Am kompatibelsten)" + +msgid "plughw (Auto-convert)" +msgstr "plughw (Automatische Konvertierung)" + +msgid "{} + {} more" +msgstr "{} + {} weitere" + +msgid "{} albums · {} tracks" +msgstr "{} Alben · {} Titel" + +msgid "{} h {} min" +msgstr "{} Std. {} Min." + +msgid "{} libraries found" +msgstr "{} Bibliotheken gefunden" + +msgid "{} min" +msgstr "{} Min." + +msgid "{} removed from blacklist" +msgstr "{} von der Sperrliste entfernt" + +msgid "{} tracks loaded" +msgstr "{} Titel geladen" + +msgid "{} tracks · {}" +msgstr "{} Titel · {}" + +msgid "{} {}" +msgstr "{} {}" + +msgid "{} {}, {}" +msgstr "{} {}, {}" + +msgid "{} — Complete Discography" +msgstr "{} — Vollständige Diskografie" + +msgid "· Unlimited" +msgstr "· Unbegrenzt" + +msgid "· of {}" +msgstr "· von {}" + +# --- Purchases (Slice 13) --- +msgid "My Purchases" +msgstr "Meine Einkäufe" + +msgid "Purchases are downloaded as DRM-free files. Availability depends on your Qobuz region." +msgstr "Einkäufe werden als DRM-freie Dateien heruntergeladen. Die Verfügbarkeit hängt von deiner Qobuz-Region ab." + +msgid "Search purchases…" +msgstr "Einkäufe durchsuchen…" + +msgid "No purchases yet" +msgstr "Noch keine Einkäufe" + +msgid "No purchased tracks yet" +msgstr "Noch keine gekauften Titel" + +msgid "Filters" +msgstr "Filter" + +msgid "Availability" +msgstr "Verfügbarkeit" + +msgid "Hide unavailable" +msgstr "Nicht verfügbare ausblenden" + +msgid "CD Quality" +msgstr "CD-Qualität" + +msgid "MP3" +msgstr "MP3" + +msgid "Downloads" +msgstr "Downloads" + +msgid "Hide downloaded" +msgstr "Heruntergeladene auf diesem Computer ausblenden" + +msgid "Group: A–Z" +msgstr "Gruppe: A-Z" + +msgid "Sort: Date" +msgstr "Sortieren: Datum" + +msgid "Sort: Artist" +msgstr "Sortieren: Künstler" + +msgid "Sort: Album" +msgstr "Sortieren: Album" + +msgid "Sort: Quality" +msgstr "Sortieren: Qualität" + +msgid "Date" +msgstr "Datum" + +msgid "Download all" +msgstr "Alles herunterladen" + +msgid "Download complete" +msgstr "Download abgeschlossen" + +msgid "Download cancelled ({}/{})" +msgstr "Download abgebrochen ({}/{})" + +msgid "Downloading {}/{}..." +msgstr "Herunterladen {}/{}..." + +msgid "Select format" +msgstr "Format auswählen" + +msgid "Purchased on" +msgstr "Gekauft am" + +msgid "Add these downloaded tracks to your local library." +msgstr "Diese heruntergeladenen Titel zur lokalen Bibliothek hinzufügen." + +msgid "{} tracks" +msgstr "{} Titel" + +msgid "Choose folder" +msgstr "Ordner wählen" + +msgid "Couldn't add to library" +msgstr "Konnte nicht zur Bibliothek hinzugefügt werden" + +msgid "Added to library" +msgstr "Zur Bibliothek hinzugefügt" + +msgid "Purchased from Qobuz" +msgstr "Qobuz-Kauf" + +msgid "Couldn't load purchases. Check your connection." +msgstr "Einkäufe konnten nicht geladen werden. Überprüfe deine Verbindung." + +msgid "This track has no album to download" +msgstr "Dieser Titel hat kein Album zum Herunterladen" + +msgid "Failed to start download. Please try again." +msgstr "Download konnte nicht gestartet werden. Bitte versuche es erneut." + +msgid "No downloadable formats available" +msgstr "Keine herunterladbaren Formate verfügbar" + +msgid "Download goodies" +msgstr "Extras herunterladen" + +# Playlist "Suggested Songs" section (T8) +msgid "Suggested songs" +msgstr "Vorgeschlagene Titel" + +msgid "Suggest songs for this playlist" +msgstr "Titel für diese Playlist vorschlagen" + +msgid "Finding similar artists..." +msgstr "Ähnliche Künstler werden gesucht..." + +msgid "Refresh suggestions" +msgstr "Vorschläge aktualisieren" + +msgid "Not interested" +msgstr "Kein Interesse" + +# Slint polish batch (radio / playlist follow-copy / MusicBrainz / reco) +msgid "QBZ Radio" +msgstr "QBZ-Radio" + +msgid "Qobuz Radio" +msgstr "Qobuz-Radio" + +msgid "Create QBZ radio" +msgstr "QBZ-Radio erstellen" + +msgid "Create Qobuz radio" +msgstr "Qobuz-Radio erstellen" + +msgid "Follow on Qobuz" +msgstr "Auf Qobuz folgen" + +msgid "Following on Qobuz" +msgstr "Folge ich auf Qobuz" + +msgid "Copy to your library" +msgstr "In deine Bibliothek kopieren" + +msgid "METADATA" +msgstr "METADATEN" + +msgid "MusicBrainz" +msgstr "MusicBrainz" + +msgid "Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections." +msgstr "Aktiviere Künstlerbeziehungen und erweiterte Metadaten von MusicBrainz. Keine Telemetrie — es gleicht nur Veröffentlichungen ab, um Künstlerseiten und Songvorschläge für Playlists anzureichern. Deaktiviere die Option, um diese Bereiche auszublenden." + +msgid "No tracks to add" +msgstr "Keine Titel zum Hinzufügen" + +msgid "Failed to add tracks" +msgstr "Titel konnten nicht hinzugefügt werden" + +msgid "Copied to your library" +msgstr "In deine Bibliothek kopiert" + +msgid "Failed to copy playlist" +msgstr "Playlist konnte nicht kopiert werden" + +msgid "Playlist has no tracks to copy" +msgstr "Die Playlist enthält keine Titel zum Kopieren" + +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{} Titel hinzugefügt" +msgstr[1] "{} Titel hinzugefügt" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{} Titel zu {} hinzugefügt" +msgstr[1] "{} Titel zu {} hinzugefügt" + +msgid "Added {} track to queue" +msgid_plural "Added {} tracks to queue" +msgstr[0] "{} Titel zur Warteschlange hinzugefügt" +msgstr[1] "{} Titel zur Warteschlange hinzugefügt" + +msgid "Unfollow on Qobuz" +msgstr "Auf Qobuz nicht mehr folgen" + +msgid "Stops in" +msgstr "Stoppt in" + +msgid "Cancel Timer" +msgstr "Timer abbrechen" + +msgid "Stop playback after:" +msgstr "Wiedergabe stoppen nach:" + +msgid "30 min" +msgstr "30 Min." + +msgid "1 hr" +msgstr "1 Std." + +msgid "2 hr" +msgstr "2 Std." + +msgid "3 hr" +msgstr "3 Std." + +msgid "5 hr" +msgstr "5 Std." + +msgid "Custom…" +msgstr "Benutzerdefiniert…" + +msgid "min" +msgstr "Min." + +msgid "Set Timer" +msgstr "Timer setzen" + +msgid "Cancel stop after this" +msgstr "Stopp danach abbrechen" + +msgid "LOGS" +msgstr "PROTOKOLLE" + +msgid "Application logs" +msgstr "Anwendungsprotokolle" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Zeige das In-App-Protokoll an, kopiere es (Geheimnisse zensiert) oder lade es hoch, um es in einem GitHub-Issue zu teilen." + +msgid "Open log viewer" +msgstr "Protokoll-Viewer öffnen" + +msgid "Setting" +msgstr "Einstellung" + +msgid "Saved" +msgstr "Gespeichert" + +msgid "Runtime" +msgstr "Laufzeit" + +msgid "Diagnostics" +msgstr "Diagnose" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Vergleich von gespeicherter und aktiver Konfiguration." + +msgid "Exported" +msgstr "Exportiert" + +msgid "Export to clipboard" +msgstr "In die Zwischenablage exportieren" + +msgid "Cast Discovery" +msgstr "Cast-Suche" + +msgid "Scanning…" +msgstr "Suche läuft…" + +msgid "Scan for devices" +msgstr "Nach Geräten suchen" + +msgid "Graphics" +msgstr "Grafik" + +msgid "Environment" +msgstr "Umgebung" + +msgid "Logs" +msgstr "Protokolle" + +msgid "View logs" +msgstr "Protokolle anzeigen" + +msgid "Upload failed." +msgstr "Hochladen fehlgeschlagen." + +msgid "All levels" +msgstr "Alle Ebenen" + +msgid "Error" +msgstr "Fehler" + +msgid "Warn" +msgstr "Warnung" + +msgid "Info" +msgstr "Info" + +msgid "Debug" +msgstr "Debug" + +msgid "Trace" +msgstr "Trace" + +msgid "Search logs…" +msgstr "Protokolle durchsuchen…" + +msgid "Auto-tail" +msgstr "Auto-Tail" + +msgid "Time" +msgstr "Zeit" + +msgid "Level" +msgstr "Ebene" + +msgid "Target" +msgstr "Modul" + +msgid "Message" +msgstr "Nachricht" + +msgid "No log entries." +msgstr "Keine Protokolleinträge." + +msgid "Copied" +msgstr "Kopiert" + +msgid "Copy all" +msgstr "Alles kopieren" + +msgid "Copy diagnostics bundle" +msgstr "Diagnosepaket kopieren" + +msgid "Uploading…" +msgstr "Wird hochgeladen…" + +msgid "Upload (public)" +msgstr "Hochladen (öffentlich)" + +msgid "Open log file" +msgstr "Protokolldatei öffnen" + +msgid "Report an issue" +msgstr "Problem melden" + +msgid "Go to logs" +msgstr "Zu den Protokollen" + +msgid "Create issue report" +msgstr "Problembericht erstellen" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Um die Behebung von Bugs und Problemen zu erleichtern, empfehlen wir, die Anwendungsprotokolle zu teilen. Wir haben uns sehr bemüht, die übermittelten Protokolle zu maskieren, damit keine sensiblen Daten enthalten sind. Der Vorgang ist manuell, niemals automatisch, damit du prüfen kannst, was geteilt wird." + +msgid "Cache window" +msgstr "Cache-Fenster" + +msgid "24 hours" +msgstr "24 Stunden" + +msgid "36 hours" +msgstr "36 Stunden" + +msgid "48 hours" +msgstr "48 Stunden" + +msgid "72 hours" +msgstr "72 Stunden" + +msgid "Refresh now" +msgstr "Jetzt aktualisieren" + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback without browser sample rate limitations." +msgstr "Ein nativer Musik-Streaming-Client für Linux, der für Audiophile entwickelt wurde, die eine bitgenaue Hi-Fi-Wiedergabe ohne Einschränkungen der Browser-Samplerate benötigen." + +msgid "Acknowledgments" +msgstr "Danksagungen" + +msgid "Animated background renderer" +msgstr "WebGL-Renderer für animierte Hintergründe" + +msgid "Audio playback engine" +msgstr "Audio-Wiedergabe-Engine" + +msgid "Author" +msgstr "Autor" + +msgid "Build" +msgstr "Bauen" + +msgid "Build Info" +msgstr "Build-Info" + +msgid "Codename" +msgstr "Codename" + +msgid "Contributors" +msgstr "Mitwirkende" + +msgid "Hatred towards all those companies that discriminate against the Linux community and don't provide us with a decent client for their product." +msgstr "Hass gegenüber all den Unternehmen, die die Linux-Community diskriminieren und uns keinen anständigen Kunden für ihr Produkt bieten." + +msgid "Icon library" +msgstr "Symbolbibliothek" + +msgid "License" +msgstr "Lizenz" + +msgid "Lyrics API" +msgstr "Songtext-API" + +msgid "Made with" +msgstr "Erstellt mit" + +msgid "Music link aggregation API" +msgstr "API zur Aggregation von Musiklinks" + +msgid "Music metadata database" +msgstr "Musik-Metadaten-Datenbank" + +msgid "Music streaming service and API" +msgstr "Musik-Streaming-Dienst und API" + +msgid "Platform" +msgstr "Plattform" + +msgid "Release" +msgstr "Freigeben" + +msgid "Release notes are not available." +msgstr "Versionshinweise sind nicht verfügbar." + +msgid "Synced lyrics provider" +msgstr "Anbieter synchronisierter Liedtexte" + +msgid "UI framework" +msgstr "UI-Framework" + +msgid "Version" +msgstr "Version" + +msgid "Website" +msgstr "Webseite" + +msgid "What's new" +msgstr "Was ist neu" + +msgid "What's new in v{}" +msgstr "Was ist neu in v{}" + +msgid "in" +msgstr "in" + +msgid "QBZ uses your Last.fm and ListenBrainz account data when they're connected (if they aren't, we recommend connecting them to enrich this section). Here you set how often that data is fetched — it defaults to 48 hours, or you can request an update right now." +msgstr "QBZ verwendet die Daten Ihrer Last.fm- und ListenBrainz-Konten, sofern diese verbunden sind (falls nicht, empfehlen wir, sie zu verbinden, um diesen Bereich anzureichern). Hier legen Sie fest, wie oft diese Daten abgerufen werden — der Standardwert beträgt 48 Stunden, oder Sie können jetzt sofort eine Aktualisierung anfordern." + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback." +msgstr "Ein nativer Musik-Streaming-Client für Linux, der für Audiophile entwickelt wurde, die eine bitgenaue Hi-Fi-Wiedergabe benötigen." + +msgid "Built with Rust and Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, and Lucide." +msgstr "Erstellt mit Rust und Slint und betrieben mit Rodio und Symphonia. Mit Dank an Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh und Lucide." + +# Recommendations (Discover external-reco) + JACK not-bit-perfect note +msgid "RECOMMENDATIONS" +msgstr "EMPFEHLUNGEN" + +msgid "Show Recommendations in Discover" +msgstr "Empfehlungen in Entdecken anzeigen" + +msgid "A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases." +msgstr "Ein personalisierter Entdecken-Tab basierend auf deinem Hörverhalten. Für beste Ergebnisse verbinde unten Last.fm und ListenBrainz — MusicBrainz wird automatisch zum Abgleich von Veröffentlichungen verwendet." + +msgid "Recommendations" +msgstr "Empfehlungen" + +msgid "More like the artists you love" +msgstr "Mehr von den Künstlern, die du liebst" + +msgid "Based on what you've been into lately" +msgstr "Basierend auf dem, was du in letzter Zeit gehört hast" + +msgid "Weekly Exploration" +msgstr "Wöchentliche Entdeckung" + +msgid "Weekly Jams" +msgstr "Wöchentliche Lieblingssongs" + +msgid "Popular artists on Qobuz" +msgstr "Beliebte Künstler auf Qobuz" + +msgid "No recommendations yet" +msgstr "Noch keine Empfehlungen" + +msgid "Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations." +msgstr "Verbinde Last.fm oder ListenBrainz in den Einstellungen oder höre mehr Musik, um personalisierte Empfehlungen zu erhalten." + +msgid "JACK is not bit-perfect" +msgstr "JACK ist nicht bit-perfekt" + +msgid "The JACK backend routes audio through the JACK graph, which resamples to the graph's sample rate. For bit-perfect playback use ALSA (direct) or PipeWire with passthrough." +msgstr "Das JACK-Backend leitet die Audioausgabe durch den JACK-Graphen, der auf die Graph-Abtastrate resampled. Für bit-perfekte Wiedergabe verwende ALSA (direkt) oder PipeWire mit Passthrough." + +msgid "Extra large" +msgstr "Extra groß" + +msgid "INTERFACE" +msgstr "OBERFLÄCHE" + +msgid "Interface size" +msgstr "Oberflächengröße" + +msgid "Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)" +msgstr "Skaliert die gesamte Oberfläche, wie der Browser-Zoom. Klein zeigt mehr Inhalt auf dem Bildschirm, Groß und Extra groß verbessern die Lesbarkeit (Neustart erforderlich)" + +msgid "Interface size changed — restart QBZ to apply" +msgstr "Oberflächengröße geändert — QBZ neu starten, um sie zu übernehmen" + +msgid "Extra small" +msgstr "Extra klein" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Mit deinem Browser anmelden" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Fahre in deinem Webbrowser fort — QBZ wartet, bis du die Anmeldung abgeschlossen hast." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "Der Browser öffnet sich möglicherweise im Hintergrund, ohne den Fokus zu übernehmen — prüfe deine anderen Fenster." + +msgid "Signing you in…" +msgstr "Du wirst angemeldet…" + +msgid "Sign-in failed" +msgstr "Anmeldung fehlgeschlagen" + +msgid "About QBZ" +msgstr "Über QBZ" + +msgid "Already used by" +msgstr "Bereits belegt durch" + +msgid "Auto (recommended)" +msgstr "Auto (empfohlen)" + +msgid "Discrete GPU (higher performance)" +msgstr "Dedizierte GPU (mehr Leistung)" + +msgid "Integrated GPU (lower power)" +msgstr "Integrierte GPU (weniger Strom)" + +msgid "Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)" +msgstr "Auto wählt den besten Renderer für die Grafikhardware. Nur ändern, wenn die App langsam wirkt oder fehlerhaft rendert (Neustart erforderlich)" + +msgid "Customize Shortcuts" +msgstr "Tastenkürzel anpassen" + +msgid "GPU" +msgstr "GPU" + +msgid "GPU (compatibility)" +msgstr "GPU (Kompatibilität)" + +msgid "Go" +msgstr "Los" + +msgid "Line Bed" +msgstr "Line Bed" + +msgid "Tunnel Flow" +msgstr "Tunnel Flow" + +msgid "Open Music Link" +msgstr "Musik-Link öffnen" + +msgid "Open Playlist Importer" +msgstr "Playlist-Importer öffnen" + +msgid "Open Qobuz Link" +msgstr "Qobuz-Link öffnen" + +msgid "Paste a Qobuz, Spotify, Apple Music, Tidal, Deezer or song.link URL" +msgstr "Füge eine URL von Qobuz, Spotify, Apple Music, Tidal, Deezer oder song.link ein" + +msgid "Place the Purchases entry in the custom title bar instead of the sidebar" +msgstr "Zeigt den Eintrag Käufe in der eigenen Titelleiste statt in der Seitenleiste" + +msgid "Press keys…" +msgstr "Tasten drücken…" + +msgid "Purchases" +msgstr "Käufe" + +msgid "Purchases in title bar" +msgstr "Käufe in der Titelleiste" + +msgid "RENDERER" +msgstr "RENDERER" + +msgid "Rendering backend" +msgstr "Rendering-Backend" + +msgid "Reset All to Defaults" +msgstr "Alle zurücksetzen" + +msgid "Resolving…" +msgstr "Wird aufgelöst…" + +msgid "Software" +msgstr "Software" + +msgid "Spectral Ribbon" +msgstr "Spectral Ribbon" + +msgid "This looks like a playlist." +msgstr "Das sieht nach einer Playlist aus." + +msgid "Wave Bed" +msgstr "Wave Bed" + +msgid "What's New" +msgstr "Neuigkeiten" + +msgid "modified" +msgstr "geändert" + +msgid "Block this album" +msgstr "Dieses Album blockieren" + +msgid "Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed." +msgstr "Blockierte Alben werden in Suche, Discover und Listen ausgeblendet — auch wenn ihr Künstler erlaubt ist." + +msgid "No blocked albums" +msgstr "Keine blockierten Alben" + +msgid "This will remove all {} blocked albums. This cannot be undone." +msgstr "Dadurch werden alle {} blockierten Alben entfernt. Dies kann nicht rückgängig gemacht werden." + +msgid "To block an album, open its menu and choose Block this album." +msgstr "Um ein Album zu blockieren, öffne sein Menü und wähle Dieses Album blockieren." + +msgid "Unblock album" +msgstr "Album freigeben" + +msgid "{} albums" +msgstr "{} Alben" + +msgid "{} artists, {} albums blocked" +msgstr "{} Künstler, {} Alben blockiert" + +msgid "{} artists, {} albums blocked (disabled)" +msgstr "{} Künstler, {} Alben blockiert (deaktiviert)" + +msgid "Keep your system's native window decorations. Turn off to use the QBZ header as the title bar, with its own window controls and drag support. Takes effect after restarting QBZ." +msgstr "Behält die nativen Fensterdekorationen des Systems. Ausschalten, um die QBZ-Kopfzeile als Titelleiste zu verwenden, mit eigener Fenstersteuerung und Drag-Unterstützung. Wird nach einem Neustart von QBZ wirksam." + +msgid "Frameless window without window controls or header drag (for tiling window manager users)" +msgstr "Rahmenloses Fenster ohne Fenstersteuerung und Kopfzeilen-Drag (für Nutzer von Tiling-Window-Managern)" + +msgid "Title bar mode changed — restart QBZ to apply" +msgstr "Titelleisten-Modus geändert — QBZ neu starten, um es zu übernehmen" + +# Compact header navigation (opt-in, 2026-07-03) +msgid "Compact header navigation" +msgstr "Kompakte Navigation in der Kopfzeile" + +msgid "Use the icon-only section navigation in the header even while the sidebar is open." +msgstr "Verwendet die Abschnittsnavigation nur mit Symbolen in der Kopfzeile, auch wenn die Seitenleiste geöffnet ist." + +# DSD support Phase 1 (2026-07-03) +msgid "DSD: {}" +msgstr "DSD: {}" + +msgid "Cannot play DSD file" +msgstr "DSD-Datei kann nicht wiedergegeben werden" + +# DSD Phases 2-3: delivery mode (2026-07-03) +msgid "DSD playback" +msgstr "DSD-Wiedergabe" + +msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC." +msgstr "Wie DSD-Titel den DAC erreichen. WARNUNG: DoP oder Nativ nur wählen, wenn der DAC es unterstützt — auf jedem anderen DAC wird lautes Rauschen wiedergegeben. Die Lautstärke ist im DoP/Nativ-Modus fest. Nativ erfordert zusätzlich Kernel-Unterstützung für den DAC." + +msgid "Convert to PCM (works everywhere)" +msgstr "Zu PCM konvertieren (funktioniert überall)" + +msgid "DoP — DSD over PCM (bit-perfect)" +msgstr "DoP — DSD über PCM (bit-perfekt)" + +msgid "Native DSD (kernel support required)" +msgstr "Natives DSD (Kernel-Unterstützung erforderlich)" + +msgid "Date added" +msgstr "Hinzugefügt am" + +msgid "Renderer setting reverted to Auto — the previous start didn't finish" +msgstr "Renderer-Einstellung auf Automatisch zurückgesetzt — der letzte Start wurde nicht abgeschlossen" + +msgid "Renderer switched to GPU (compatibility) — the GPU renderer failed to start" +msgstr "Renderer auf GPU (Kompatibilität) umgestellt — der GPU-Renderer konnte nicht starten" + +msgid "Audio output error" +msgstr "Audioausgabefehler" + +msgid "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default" +msgstr "Direkter ALSA-Gerätezugriff ist in Flatpak/Snap blockiert — Audio-Backend auf PipeWire oder Systemstandard umstellen" + +msgid "Auto theme generation failed" +msgstr "Automatische Themenerstellung fehlgeschlagen" + +msgid "Play all tracks" +msgstr "Alle Titel abspielen" + +msgid "Shuffle all tracks" +msgstr "Alle Titel zufällig abspielen" + +msgid "Select multiple tracks" +msgstr "Mehrere Titel auswählen" + +msgid "Play a random album" +msgstr "Zufälliges Album abspielen" + +msgid "Open a random artist" +msgstr "Zufälligen Künstler öffnen" + +msgid "Play a random playlist" +msgstr "Zufällige Playlist abspielen" + +msgid "Open a random label" +msgstr "Zufälliges Label öffnen" + +msgid "Start from current theme" +msgstr "Vom aktuellen Theme ausgehen" + +msgid "Copy the colors of the currently applied theme into the editor as a starting point." +msgstr "Kopiert die Farben des aktuell angewendeten Themes als Ausgangspunkt in den Editor." + +msgid "Use current colors" +msgstr "Aktuelle Farben verwenden" + +msgid "Dark theme" +msgstr "Dunkles Theme" + +msgid "Set the overall light or dark polarity. Affects derived shades, borders and overlays." +msgstr "Legt die generelle helle oder dunkle Polarität fest. Betrifft abgeleitete Schattierungen, Ränder und Overlays." + +msgid "SURFACES" +msgstr "OBERFLÄCHEN" + +msgid "Background" +msgstr "Hintergrund" + +msgid "Surface" +msgstr "Oberfläche" + +msgid "Elevated" +msgstr "Erhöht" + +msgid "TEXT" +msgstr "TEXT" + +msgid "Text" +msgstr "Text" + +msgid "Secondary text" +msgstr "Sekundärer Text" + +msgid "ACCENT" +msgstr "AKZENT" + +msgid "Accent" +msgstr "Akzent" + +msgid "Danger" +msgstr "Gefahr" + +msgid "Warning" +msgstr "Warnung" + +msgid "Success" +msgstr "Erfolg" + +msgid "OTHER" +msgstr "SONSTIGES" + +msgid "Border" +msgstr "Rand" + +# Unavailable-track auto-skip (Tauri #467 parity) +msgid "No available tracks to play" +msgstr "Keine verfügbaren Titel zum Abspielen" +# Always-visible log uploader entry (settings sub-nav footer) +msgid "Share logs" +msgstr "Protokolle teilen" +# Guest local-only mode disclaimer line (login screen, #553) +msgid "QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library)." +msgstr "QBZ kann ohne Qobuz™-Konto als Offline-Player genutzt werden (kein Zugriff auf die Qobuz™-Bibliothek)." +# Renderer degradation ladder toast (generic: GL or software rung) +msgid "Renderer switched automatically — the previous renderer failed to start" +msgstr "Renderer automatisch gewechselt — der vorherige konnte nicht starten" + +# QConnect startup behavior (Settings > Playback, ported from Tauri) +msgid "Auto-connect Qobuz Connect on startup" +msgstr "Qobuz Connect beim Start automatisch verbinden" +msgid "Choose whether Qobuz Connect activates automatically when QBZ launches." +msgstr "Wählen, ob Qobuz Connect beim Start von QBZ automatisch aktiviert wird." +msgid "Remember state" +msgstr "Zustand merken" +msgid "On by default" +msgstr "Standardmäßig ein" +msgid "Off by default" +msgstr "Standardmäßig aus" + +msgid "QBZ recovered from repeated startup crashes — some restored state was reset" +msgstr "QBZ hat sich von wiederholten Startabstürzen erholt — einige wiederhergestellte Zustände wurden zurückgesetzt" + +# QConnect device name (Settings > Playback, ported from Tauri) +msgid "Qobuz Connect device name" +msgstr "Qobuz Connect-Gerätename" + +msgid "The name other Qobuz Connect apps see for this device. Applies on the next connection." +msgstr "Der Name, den andere Qobuz Connect-Apps für dieses Gerät sehen. Wird bei der nächsten Verbindung übernommen." + +# Quality downgrade tooltip (#590 follow-up) +msgid "Source: {}" +msgstr "Quelle: {}" + +msgid "Output: {}" +msgstr "Ausgabe: {}" + +# Local Library header Plex sync button (#573) +msgid "Sync Plex library" +msgstr "Plex-Bibliothek synchronisieren" + +# Library Albums sort options (#566) +msgid "Recently added" +msgstr "Zuletzt hinzugefügt" + +msgid "First added" +msgstr "Zuerst hinzugefügt" + +msgid "Random" +msgstr "Zufällig" + + +# Offline cache clear — communicate it does not remove downloads (#606) +msgid "Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above." +msgstr "Gibt zwischengespeicherte Daten frei. Deine heruntergeladenen Alben bleiben erhalten — entferne sie über den Offline-Manager oben." + +msgid "Couldn't clear the cache" +msgstr "Cache konnte nicht geleert werden" + + +# Task 18 — Settings > Developer > Export settings modal (daemon 04 §4.2) +msgid "SETTINGS PORTABILITY" +msgstr "EINSTELLUNGEN ÜBERTRAGEN" + +msgid "Export settings…" +msgstr "Einstellungen exportieren…" + +msgid "Save a portable bundle of your settings to move to another machine or the qbzd daemon." +msgstr "Speichert ein portables Bündel deiner Einstellungen, um sie auf einen anderen Rechner oder den qbzd-Daemon zu übertragen." + +msgid "Export…" +msgstr "Exportieren…" + +msgid "Export settings" +msgstr "Einstellungen exportieren" + +msgid "Save your settings as a bundle to import on the qbzd daemon or another machine." +msgstr "Speichere deine Einstellungen als Bündel, um sie auf dem qbzd-Daemon oder einem anderen Rechner zu importieren." + +msgid "Include Qobuz login and integration tokens" +msgstr "Qobuz-Anmeldung und Integrations-Tokens einschließen" + +msgid "Anyone with the file can use your accounts. It is saved readable only by you; delete it after importing." +msgstr "Wer die Datei lesen kann, kann deine Konten nutzen. Sie wird nur für dich lesbar gespeichert; lösche sie nach dem Import." + +msgid "Export" +msgstr "Exportieren" + +msgid "Bundle saved. On the daemon box:" +msgstr "Bündel gespeichert. Auf dem Daemon-Rechner:" + +msgid "Could not export settings" +msgstr "Einstellungen konnten nicht exportiert werden" + + +# Share menu Song.link/Album.link toasts (#514) +msgid "Fetching Song.link..." +msgstr "Song.link wird abgerufen..." + +msgid "Fetching Album.link..." +msgstr "Album.link wird abgerufen..." + +msgid "Link copied" +msgstr "Link kopiert" + +msgid "Failed to copy link" +msgstr "Link konnte nicht kopiert werden" + +# Dynamic app background (Appearance settings) +msgid "Ambient" +msgstr "Ambient" + +msgid "Blurred art" +msgstr "Unscharfes Cover" + +msgid "Dynamic background" +msgstr "Dynamischer Hintergrund" + +msgid "Animated album-art background behind the whole app. High resource use — GPU accelerated." +msgstr "Animierter Album-Cover-Hintergrund hinter der gesamten App. Hoher Ressourcenverbrauch — GPU-beschleunigt." + +# Preferred GPU (Renderer settings) +msgid "Preferred GPU" +msgstr "Bevorzugte GPU" + +msgid "Which GPU renders the app. Only the GPUs actually present are listed. Choosing one other than the default also switches the renderer to Vulkan — the only backend Qt can select a GPU on (requires restart)" +msgstr "Welche GPU die App rendert. Es werden nur tatsächlich vorhandene GPUs aufgelistet. Eine andere als die Standard-GPU stellt auch den Renderer auf Vulkan um — das einzige Backend, auf dem Qt eine GPU auswählen kann (Neustart erforderlich)" + +msgid "Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)" +msgstr "Welche GPU die App rendert. Auf einem Hybrid-Laptop verlagert die Wahl der dedizierten GPU die Last des dynamischen Hintergrunds von der integrierten weg (kühler, aber höherer Stromverbrauch). Auto wird empfohlen (Neustart erforderlich)" + +msgid "discrete" +msgstr "dediziert" + +msgid "integrated" +msgstr "integriert" + +msgid "Preferred GPU changed — restart QBZ to apply" +msgstr "Bevorzugte GPU geändert — QBZ neu starten, um die Änderung zu übernehmen" + +msgid "Selected GPU is unavailable in this display session — using Auto" +msgstr "Die ausgewählte GPU ist in dieser Bildschirmsitzung nicht verfügbar — Auto wird verwendet" + +msgid "Renderer changed — restart QBZ to apply" +msgstr "Renderer geändert — QBZ neu starten, um es anzuwenden" + +msgid "Search your library" +msgstr "Deine Bibliothek durchsuchen" + +msgid "From catalog" +msgstr "Aus dem Katalog" + +msgid "In library" +msgstr "In der Bibliothek" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Zu Favoriten hinzufügen" + +msgid "Remove from favorites" +msgstr "Aus Favoriten entfernen" + +msgid "Start radio" +msgstr "Radio starten" + +msgid "Booklet" +msgstr "Booklet" + +msgid "Add to Mixtape" +msgstr "Zu Mixtape hinzufügen" + +msgid "Album info" +msgstr "Album-Infos" + +msgid "More options" +msgstr "Weitere Optionen" + +msgid "Follow artist" +msgstr "Künstler folgen" + +msgid "Unfollow" +msgstr "Nicht mehr folgen" + +msgid "Artist network" +msgstr "Künstler-Netzwerk" + +msgid "Upload to Qobuz" +msgstr "Zu Qobuz hochladen" + +msgid "Select tracks" +msgstr "Titel auswählen" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz weist Anfragen vorübergehend ab – kurze Pause. Bitte gleich erneut versuchen." + + +msgid "Flatpak" +msgstr "Flatpak" + +msgid "Snap" +msgstr "Snap" + +msgid "FLATPAK SANDBOX" +msgstr "FLATPAK-SANDBOX" + +msgid "SNAP SANDBOX" +msgstr "SNAP-SANDBOX" + +msgid "QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates." +msgstr "QBZ läuft in einer Flatpak-Sandbox. Manche Funktionen brauchen einmalige Berechtigungen — führe die Befehle in einem Terminal aus und starte QBZ danach neu. Die Berechtigungen bleiben über Updates hinweg erhalten." + +msgid "QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ." +msgstr "QBZ läuft in einer Snap-Sandbox. Manche Audio-Schnittstellen müssen einmalig verbunden werden — führe diese Befehle in einem Terminal aus und starte QBZ danach neu." + +msgid "Exclusive DAC access (bit-perfect)" +msgstr "Exklusiver DAC-Zugriff (Bit-perfekt)" + +msgid "Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected." +msgstr "Für den exklusiven Modus empfohlen: QBZ kann PipeWire bitten, den DAC sauber zu übergeben (D-Bus-Gerätereservierung). Ohne sie hält PipeWire den DAC weiter und andere Apps mischen trotz gewähltem ALSA + hw + Exklusiv weiter darüber." + +msgid "Music on NAS or external drives" +msgstr "Musik auf NAS oder externen Laufwerken" + +msgid "Grants access to a folder outside the sandbox. Repeat the command for each folder you need." +msgstr "Gibt Zugriff auf einen Ordner außerhalb der Sandbox. Wiederhole den Befehl für jeden Ordner, den du brauchst." + +msgid "Required audio connections" +msgstr "Erforderliche Audio-Verbindungen" + +msgid "Optional — external drives & NAS" +msgstr "Optional — externe Laufwerke & NAS" + +msgid "Copy" +msgstr "Kopieren" + +msgid "Cut" +msgstr "Ausschneiden" + +msgid "Paste" +msgstr "Einfügen" + +msgid "Copied!" +msgstr "Kopiert!" + +msgid "Limited by your streaming quality setting" +msgstr "Begrenzt durch Ihre Einstellung für die Streaming-Qualität" + +msgid "Qobuz has no higher quality for this track" +msgstr "Qobuz bietet für diesen Titel keine höhere Qualität an" + +msgid "offline copy — cap not applied" +msgstr "Offline-Kopie — Limit nicht angewendet" + +msgid "cached copy — cap not applied" +msgstr "Cache-Kopie — Limit nicht angewendet" + +msgid "Limited by your output device's limit" +msgstr "Begrenzt durch das Limit Ihres Ausgabegeräts" + +msgid "Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting." +msgstr "Begrenzt die angeforderte Streaming-Qualität auf das Limit Ihres Ausgabegeräts. Gilt nur für die lokale Wiedergabe, nie für das Casten." + +msgid "Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly." +msgstr "Die genauen Limits Ihres Geräts konnten auf diesem System nicht ausgelesen werden, daher wird ein gängiger Satz angenommen. Die Begrenzung gilt trotzdem, entspricht aber möglicherweise nicht exakt Ihrer Hardware." + +msgid "Detected device limit" +msgstr "Erkanntes Gerätelimit" + +msgid "Quality for this device" +msgstr "Qualität für dieses Gerät" + +msgid "Follow app setting ({})" +msgstr "App-Einstellung folgen ({})" + +msgid "Hi-Res — 24-bit, up to 96 kHz" +msgstr "Hi-Res — 24-bit, bis zu 96 kHz" + +msgid "CD — 16-bit / 44.1 kHz" +msgstr "CD — 16-bit / 44.1 kHz" + +msgid "MP3 320" +msgstr "MP3 320" + +msgid "Qobuz only offers these tiers — there is no 48 kHz option. The step below Hi-Res is CD 44.1 kHz." +msgstr "Qobuz bietet nur diese Stufen an — eine 48-kHz-Option gibt es nicht. Die Stufe unter Hi-Res ist CD 44.1 kHz." + +msgid "Limited by the quality cap for {}" +msgstr "Begrenzt durch das Qualitätslimit für {}" + +msgid "capped for this device" +msgstr "für dieses Gerät begrenzt" + +msgid "Albums by folder" +msgstr "Alben nach Ordner" + +msgid "Albums by metadata" +msgstr "Alben nach Metadaten" + +msgid "ALBUMS VIEW" +msgstr "ALBENANSICHT" + +msgid "Album grouping" +msgstr "Album-Gruppierung" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Ordner: eine Karte pro Album-Ordner — ideal für Compilations und Boxsets. Metadaten: nach Album- + Künstler-Tags aufteilen." + +msgid "+{} more artists" +msgstr "+{} weitere Künstler" + +msgid "Show less" +msgstr "Weniger anzeigen" +# Most Played Albums rail title +msgid "Most Played Albums" +msgstr "Meistgespielte Alben" + +# Most Played Albums View-all — per-card play count +msgid "{} plays" +msgstr "{} Wiedergaben" + +# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast +msgid "Show lyrics translation" +msgstr "Songtext-Übersetzung anzeigen" + +msgid "Hide lyrics translation" +msgstr "Songtext-Übersetzung ausblenden" + +msgid "Translation language" +msgstr "Übersetzungssprache" + +msgid "Auto (account language)" +msgstr "Automatisch (Kontosprache)" + +msgid "Translation is not available right now. Please try again later." +msgstr "Die Übersetzung ist gerade nicht verfügbar. Bitte versuche es später erneut." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Abgelehnte Künstler verschwinden nur aus deinen Empfehlungen — sie erscheinen weiterhin in der Suche und überall sonst." + +msgid "No dismissed artists" +msgstr "Keine abgelehnten Künstler" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Um einen Künstler nicht mehr in Empfehlungen zu sehen, öffne das Menü seiner Karte und wähle Nicht interessiert." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} erscheint nicht mehr in Empfehlungen" + +msgid "Artist dismissed from Recommendations" +msgstr "Künstler aus Empfehlungen abgelehnt" + +msgid "{} restored to Recommendations" +msgstr "{} wieder in Empfehlungen aufgenommen" + +msgid "LIBRARY › ALL" +msgstr "BIBLIOTHEK › ALLE" + +msgid "Show local files & Plex" +msgstr "Lokale Dateien & Plex anzeigen" + +msgid "Include your favorited local files and Plex items in the Library All feed." +msgstr "Zeigt deine favorisierten lokalen Dateien und Plex-Elemente im Bibliotheksfeed Alle an." + +msgid "Play indicator animation" +msgstr "Wiedergabeindikator-Animation" + +msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU." +msgstr "Animiert die gerade spielende Zeile mit Equalizer-Balken. Aus (Standard) zeigt ein statisches Pause-Symbol mit einer Akzentmarkierung am Rand — schont die CPU." + +msgid "Invert swipe navigation direction" +msgstr "Wischrichtung für Navigation umkehren" + +msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward." +msgstr "Vertauscht den Zwei-Finger-Wisch auf dem Touchpad: links geht zurück, rechts geht vorwärts." + +msgid "Local items in Library All" +msgstr "Lokale Elemente in Bibliothek › Alle" + +msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track." +msgstr "Nur Favoriten: was du markiert hast. Gesamte lokale Bibliothek: jedes Album, jeder Künstler und jeder Titel aus Ordnern und Plex." + +msgid "Favorited items only" +msgstr "Nur Favoriten" + +msgid "Entire local library" +msgstr "Gesamte lokale Bibliothek" + +msgid "Select multiple albums" +msgstr "Mehrere Alben auswählen" + +msgid "Play later" +msgstr "Später abspielen" + +msgid "Added to play later" +msgstr "Für später hinzugefügt" + +msgid "Next in queue" +msgstr "Als Nächstes in der Warteschlange" + +msgid "Next up" +msgstr "Als Nächstes" + +msgid "Removed {} tracks from favorites" +msgstr "{} Titel aus Favoriten entfernt" + +msgid "Removing {} tracks from favorites…" +msgstr "{} Titel werden aus Favoriten entfernt…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Album \"{}\" blockiert" + +msgid "Album \"{}\" unblocked" +msgstr "Album \"{}\" freigegeben" + +msgid "Failed to block album" +msgstr "Album konnte nicht blockiert werden" + +msgid "Failed to unblock album" +msgstr "Album konnte nicht freigegeben werden" + +msgid "Failed to clear album blacklist" +msgstr "Album-Sperrliste konnte nicht geleert werden" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "{} Album aus der Sperrliste entfernt" +msgstr[1] "{} Alben aus der Sperrliste entfernt" + +msgid "Playlist imported" +msgstr "Playlist importiert" + +msgid "Playlist import failed" +msgstr "Playlist-Import fehlgeschlagen" + +msgid "Not logged in to Qobuz" +msgstr "Nicht bei Qobuz angemeldet" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Ordner löschen" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Ordner „{}“ löschen? Seine Playlists werden wieder in die oberste Ebene verschoben." + +msgid "Choose a folder image" +msgstr "Wählen Sie ein Ordnerbild" + +msgid "Images" +msgstr "Bilder" + +msgid "Failed to create folder" +msgstr "Ordner konnte nicht erstellt werden" + +msgid "Failed to save folder" +msgstr "Ordner konnte nicht gespeichert werden" + +msgid "Failed to delete folder" +msgstr "Ordner konnte nicht gelöscht werden" + +msgid "Failed to rename playlist" +msgstr "Playlist konnte nicht umbenannt werden" + +msgid "Failed to delete playlist" +msgstr "Playlist konnte nicht gelöscht werden" + +msgid "Couldn't create the playlist" +msgstr "Playlist konnte nicht erstellt werden" + +# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard, +# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. +msgid "Open playlist" +msgstr "Playlist öffnen" + +# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut +# action labels + category names for the cheatsheet and the customize editor. +# Slint's Rust-side labels were never extracted (the port inherits the gap); +# this block fixes it for both frontends. en is EXCLUDED on purpose +# (sparse-override convention, qbz-i18n/src/lib.rs:120-125). + +msgid "Play / Pause" +msgstr "Wiedergabe / Pause" + +msgid "Go Back" +msgstr "Zurück" + +msgid "Go Forward" +msgstr "Vorwärts" + +msgid "Toggle Sidebar" +msgstr "Seitenleiste ein-/ausblenden" + +msgid "Immersive Mode" +msgstr "Immersiver Modus" + +msgid "Close / Dismiss" +msgstr "Schließen / Ausblenden" + +msgid "Show Shortcuts" +msgstr "Tastenkürzel anzeigen" + +msgid "Toggle Mini Player" +msgstr "Mini-Player ein-/ausblenden" + +msgid "Seek Forward (5s)" +msgstr "5 s vorspulen" + +msgid "Seek Back (5s)" +msgstr "5 s zurückspulen" + +msgid "Seek Forward (10s)" +msgstr "10 s vorspulen" + +msgid "Seek Back (10s)" +msgstr "10 s zurückspulen" + +msgid "Micro View" +msgstr "Mikro-Ansicht" + +msgid "Compact View" +msgstr "Kompaktansicht" + +msgid "Artwork View" +msgstr "Cover-Ansicht" + +msgid "Listen list" +msgstr "Hörliste" + +msgid "Lyrics View" +msgstr "Songtext-Ansicht" + +msgid "Navigation" +msgstr "Navigation" + +msgid "Interface" +msgstr "Oberfläche" + +msgid "Mini Player" +msgstr "Mini-Player" + + +# Kiosk shell (2026-08-02 kiosk-port contract) + +msgid "Cover" +msgstr "Cover" + +msgid "Radio" +msgstr "Radio" + +msgid "Up Next" +msgstr "Als Nächstes" + +# Kiosk <-> Desktop profile toggle (view-mode menu) + +msgid "Kiosk mode" +msgstr "Kioskmodus" + +msgid "Desktop mode" +msgstr "Desktopmodus" + +# AlbumView cover menu / lightbox (Qt port, 2026-08-10) + +msgid "View cover" +msgstr "Cover ansehen" + +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Bild ansehen" + +msgid "Could not load the image" +msgstr "Das Bild konnte nicht geladen werden" + +# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's +# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen +# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter", +# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On", +# "Bands & Projects", "Load more", "Loading…", "Artist Scene", +# "No artists found for this scene.", "No album appearances found." and +# "Select an artist to see their albums." (period-only delta) already exist. +# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7, +# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them +# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every +# call site substitutes explicitly, e.g. .replace("{artist}", name). + +msgid "Based on {artist} · {genres}" +msgstr "Basierend auf {artist} · {genres}" + +msgid "Searching MusicBrainz for {genre} artists..." +msgstr "Suche auf MusicBrainz nach {genre}-Künstlern..." + +msgid "Verifying availability on Qobuz..." +msgstr "Verfügbarkeit auf Qobuz wird geprüft..." + +msgid "Preparing results..." +msgstr "Ergebnisse werden vorbereitet..." + +msgid "No matching artists from this scene are currently available on Qobuz." +msgstr "Derzeit sind keine passenden Künstler aus dieser Szene auf Qobuz verfügbar." + +msgid "Loading albums..." +msgstr "Alben werden geladen..." + +msgid "Failed to load albums" +msgstr "Alben konnten nicht geladen werden" + +msgid "Failed to load appearances" +msgstr "Mitwirkungen konnten nicht geladen werden" + +msgid "Looking up {name}..." +msgstr "{name} wird gesucht..." + +msgid "Failed to look up musician" +msgstr "Musiker konnte nicht gefunden werden" + +msgid "Browse view" +msgstr "Browse-Ansicht" + +msgid "Grid view" +msgstr "Rasteransicht" + +msgid "Alphabetical (A-Z)" +msgstr "Alphabetisch (A-Z)" + +msgid "This musician appears in album credits but has limited catalog information available in Qobuz." +msgstr "Dieser Musiker erscheint in den Album-Credits, es sind bei Qobuz aber nur wenige Katalogdaten verfügbar." + +msgid "Unable to verify this musician's catalog. The information shown is based on album credits." +msgstr "Der Katalog dieses Musikers konnte nicht überprüft werden. Die angezeigten Informationen stammen aus den Album-Credits." + +msgid "This musician appears in album credits." +msgstr "Dieser Musiker erscheint in den Album-Credits." + +msgid "Discography" +msgstr "Diskografie" + +msgid "Live Albums" +msgstr "Live-Alben" + +msgid "Select an artist to see their albums" +msgstr "Wähle einen Künstler aus, um seine Alben zu sehen" + +msgid "{} / {} artists" +msgstr "{} / {} Künstler" + +# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17 +# §8-6). "Unavailable" and "This track is no longer available" already +# exist in this file and are REUSED by the render and the reactive skip toast +# — nothing below twins them. +# +# The skipped-tracks toast is a plural PAIR, per the convention already set by +# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build +# drops N rows at once, and a singular-only string reads wrong in every locale +# with plural rules. Russian carries all three forms. +# +# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled +# state (contract §5.3/F5): the track left the catalogue but the user owns a +# downloaded copy, so it still plays. It cannot fold into "This track is no +# longer available" — that sentence would be a lie on a row playing right now. +# +# Em dash is U+2014 with spaces on both sides, as everywhere else in this file. +# "Titel" is invariant in the plural, so both forms are identical (same as +# "Caching {} track offline…"). +msgid "Find available version" +msgstr "Verfügbare Version suchen" + +msgid "Replace unavailable track" +msgstr "Nicht verfügbaren Titel ersetzen" + +msgid "No available version found" +msgstr "Keine verfügbare Version gefunden" + +msgid "Track replaced" +msgstr "Titel ersetzt" + +msgid "Could not replace the track" +msgstr "Titel konnte nicht ersetzt werden" + +msgid "The replacement was added at the end of the playlist" +msgstr "Der Ersatz wurde am Ende der Playlist hinzugefügt" + +msgid "{} track skipped — no longer available" +msgid_plural "{} tracks skipped — no longer available" +msgstr[0] "{} Titel übersprungen — nicht mehr verfügbar" +msgstr[1] "{} Titel übersprungen — nicht mehr verfügbar" + +msgid "No longer on Qobuz — playing your downloaded copy" +msgstr "Nicht mehr bei Qobuz — deine heruntergeladene Kopie wird abgespielt" + +msgid "Exact match" +msgstr "Exakte Übereinstimmung" + +msgid "Low confidence" +msgstr "Geringe Sicherheit" + +msgid "Replace" +msgstr "Ersetzen" + +msgid "Replacing..." +msgstr "Wird ersetzt …" + +msgid "The replacement takes the unavailable track's place in the playlist." +msgstr "Der Ersatz nimmt den Platz des nicht verfügbaren Titels in der Playlist ein." + +# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19) +msgid "Items per carousel" +msgstr "Elemente pro Karussell" + +# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19) +msgid "Recently Played Playlists" +msgstr "Zuletzt gespielte Playlists" + +msgid "Playlists you play will appear here." +msgstr "Playlists, die du abspielst, erscheinen hier." + +# Applied-filters tooltip (2026-08-19) +msgid "+{} more" +msgstr "+{} weitere" + +msgid "+{} more filters" +msgstr "+{} weitere Filter" + +msgid "Applied filters" +msgstr "Aktive Filter" + +# Applied-filters tooltip: group labels (2026-08-19) +msgid "Category" +msgstr "Kategorie" + +# Applied-filters tooltip: Library source switches (2026-08-19) +msgid "Favorites" +msgstr "Favoriten" + +# Discover configurator: Refresh content (2026-08-19) +msgid "Refresh content" +msgstr "Inhalte aktualisieren" + +# Appearance: click-on-section opens the first tab (2026-08-19) +msgid "Click on menu item navigates to first tab" +msgstr "Klick auf einen Menüpunkt öffnet den ersten Tab" + +msgid "Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu" +msgstr "Ein Klick auf eine Sektion in der Seitenleiste oder Titelleiste öffnet sie auf ihrem ersten Tab — Discover auf Home, Library auf All — statt nur ihr Menü zu zeigen" + +# Discover configurator: per-rail default item count (2026-08-19) +msgid "Default ({})" +msgstr "Standard ({})" + +# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources + +msgid "Streaming URL" +msgstr "Streaming-URL" + +msgid "Playlist file" +msgstr "Playlist-Datei" + +msgid "JSON file" +msgstr "JSON-Datei" + +msgid "ListenBrainz" +msgstr "ListenBrainz" + +msgid "Last.fm" +msgstr "Last.fm" + +msgid "Choose file…" +msgstr "Datei wählen…" + +msgid "No file selected" +msgstr "Keine Datei ausgewählt" + +msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog." +msgstr "Es wird nur die Titelliste gelesen — die referenzierten Audiodateien werden nie geöffnet, kopiert oder zur lokalen Bibliothek hinzugefügt. Jeder Eintrag wird im Qobuz-Katalog gesucht." + +msgid "JSON is read best-effort — check the track count below before importing." +msgstr "JSON wird nach bestem Bemühen gelesen — prüfe die Titelanzahl vor dem Import." + +msgid "Could not read the file." +msgstr "Datei konnte nicht gelesen werden." + +msgid "No tracks found in the file." +msgstr "Keine Titel in der Datei gefunden." + +msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON." +msgstr "Dateiformat nicht erkannt — nutze XSPF, PLS, M3U/M3U8 oder JSON." + +msgid "This looks like a live-stream manifest, not a playlist." +msgstr "Das sieht nach einem Livestream-Manifest aus, nicht nach einer Playlist." + +msgid "That file is too large to import." +msgstr "Diese Datei ist zu groß für den Import." + +msgid "Could not recognize a track list in this JSON." +msgstr "In diesem JSON wurde keine Titelliste erkannt." + +msgid "Username or profile URL" +msgstr "Benutzername oder Profil-URL" + +msgid "Username or playlist URL" +msgstr "Benutzername oder Playlist-URL" + +msgid "Play your library" +msgstr "Deine Bibliothek abspielen" + +msgid "Play your mix" +msgstr "Deinen Mix abspielen" + +msgid "Play your recommendations" +msgstr "Deine Empfehlungen abspielen" + +msgid "Station" +msgstr "Sender" + +msgid "No playlists found for {}." +msgstr "Keine Playlists für {} gefunden." + +msgid "Enter a public username." +msgstr "Gib einen öffentlichen Benutzernamen ein." + +msgid "Duplicates: {}" +msgstr "Duplikate: {}" + +msgid "Media" +msgstr "Medium" + +msgid "Open audio CD" +msgstr "Audio-CD öffnen" + +msgid "Audio CD" +msgstr "Audio-CD" + +msgid "No optical drive found." +msgstr "Kein optisches Laufwerk gefunden." + +msgid "No disc in the drive." +msgstr "Keine Disc im Laufwerk." + +msgid "That disc has no audio tracks." +msgstr "Diese Disc hat keine Audiotitel." + +msgid "Could not read the disc." +msgstr "Die Disc konnte nicht gelesen werden." + +msgid "Track {}" +msgstr "Titel {}" + +msgid "Open SACD image…" +msgstr "SACD-Image öffnen…" + +msgid "Choose a disc image" +msgstr "Disc-Image auswählen" + +msgid "That image has no stereo audio area." +msgstr "Dieses Image hat keinen Stereo-Audiobereich." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Diese Disc ist DST-komprimiert; QBZ kann sie noch nicht abspielen." + +msgid "That file is not a disc image." +msgstr "Diese Datei ist kein Disc-Image." + +msgid "That file is not a SACD image." +msgstr "Diese Datei ist kein SACD-Abbild." + +msgid "That SACD image is damaged or unsupported." +msgstr "Dieses SACD-Abbild ist beschädigt oder wird nicht unterstützt." + +msgid "SACD" +msgstr "SACD" + +msgid "Library Explorer" +msgstr "Bibliotheks-Explorer" + +msgid "Genres" +msgstr "Genres" + +msgid "All genres" +msgstr "Alle Genres" + +msgid "All albums" +msgstr "Alle Alben" + +msgid "No albums match these filters" +msgstr "Keine Alben entsprechen diesen Filtern" + +msgid "Try a different genre, artist, album, source or quality." +msgstr "Versuche es mit einem anderen Genre, Künstler, Album, einer anderen Quelle oder Qualität." + +msgid "Favorites only" +msgstr "Nur Favoriten" + +msgid "Where should the ripped album go?" +msgstr "Wohin soll das gerippte Album?" + +msgid "Ripping" +msgstr "Wird gerippt" + +msgid "Nothing to rip." +msgstr "Nichts zu rippen." + +msgid "Ripped {} track to your library" +msgid_plural "Ripped {} tracks to your library" +msgstr[0] "{} Titel in deine Bibliothek gerippt" +msgstr[1] "{} Titel in deine Bibliothek gerippt" + +# Discs: rip wizard, metadata lookup and the disc tooltips (2026-08-21) + +msgid "Play a folder, CD or disc image without adding it to your library" +msgstr "Einen Ordner, eine CD oder ein Disc-Image abspielen, ohne es zur Mediathek hinzuzufügen" + +msgid "Reading the disc…" +msgstr "Disc wird gelesen…" + +msgid "Re-scan your folders and media servers for new music" +msgstr "Ordner und Medienserver erneut nach neuer Musik durchsuchen" + +msgid "Manage the folders your library is built from" +msgstr "Die Ordner verwalten, aus denen die Mediathek aufgebaut wird" + +msgid "Play the whole disc from the first track" +msgstr "Die ganze Disc ab dem ersten Titel abspielen" + +msgid "Play it in a shuffled order" +msgstr "In zufälliger Reihenfolge abspielen" + +msgid "Look this disc up and correct its details" +msgstr "Diese Disc nachschlagen und ihre Angaben korrigieren" + +msgid "Copy this CD to your computer as FLAC files" +msgstr "Diese CD als FLAC-Dateien auf den Computer kopieren" + +msgid "Close this disc and go back to your library" +msgstr "Diese Disc schließen und zur Mediathek zurückkehren" + +msgid "Correct disc details" +msgstr "Disc-Angaben korrigieren" + +msgid "SACD image" +msgstr "SACD-Image" + +msgid "Close without changing anything" +msgstr "Schließen, ohne etwas zu ändern" + +msgid "Community database. Knows discs by their table of contents." +msgstr "Community-Datenbank. Erkennt Discs an ihrem Inhaltsverzeichnis." + +msgid "Collector database. Stronger on pressings, editions and catalogue numbers." +msgstr "Sammler-Datenbank. Stärker bei Pressungen, Editionen und Katalognummern." + +msgid "Search this provider" +msgstr "In dieser Quelle suchen" + +msgid "The provider is rate-limiting right now. Try again in a moment." +msgstr "Die Quelle drosselt gerade die Anfragen. Bitte gleich erneut versuchen." + +msgid "No releases matched. Try fewer words, or the other provider." +msgstr "Keine Treffer. Versuche es mit weniger Wörtern oder der anderen Quelle." + +msgid "Search for the release this disc actually is." +msgstr "Suche nach der Veröffentlichung, die diese Disc tatsächlich ist." + +msgid "Remove my correction" +msgstr "Meine Korrektur entfernen" + +msgid "Forget the saved details for this disc and look it up fresh next time" +msgstr "Die gespeicherten Angaben zu dieser Disc verwerfen und beim nächsten Mal neu nachschlagen" + +msgid "Applying..." +msgstr "Wird angewendet..." + +msgid "Use these details" +msgstr "Diese Angaben übernehmen" + +msgid "Rename this disc and remember it for next time" +msgstr "Diese Disc umbenennen und für das nächste Mal merken" + +msgid "Rip this CD" +msgstr "Diese CD auslesen" + +msgid "What will happen" +msgstr "Was passieren wird" + +msgid "Track names" +msgstr "Titelnamen" + +msgid "Your library" +msgstr "Deine Mediathek" + +msgid "Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags." +msgstr "Jeder Titel der Disc wird gelesen und als FLAC-Datei geschrieben – ein Ordner pro Album, eine Datei pro Titel, mit Tags." + +msgid "Format: FLAC (lossless) — the only format QBZ writes" +msgstr "Format: FLAC (verlustfrei) – das einzige Format, das QBZ schreibt" + +msgid "Where the album goes" +msgstr "Wohin das Album kommt" + +msgid "Choose folder…" +msgstr "Ordner wählen…" + +msgid "Change…" +msgstr "Ändern…" + +msgid "Pick the folder the album folder will be created in" +msgstr "Den Ordner wählen, in dem der Album-Ordner angelegt wird" + +msgid "Nothing chosen yet" +msgstr "Noch nichts ausgewählt" + +msgid "This folder is already part of your Local Library." +msgstr "Dieser Ordner gehört bereits zu deiner lokalen Mediathek." + +msgid "This folder is outside your Local Library, so the album will not appear there on its own." +msgstr "Dieser Ordner liegt außerhalb deiner lokalen Mediathek – das Album erscheint dort nicht von selbst." + +msgid "Choose a destination first and this step will know what to offer." +msgstr "Wähle zuerst ein Ziel, dann weiß dieser Schritt, was er anbieten kann." + +msgid "Re-scan that folder when the rip finishes" +msgstr "Diesen Ordner nach dem Auslesen erneut durchsuchen" + +msgid "Only that folder, not your whole library." +msgstr "Nur diesen Ordner, nicht die gesamte Mediathek." + +msgid "Do nothing" +msgstr "Nichts tun" + +msgid "The album appears the next time you scan." +msgstr "Das Album erscheint beim nächsten Durchsuchen." + +msgid "Add this folder to Local Library and scan it" +msgstr "Diesen Ordner zur lokalen Mediathek hinzufügen und durchsuchen" + +msgid "Future rips into the same folder will be picked up too." +msgstr "Spätere Auslesevorgänge in denselben Ordner werden ebenfalls erfasst." + +msgid "The files are written; the library will not know about them." +msgstr "Die Dateien werden geschrieben; die Mediathek erfährt nichts davon." + +msgid "A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool." +msgstr "Ein einfacher Ripper, gedacht, um CDs schnell in die Mediathek zu bekommen. Für AccurateRip-Prüfungen und erweiterte Optionen nimm ein spezialisiertes Programm." + +msgid "Close without ripping anything" +msgstr "Schließen, ohne etwas auszulesen" + +msgid "Go back a step" +msgstr "Einen Schritt zurück" + +msgid "Start ripping" +msgstr "Auslesen starten" + +msgid "Go to the next step" +msgstr "Zum nächsten Schritt" + +msgid "Read the disc and write the FLAC files" +msgstr "Die Disc lesen und die FLAC-Dateien schreiben" + +msgid "Choose a destination folder first" +msgstr "Zuerst einen Zielordner wählen" + +msgid "No disc is open." +msgstr "Es ist keine Disc geöffnet." + +msgid "Couldn't load that release." +msgstr "Diese Veröffentlichung konnte nicht geladen werden." + +msgid "Disc details updated" +msgstr "Disc-Angaben aktualisiert" + +msgid "Correction removed. Re-open the disc to look it up again." +msgstr "Korrektur entfernt. Öffne die Disc erneut, um sie neu nachzuschlagen." + +msgid "Only a physical CD can be ripped." +msgstr "Nur eine physische CD kann ausgelesen werden." + +msgid "Choose where the album should go first." +msgstr "Wähle zuerst, wohin das Album kommen soll." + +msgid "Couldn't add that folder to the library" +msgstr "Dieser Ordner konnte nicht zur Mediathek hinzugefügt werden" + +# Rip progress panel, cancel and partial rips (2026-08-21) + +msgid "Writing FLAC files to" +msgstr "FLAC-Dateien werden geschrieben nach" + +msgid "Hide this panel — the rip keeps going" +msgstr "Dieses Fenster ausblenden – das Auslesen läuft weiter" + +msgid "Ripping — don't eject the disc until this finishes." +msgstr "Auslesen läuft – wirf die Disc erst aus, wenn es fertig ist." + +msgid "Finished" +msgstr "Fertig" + +msgid "Files already written stay on disk. Delete them yourself if you don't want them." +msgstr "Bereits geschriebene Dateien bleiben auf der Festplatte. Wenn du sie nicht willst, lösche sie selbst." + +msgid "Stop ripping" +msgstr "Auslesen stoppen" + +msgid "Stopping…" +msgstr "Wird gestoppt…" + +msgid "Stop after the current track. Nothing is deleted." +msgstr "Nach dem aktuellen Titel stoppen. Es wird nichts gelöscht." + +msgid "Rip stopped. The files already written are still there." +msgstr "Auslesen gestoppt. Die bereits geschriebenen Dateien sind noch da." + +msgid "See what the rip is doing, or stop it" +msgstr "Sehen, was das Auslesen gerade tut – oder es stoppen" + +msgid "Pick at least one track to rip." +msgstr "Wähle mindestens einen Titel zum Auslesen." + +# Ephemeral pane: per-disc blocks and the track filter (2026-08-21) + +msgid "Disc {}" +msgstr "Disc {}" + +msgid "Filter tracks" +msgstr "Titel filtern" + +msgid "No track matches that." +msgstr "Kein Titel passt dazu." + +msgid "NAVIGATION" +msgstr "NAVIGATION" + +msgid "SEARCH" +msgstr "SUCHE" + +msgid "PLAYER & VISUALS" +msgstr "PLAYER & VISUELLES" + +msgid "WINDOW" +msgstr "FENSTER" + +msgid "SYSTEM INTEGRATION" +msgstr "SYSTEMINTEGRATION" + +msgid "The typeface used across the app. System follows your operating system; the other choices are bundled with QBZ (requires restart)" +msgstr "Die Schriftart der gesamten App. „System“ folgt dem Betriebssystem; die übrigen Schriften werden mit QBZ geliefert (Neustart erforderlich)" + +msgid "Library folders" +msgstr "Bibliotheksordner" + +msgid "{} folders" +msgstr "{} Ordner" + +msgid "No Qobuz account? You can still use QBZ as a player for your own music library" +msgstr "Kein Qobuz-Konto? Du kannst QBZ trotzdem als Player für deine eigene Musik nutzen" + +msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working." +msgstr "Du nutzt QBZ ohne Qobuz-Konto. Deine lokale Bibliothek und Downloads funktionieren weiterhin." + +msgid "Continuous playback" +msgstr "Kontinuierliche Wiedergabe" + +msgid "Open" +msgstr "Öffnen" + +msgid "Folder view" +msgstr "Ordneransicht" + +msgid "Open media" +msgstr "Medium öffnen" + +msgid "Open folder…" +msgstr "Ordner öffnen…" + +# Tab-Reihenfolge der lokalen Bibliothek und Start ohne Sitzung (2026-08-24) +msgid "LIBRARY TABS" +msgstr "BIBLIOTHEKS-TABS" + +msgid "Tab order and default" +msgstr "Tab-Reihenfolge und Standard" + +msgid "Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session." +msgstr "Lege die Reihenfolge der Tabs der lokalen Bibliothek fest. Der erste wird standardmäßig geöffnet, auch wenn QBZ ohne Qobuz-Sitzung startet." + +msgid "Local Library tabs" +msgstr "Tabs der lokalen Bibliothek" + +msgid "Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session." +msgstr "Ordne die Tabs der lokalen Bibliothek neu. Der erste wird standardmäßig geöffnet und beim Start ohne Qobuz-Sitzung angezeigt." + +msgid "Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu" +msgstr "Ein Klick auf einen Bereich in der Seiten- oder Titelleiste öffnet dessen ersten Tab — einschließlich des gewählten Standards der lokalen Bibliothek — statt nur das Menü anzuzeigen" + +# Position der Genrefilter in der lokalen Bibliothek (2026-08-24) +msgid "GENRES VIEW" +msgstr "GENRE-ANSICHT" + +msgid "Genre filters position" +msgstr "Position der Genrefilter" + +msgid "Place the chained genre filters above, beside or below the album results." +msgstr "Platziere die verketteten Genrefilter über, neben oder unter den Albumergebnissen." + +msgid "Top" +msgstr "Oben" + +msgid "Bottom" +msgstr "Unten" + +msgid "Album metadata saved." +msgstr "Album-Metadaten gespeichert." + +msgid "Album title is required." +msgstr "Ein Albumtitel ist erforderlich." + +msgid "Apply to form" +msgstr "Auf Formular anwenden" + +msgid "CUE-based albums can only be edited with a sidecar." +msgstr "CUE-basierte Alben können nur mit einer Sidecar-Datei bearbeitet werden." + +msgid "CUE-based albums use sidecar metadata." +msgstr "CUE-basierte Alben verwenden Sidecar-Metadaten." + +msgid "Canonical" +msgstr "Kanonisch" + +msgid "Close without saving" +msgstr "Ohne Speichern schließen" + +msgid "Couldn't inspect album metadata." +msgstr "Die Album-Metadaten konnten nicht geprüft werden." + +msgid "Couldn't load that metadata result." +msgstr "Dieses Metadatenergebnis konnte nicht geladen werden." + +msgid "Couldn't open the metadata source." +msgstr "Die Metadatenquelle konnte nicht geöffnet werden." + +msgid "Detected" +msgstr "Erkannt" + +msgid "Disc number" +msgstr "Disc-Nummer" + +msgid "Editing the selected physical version" +msgstr "Die ausgewählte physische Version wird bearbeitet" + +msgid "Enter an album title or artist to search." +msgstr "Gib einen Albumtitel oder Künstler für die Suche ein." + +msgid "Every track needs a title." +msgstr "Jeder Track benötigt einen Titel." + +msgid "File names are shown for reference and cannot be changed here." +msgstr "Dateinamen werden zur Orientierung angezeigt und können hier nicht geändert werden." + +msgid "Find metadata" +msgstr "Metadaten suchen" + +msgid "ID3 writing" +msgstr "ID3-Schreibformat" + +msgid "Metadata editing is available for local file versions only." +msgstr "Metadaten können nur für lokale Dateiversionen bearbeitet werden." + +msgid "Metadata search failed." +msgstr "Die Metadatensuche ist fehlgeschlagen." + +msgid "No local album version is selected." +msgstr "Es ist keine lokale Albumversion ausgewählt." + +msgid "None" +msgstr "Keine" + +msgid "One or more audio files are unavailable." +msgstr "Eine oder mehrere Audiodateien sind nicht verfügbar." + +msgid "Open source" +msgstr "Quelle öffnen" + +msgid "QBZ will preflight every file, update its canonical tag layer, then read it back to verify the change. Keep a backup if these files cannot be replaced." +msgstr "QBZ prüft jede Datei vorab, aktualisiert ihre kanonische Tag-Ebene und liest sie anschließend zur Kontrolle erneut. Bewahre eine Sicherung auf, wenn diese Dateien nicht ersetzt werden können." + +msgid "Save changes to" +msgstr "Änderungen speichern in" + +msgid "Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled." +msgstr "Einige Dateien enthalten widersprüchliche Tag-Ebenen. Die kanonische Ebene hat Vorrang, sofern die Synchronisierung nicht aktiviert ist." + +msgid "Synchronize compatible tag layers" +msgstr "Kompatible Tag-Ebenen synchronisieren" + +msgid "Tag layers" +msgstr "Tag-Ebenen" + +msgid "The canonical tag is not writable for every file." +msgstr "Das kanonische Tag kann nicht in jede Datei geschrieben werden." + +msgid "The metadata editor is no longer open." +msgstr "Der Metadateneditor ist nicht mehr geöffnet." + +msgid "The metadata form could not be read." +msgstr "Das Metadatenformular konnte nicht gelesen werden." + +msgid "The track list changed; reopen the editor." +msgstr "Die Trackliste wurde geändert; öffne den Editor erneut." + +msgid "The track list contains a duplicate row." +msgstr "Die Trackliste enthält eine doppelte Zeile." + +msgid "Track number" +msgstr "Tracknummer" + +msgid "Unknown metadata persistence mode." +msgstr "Unbekannter Speichermodus für Metadaten." + +msgid "Write and verify" +msgstr "Schreiben und prüfen" + +msgid "Write metadata into audio files?" +msgstr "Metadaten in die Audiodateien schreiben?" + +msgid "Year must be a valid number." +msgstr "Das Jahr muss eine gültige Zahl sein." + +msgid "{} must be a positive number." +msgstr "{} muss eine positive Zahl sein." + +msgid "The ephemeral session changed; reopen the metadata editor." +msgstr "Die temporäre Sitzung wurde geändert; öffne den Metadaten-Editor erneut." + +msgid "Album artists" +msgstr "Albuminterpreten" + +msgid "Album artists (ordered; separate with semicolons)" +msgstr "Albuminterpreten (geordnet; mit Semikolon trennen)" + +msgid "Artist credit" +msgstr "Interpretenangabe" + +msgid "Artists (ordered; semicolon separated)" +msgstr "Interpreten (geordnet; mit Semikolon getrennt)" + +msgid "Artwork is larger than 25 MiB." +msgstr "Das Cover ist größer als 25 MiB." + +msgid "Artwork search failed." +msgstr "Die Cover-Suche ist fehlgeschlagen." + +msgid "Artwork source did not provide a secure URL." +msgstr "Die Cover-Quelle hat keine sichere URL bereitgestellt." + +msgid "Choose album artwork" +msgstr "Albumcover auswählen" + +msgid "Choose another file" +msgstr "Andere Datei auswählen" + +msgid "Choose file" +msgstr "Datei auswählen" + +msgid "Compilation / Various Artists" +msgstr "Kompilation / Verschiedene Interpreten" + +msgid "Composers" +msgstr "Komponisten" + +msgid "Couldn't download that artwork." +msgstr "Dieses Cover konnte nicht heruntergeladen werden." + +msgid "Couldn't stage artwork" +msgstr "Das Cover konnte nicht vorbereitet werden" + +msgid "Current artwork" +msgstr "Aktuelles Cover" + +msgid "Discogs release ID" +msgstr "Discogs-Veröffentlichungs-ID" + +msgid "Enter an album title to find artwork." +msgstr "Gib einen Albumtitel ein, um Cover zu suchen." + +msgid "File" +msgstr "Datei" + +msgid "Find artwork" +msgstr "Cover suchen" + +msgid "Local file" +msgstr "Lokale Datei" + +msgid "MusicBrainz album artist IDs" +msgstr "MusicBrainz-IDs der Albuminterpreten" + +msgid "MusicBrainz artist IDs" +msgstr "MusicBrainz-Interpreten-IDs" + +msgid "MusicBrainz recording ID" +msgstr "MusicBrainz-Aufnahme-ID" + +msgid "MusicBrainz release ID" +msgstr "MusicBrainz-Veröffentlichungs-ID" + +msgid "MusicBrainz release-group ID" +msgstr "MusicBrainz-Veröffentlichungsgruppen-ID" + +msgid "MusicBrainz track ID" +msgstr "MusicBrainz-Titel-ID" + +msgid "Performers" +msgstr "Mitwirkende" + +msgid "Revert selection" +msgstr "Auswahl zurücksetzen" + +msgid "Selected track details" +msgstr "Details des ausgewählten Titels" + +msgid "That artwork result is no longer available." +msgstr "Dieses Cover-Ergebnis ist nicht mehr verfügbar." + +msgid "The selected artwork changed; choose it again." +msgstr "Das ausgewählte Cover wurde geändert; wähle es erneut aus." + +msgid "The selected artwork has unsupported dimensions." +msgstr "Das ausgewählte Cover hat nicht unterstützte Abmessungen." + +msgid "The selected file is not a supported image." +msgstr "Die ausgewählte Datei ist kein unterstütztes Bild." + +msgid "Goniometer" +msgstr "Goniometer" + +msgid "Oscilloscope" +msgstr "Oszilloskop" + +msgid "Track waveform" +msgstr "Titel-Wellenform" + +msgid "Show the full-track waveform in the player seek bar." +msgstr "Zeigt die Wellenform des gesamten Titels in der Suchleiste des Players an." + +msgid "Compact header" +msgstr "Kompakter Kopfbereich" + +msgid "Show less data in album headers." +msgstr "Weniger Daten in Albumkopfbereichen anzeigen." + +msgid "Show full album header" +msgstr "Vollständigen Albumkopf anzeigen" + +msgid "Show compact album header" +msgstr "Kompakten Albumkopf anzeigen" + +msgid "Immediate scrobbling" +msgstr "Sofortiges Scrobbling" + +msgid "Send scrobbles immediately while manual offline mode is enabled." +msgstr "Scrobbles sofort senden, während der manuelle Offline-Modus aktiviert ist." + +msgid "Accumulated scrobbling" +msgstr "Gesammeltes Scrobbling" + +msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old." +msgstr "Scrobbles bei fehlender Netzwerkverbindung zwischenspeichern und senden, sobald sie wiederhergestellt ist. Last.fm akzeptiert Scrobbles, die bis zu 2 Wochen alt sind." + +msgid "Scrobble while logged out" +msgstr "Scrobbling ohne Anmeldung" + +msgid "Continue sending plays to connected scrobblers without a Qobuz session." +msgstr "Wiedergaben auch ohne Qobuz-Sitzung an verbundene Scrobble-Dienste senden." + +msgid "Focus on playing" +msgstr "Wiedergabe fokussieren" + +msgid "Go to top" +msgstr "Nach oben" + +msgid "Quick view" +msgstr "Schnellansicht" + +msgid "Could not load album" +msgstr "Album konnte nicht geladen werden" + +msgid "This album has no tracks." +msgstr "Dieses Album enthält keine Titel." + +msgid "Reactive Rings" +msgstr "Reaktive Ringe" + +msgid "Windows — yes, but read this first" +msgstr "Windows — ja, aber lies zuerst das hier" + +msgid "Don't show this again" +msgstr "Nicht mehr anzeigen" + +msgid "I understand" +msgstr "Ich verstehe" +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker + +msgid "Volume Up" +msgstr "Lauter" + +msgid "Volume Down" +msgstr "Leiser" + +msgid "Mute / Unmute" +msgstr "Stumm / Ton an" + +msgid "Toggle Shuffle" +msgstr "Zufallswiedergabe umschalten" + +msgid "Cycle Repeat" +msgstr "Wiederholmodus wechseln" + +msgid "Like Track" +msgstr "Titel liken" + +msgid "Seek Forward" +msgstr "Vorspulen" + +msgid "Seek Back" +msgstr "Zurückspulen" + +msgid "Keymap" +msgstr "Tastenschema" + +msgid "Vim" +msgstr "Vim" + +# Local Library QoL: Explorer, scan progress, and per-track metadata editor +msgid "All years" +msgstr "Alle Jahre" + +msgid "Explorer columns" +msgstr "Explorer-Spalten" + +msgid "Genre and year" +msgstr "Genre und Jahr" + +msgid "Years" +msgstr "Jahre" + +msgid "Edit track metadata" +msgstr "Track-Metadaten bearbeiten" + +msgid "Building library index" +msgstr "Bibliotheksindex wird erstellt" + +msgid "Local folders" +msgstr "Lokale Ordner" + +msgid "Next track" +msgstr "Nächster Titel" + +msgid "Overall" +msgstr "Gesamt" + +msgid "Previous track" +msgstr "Vorheriger Titel" + +msgid "Scanning library" +msgstr "Bibliothek wird gescannt" + +msgid "The track is no longer in the library." +msgstr "Der Titel befindet sich nicht mehr in der Bibliothek." + +msgid "Updating library index" +msgstr "Bibliotheksindex wird aktualisiert" + +msgid "Edit album" +msgstr "Album bearbeiten" + +msgid "Editing a local metadata sidecar" +msgstr "Lokale Metadaten-Sidecar-Datei wird bearbeitet" + +msgid "Media-server metadata is stored in a local sidecar." +msgstr "Metadaten des Medienservers werden in einer lokalen Sidecar-Datei gespeichert." + +msgid "Media-server metadata can only be edited with a sidecar." +msgstr "Metadaten des Medienservers können nur über eine Sidecar-Datei bearbeitet werden." + +msgid "Network file" +msgstr "Netzwerkdatei" + +msgid "Offline download" +msgstr "Offline-Download" + +msgid "File path" +msgstr "Dateipfad" + +msgid "Containing folder" +msgstr "Übergeordneter Ordner" + +msgid "Media item ID" +msgstr "Medienobjekt-ID" + +msgid "Album ID" +msgstr "Album-ID" + +msgid "Server" +msgstr "Server" + +msgid "Duration" +msgstr "Dauer" + +msgid "Format" +msgstr "Format" + +msgid "Channels" +msgstr "Kanäle" + +msgid "File size" +msgstr "Dateigröße" + +msgid "Location" +msgstr "Speicherort" + +msgid "The media item is no longer available." +msgstr "Das Medienobjekt ist nicht mehr verfügbar." + +msgid "The local album folder is not currently available." +msgstr "Der lokale Albumordner ist derzeit nicht verfügbar." + +msgid "Everything is already available offline" +msgstr "Alles ist bereits offline verfügbar" + +msgid "Some tracks are already available offline" +msgstr "Einige Titel sind bereits offline verfügbar" + +msgid "{} of {} tracks in “{}” are already available offline." +msgstr "{} von {} Titeln in „{}“ sind bereits offline verfügbar." + +msgid "Download the entire playlist again" +msgstr "Gesamte Playlist erneut herunterladen" + +msgid "Download the entire album again" +msgstr "Gesamtes Album erneut herunterladen" + +msgid "Re-downloads every track and can repair corrupt offline copies." +msgstr "Lädt jeden Titel erneut herunter und kann beschädigte Offline-Kopien reparieren." + +msgid "Only missing tracks" +msgstr "Nur fehlende Titel" + +msgid "Keeps existing offline copies and downloads tracks that are not available yet." +msgstr "Behält vorhandene Offline-Kopien bei und lädt noch nicht verfügbare Titel herunter." + +msgid "PRIVACY" +msgstr "DATENSCHUTZ" + +msgid "Listening history" +msgstr "Hörverlauf" + +msgid "Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler." +msgstr "Führt ein lokales Protokoll Ihrer Wiedergaben: Titel, Quelle, wie viel davon Sie gehört haben und wann. Es bleibt auf diesem Rechner, pro Konto, und verlässt ihn nie, sofern Sie keinen Scrobbler aktivieren." + +msgid "Clear listening history" +msgstr "Hörverlauf löschen" + +msgid "Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected." +msgstr "Löscht jede auf diesem Rechner für dieses Konto aufgezeichnete Wiedergabe. Bereits gesendete Scrobbles sind nicht betroffen." + +msgid "Listening history cleared." +msgstr "Hörverlauf gelöscht." + +msgid "Couldn't clear listening history." +msgstr "Hörverlauf konnte nicht gelöscht werden." + +msgid "Checking ALSA mixer controls…" +msgstr "ALSA-Mixerregler werden geprüft…" + +msgid "This device exposes multiple playback controls. Choose which one QBZ should use." +msgstr "Dieses Gerät stellt mehrere Wiedergaberegler bereit. Wählen Sie aus, welchen QBZ verwenden soll." + +msgid "The saved mixer control is no longer available. Choose another control." +msgstr "Der gespeicherte Mixerregler ist nicht mehr verfügbar. Wählen Sie einen anderen Regler." + +msgid "QBZ does not have permission to read this device's ALSA mixer." +msgstr "QBZ hat keine Berechtigung, den ALSA-Mixer dieses Geräts zu lesen." + +msgid "The ALSA mixer is busy. Close the app using it and try again." +msgstr "Der ALSA-Mixer ist belegt. Schließen Sie die verwendende Anwendung und versuchen Sie es erneut." + +msgid "The selected ALSA device is no longer available." +msgstr "Das ausgewählte ALSA-Gerät ist nicht mehr verfügbar." + +msgid "This ALSA device does not expose a mixer." +msgstr "Dieses ALSA-Gerät stellt keinen Mixer bereit." + +msgid "QBZ could not write the selected ALSA mixer control. Direct playback remains fixed at 100%." +msgstr "QBZ konnte den ausgewählten ALSA-Mixerregler nicht schreiben. Die direkte Wiedergabe bleibt auf 100 % eingestellt." + +msgid "Selected mixer control" +msgstr "Ausgewählter Mixerregler" + +msgid "Choose mixer control" +msgstr "Mixerregler auswählen" + +msgid "Change mixer control" +msgstr "Mixerregler ändern" + +msgid "Qobuz Connect could not shut down safely. Quit and reopen QBZ, then try logging out again." +msgstr "Qobuz Connect konnte nicht sicher beendet werden. Beenden und öffnen Sie QBZ erneut und versuchen Sie dann noch einmal, sich abzumelden." + +msgid "Show filter panels" +msgstr "Filterbereiche anzeigen" + +msgid "Hide filter panels" +msgstr "Filterbereiche ausblenden" + +msgid "Already in" +msgstr "Bereits in" + +msgid "Playlist index updating..." +msgstr "Playlist-Index wird aktualisiert..." + +msgid "{} of {}" +msgstr "{} von {}" + +msgid "Last playlist" +msgstr "Letzte Playlist" + +msgid "OK" +msgstr "OK" + +msgid "Sponsors" +msgstr "Sponsoren" + +msgid "And all those anonymous sponsors..." +msgstr "Und all die anonymen Sponsoren..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect kann lokale Tracks nicht auflösen. {} Track wurde übersprungen und nicht zur Warteschlange hinzugefügt. Schalte Qobuz Connect aus, um ihn abzuspielen." +msgstr[1] "Qobuz Connect kann lokale Tracks nicht auflösen. {} Tracks wurden übersprungen und nicht zur Warteschlange hinzugefügt. Schalte Qobuz Connect aus, um sie abzuspielen." + +msgid "Qobuz Connect device" +msgstr "Qobuz-Connect-Gerät" + +msgid "Continue playback on %1" +msgstr "Wiedergabe auf %1 fortsetzen" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Die Qobuz-Connect-Warteschlange verwenden und dieses Gerät aktiv lassen." + +msgid "Continue playback on this device" +msgstr "Wiedergabe auf diesem Gerät fortsetzen" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Die Qobuz-Connect-Warteschlange verwenden und die lokale Warteschlange ersetzen." + +msgid "Continue this device's current playback" +msgstr "Aktuelle Wiedergabe dieses Geräts fortsetzen" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Die Qobuz-Connect-Warteschlange und den Wiedergabestatus durch die aktuelle Warteschlange und Position dieses Geräts ersetzen." + +msgid "Cancel Qobuz Connect" +msgstr "Qobuz Connect abbrechen" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Beide Warteschlangen unverändert lassen und die aktuelle lokale Wiedergabe fortsetzen." + +msgid "Choose how to continue" +msgstr "Auswählen, wie fortgefahren werden soll" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Auf diesem Gerät und einem anderen Qobuz-Connect-Wiedergabegerät läuft eine Wiedergabe. Wähle aus, welche Warteschlange und welches Gerät fortfahren sollen." + +msgid "Ask every time" +msgstr "Jedes Mal nachfragen" + +msgid "Continue on the active renderer" +msgstr "Auf dem aktiven Wiedergabegerät fortsetzen" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Hier mit der Qobuz-Connect-Warteschlange fortsetzen" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Lokale Wiedergabe fortsetzen und Qobuz-Connect-Warteschlange ersetzen" + +msgid "When playback conflicts" +msgstr "Bei Wiedergabekonflikten" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Jedes Mal nachfragen oder automatisch auswählen, welche Warteschlange und welches Gerät fortfahren sollen." + +msgid "Purchased" +msgstr "Gekauft" + +msgid "Sort: Release date" +msgstr "Sortierung: Veröffentlichungsdatum" + +msgid "Until the purchased file is downloaded, Play streams the best quality Qobuz offers for this album. Once it is on disk, Play uses your copy." +msgstr "Solange die gekaufte Datei nicht heruntergeladen ist, streamt Wiedergabe die beste Qualität, die Qobuz für dieses Album anbietet. Sobald sie auf der Festplatte liegt, verwendet Wiedergabe deine Kopie." + +msgid "Open in Local Library" +msgstr "In lokaler Bibliothek öffnen" + +msgid "Import / Export" +msgstr "Import / Export" + +msgid "This bundle holds the app's own settings: audio, playback, appearance, integrations. It does not contain your Qobuz favorites, playlists, blacklist or purchases." +msgstr "Dieses Paket enthält die Einstellungen der App: Audio, Wiedergabe, Darstellung, Integrationen. Es enthält keine Qobuz-Favoriten, Playlists, Blacklist oder Käufe." + +msgid "BLACKLIST" +msgstr "BLACKLIST" + +msgid "Export blacklist…" +msgstr "Blacklist exportieren…" + +msgid "Save your blocked artists and albums as a file you can hand to another QBZ user." +msgstr "Speichert deine blockierten Künstler und Alben als Datei, die du an andere QBZ-Nutzer weitergeben kannst." + +msgid "Import blacklist…" +msgstr "Blacklist importieren…" + +msgid "Add blocked artists and albums from a file. Nothing already on your blacklist is removed." +msgstr "Fügt blockierte Künstler und Alben aus einer Datei hinzu. Nichts, was bereits auf deiner Blacklist steht, wird entfernt." + +msgid "Import…" +msgstr "Importieren…" + +msgid "Import failed." +msgstr "Import fehlgeschlagen." + +msgid "Not a QBZ blacklist file." +msgstr "Keine QBZ-Blacklist-Datei." + +msgid "Choose a blacklist file" +msgstr "Blacklist-Datei auswählen" + +msgid "Imported {} artists and {} albums ({} already blocked)." +msgstr "{} Künstler und {} Alben importiert ({} bereits blockiert)." + +msgid "Member" +msgstr "Mitglied" + +msgid "QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™." +msgstr "QBZ benötigt ein Qobuz™-Konto. Ohne aktives Abonnement ist die Wiedergabe auf 30-Sekunden-Vorschauen beschränkt; Favoriten, Playlists und Käufe funktionieren weiterhin. Deine Zugangsdaten gehen direkt an Qobuz™." + +msgid "ACCOUNT MIGRATION" +msgstr "KONTOMIGRATION" + +msgid "Moves favorites, playlists and followed playlists from one Qobuz account into another, adding only what is missing and deleting nothing on either side. It does not move subscriptions or purchases: those stay with the Qobuz account that holds them." +msgstr "Überträgt Favoriten, Playlists und gefolgte Playlists von einem Qobuz-Konto in ein anderes, fügt nur Fehlendes hinzu und löscht auf keiner Seite etwas. Abonnements und Käufe werden nicht übertragen: sie bleiben beim Qobuz-Konto, das sie besitzt." + +msgid "Create migration snapshot" +msgstr "Migrations-Snapshot erstellen" + +msgid "Sign in with the account you are moving FROM, then save its favorites and playlists to a file in its QBZ profile." +msgstr "Melde dich mit dem Konto an, VON dem du umziehst, und speichere dessen Favoriten und Playlists in einer Datei in seinem QBZ-Profil." + +msgid "Create…" +msgstr "Erstellen…" + +msgid "Migrate into this account" +msgstr "In dieses Konto migrieren" + +msgid "Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing." +msgstr "Melde dich mit dem Konto an, ZU dem du umziehst, und wähle unten einen Snapshot. Wiederholen ist sicher: ein zweiter Lauf fügt nichts hinzu." + +msgid "No snapshots yet. Sign in with the old account and create one first." +msgstr "Noch keine Snapshots. Melde dich mit dem alten Konto an und erstelle zuerst einen." + +msgid "{} favorites · {} playlists · {} followed" +msgstr "{} Favoriten · {} Playlists · {} gefolgt" + +msgid "this account" +msgstr "dieses Konto" + +msgid "Migrate…" +msgstr "Migrieren…" + +msgid "Account {} · {}" +msgstr "Konto {} · {}" + +msgid "Reading favorites and playlists…" +msgstr "Favoriten und Playlists werden gelesen…" + +msgid "Reading this account…" +msgstr "Dieses Konto wird gelesen…" + +msgid "Adding favorites…" +msgstr "Favoriten werden hinzugefügt…" + +msgid "Adding playlists…" +msgstr "Playlists werden hinzugefügt…" + +msgid "Following playlists…" +msgstr "Playlists werden gefolgt…" + +msgid "{} / {} — {}" +msgstr "{} / {} — {}" + +msgid "Snapshot failed." +msgstr "Snapshot fehlgeschlagen." + +msgid "Migration failed." +msgstr "Migration fehlgeschlagen." + +msgid "Saved {} favorites, {} playlists and {} followed playlists to {}" +msgstr "{} Favoriten, {} Playlists und {} gefolgte Playlists gespeichert in {}" + +msgid "That snapshot was taken from this same account." +msgstr "Dieser Snapshot stammt von genau diesem Konto." + +msgid "Nothing to migrate: everything is already in this account." +msgstr "Nichts zu migrieren: alles ist bereits in diesem Konto." + +msgid "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there." +msgstr "{} Favoriten, {} Playlists ({} Titel) und {} gefolgte Playlists hinzugefügt; {} waren bereits vorhanden." + +msgid "{} items failed; see the log." +msgstr "{} Elemente fehlgeschlagen; siehe Log." + +msgid "Also copy the local profile" +msgstr "Auch das lokale Profil kopieren" + +msgid "Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer." +msgstr "Bibliotheksordner, Playlist-Ordner und -Reihenfolge, angeheftete Elemente, Blacklist und Einstellungen des QBZ-Profils des alten Kontos auf diesem Computer." + +msgid "Media server connections" +msgstr "Medienserver-Verbindungen" + +msgid "Plex, Jellyfin and Subsonic settings, including their credentials." +msgstr "Plex-, Jellyfin- und Subsonic-Einstellungen, einschließlich Zugangsdaten." + +msgid "Scrobbler accounts" +msgstr "Scrobbler-Konten" + +msgid "Last.fm and ListenBrainz settings, including their credentials." +msgstr "Last.fm- und ListenBrainz-Einstellungen, einschließlich Zugangsdaten." + +msgid "The listen log and the events behind offline recommendations." +msgstr "Das Hörprotokoll und die Ereignisse hinter den Offline-Empfehlungen." + +msgid "Local profile: {} rows copied; {} playlist rows had no match." +msgstr "Lokales Profil: {} Zeilen kopiert; {} Playlist-Zeilen ohne Entsprechung." + +msgid "The library already had folders: run a rescan to index the added ones." +msgstr "Die Bibliothek hatte bereits Ordner: starte einen erneuten Scan, um die hinzugefügten zu indizieren." + +msgid "Restart QBZ to see the migrated local profile." +msgstr "Starte QBZ neu, um das migrierte lokale Profil zu sehen." + +msgid "The local profile could not be copied; see the log." +msgstr "Das lokale Profil konnte nicht kopiert werden; siehe Log." +msgid "Delete…" +msgstr "Löschen…" + +msgid "Delete migration snapshot?" +msgstr "Migrations-Snapshot löschen?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Dadurch wird nur das Migrationspaket entfernt. Das QBZ-Profil und die Benutzerdaten des alten Kontos bleiben erhalten." + +msgid "Delete snapshot" +msgstr "Snapshot löschen" + +msgid "Migration snapshot deleted." +msgstr "Migrations-Snapshot gelöscht." + +msgid "Could not delete the migration snapshot." +msgstr "Der Migrations-Snapshot konnte nicht gelöscht werden." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} aktualisiert gerade seine Bibliothek ({} Elemente gescannt). Warten Sie, bis der Vorgang abgeschlossen ist, und synchronisieren Sie dann erneut." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "Im Quellprofil war keine Plex-, Jellyfin- oder Subsonic-Verbindung gespeichert, daher konnte keine kopiert werden." + +msgid "Media server connections copied: {} of {}." +msgstr "Kopierte Medienserver-Verbindungen: {} von {}." + +msgid "Copying local profile…" +msgstr "Lokales Profil wird kopiert…" + +msgid "Refreshing migrated data…" +msgstr "Migrierte Daten werden aktualisiert…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Dies geschieht nur einmal. Bitte warten Sie, bis der Vorgang abgeschlossen ist, und schließen Sie das Fenster nicht." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Sie können dieses Fenster schließen; die Migration wird im Hintergrund fortgesetzt." + +msgid "From" +msgstr "Von" + +msgid "To" +msgstr "Nach" + +msgid "Step {} of {}" +msgstr "Schritt {} von {}" + +msgid "Elapsed: {}" +msgstr "Vergangene Zeit: {}" + +msgid "Remaining: {}" +msgstr "Verbleibend: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Prüfen Sie die gespeicherten Quellprofile, wählen Sie die zu kopierenden Daten und bestätigen Sie das angemeldete Zielkonto." + +msgid "Migrate account data" +msgstr "Kontodaten migrieren" + +msgid "Start migration" +msgstr "Migration starten" + +msgid "What is a migration profile?" +msgstr "Was ist ein Migrationsprofil?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Es ist ein einmaliges Paket aus dem Quellkonto. Es bewahrt dessen Qobuz-Favoriten und Playlists, damit sie dem aktuell angemeldeten Konto hinzugefügt werden können. Optionale lokale Daten werden direkt aus dem alten QBZ-Profil auf diesem Computer kopiert. Das Löschen des Pakets löscht weder das alte Profil noch Kontodaten." + +msgid "SAVED SOURCE PROFILES" +msgstr "GESPEICHERTE QUELLPROFILE" + +msgid "No saved migration profiles are available." +msgstr "Es sind keine gespeicherten Migrationsprofile verfügbar." + +msgid "Selected" +msgstr "Ausgewählt" + +msgid "Select" +msgstr "Auswählen" + +msgid "MIGRATION DIRECTION" +msgstr "MIGRATIONSRICHTUNG" + +msgid "Select a source profile above" +msgstr "Oben ein Quellprofil auswählen" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Angemeldetes Qobuz-Konto · ID {}" + +msgid "WHAT TO COPY" +msgstr "ZU KOPIERENDE DATEN" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "Die Migration ist additiv: Sie entfernt keine Favoriten oder Playlists aus einem der Konten." + +msgid "That library folder is disabled." +msgstr "Dieser Bibliotheksordner ist deaktiviert." + +msgid "That folder overlaps an existing library folder." +msgstr "Dieser Ordner überschneidet sich mit einem vorhandenen Bibliotheksordner." + +msgid "This purchase is not available for download." +msgstr "Dieser Kauf steht nicht zum Download zur Verfügung." + +msgid "Could not save playback preference." +msgstr "Die Wiedergabeeinstellung konnte nicht gespeichert werden." + +msgid "Qobuz · stream/offline copy" +msgstr "Qobuz · Stream/Offline-Kopie" + +msgid "Local purchase" +msgstr "Lokaler Kauf" + +msgid "Play with" +msgstr "Wiedergeben mit" + +msgid "Download purchase" +msgstr "Kauf herunterladen" + +msgid "1 track purchased" +msgstr "1 Titel gekauft" + +msgid "{} tracks purchased" +msgstr "{} Titel gekauft" + +msgid "This copy cannot be continued right now." +msgstr "Diese Kopie kann derzeit nicht fortgesetzt werden." + +msgid "Library folder refreshed." +msgstr "Bibliotheksordner aktualisiert." + +msgid "Download another copy" +msgstr "Weitere Kopie herunterladen" + +msgid "Complete" +msgstr "Vollständig" + +msgid "Partial" +msgstr "Teilweise" + +msgid "Missing files" +msgstr "Fehlende Dateien" + +msgid "Changed files" +msgstr "Geänderte Dateien" + +msgid "Unreadable files" +msgstr "Nicht lesbare Dateien" + +msgid "Local purchase copies" +msgstr "Lokale Kaufkopien" + +msgid "{} of {} tracks" +msgstr "{} von {} Titeln" + +msgid "Continue copy" +msgstr "Kopie fortsetzen" + +msgid "Add to Local Library" +msgstr "Zur lokalen Bibliothek hinzufügen" + +msgid "In Local Library" +msgstr "In lokaler Bibliothek" + +msgid "{} of {} tracks available in this folder." +msgstr "{} von {} Titeln sind in diesem Ordner verfügbar." + +msgid "{} failed" +msgstr "{} fehlgeschlagen" + +msgid "{} cancelled" +msgstr "{} abgebrochen" + +msgid "Download finished" +msgstr "Download abgeschlossen" + +msgid "Shuffle: On" +msgstr "Mischen: Ein" + +msgid "Shuffle: Off" +msgstr "Mischen: Aus" + +msgid "Repeat: Off" +msgstr "Wiederholen: Aus" + +msgid "Repeat: All" +msgstr "Wiederholen: Alle" + +msgid "Repeat: One" +msgstr "Wiederholen: Eins" + +msgid "Show album artwork in Queue track list" +msgstr "Album-Cover in der Titelliste der Warteschlange anzeigen" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Ersetzt Titelnummern in der Warteschlange durch Cover-Miniaturen. Standardmäßig deaktiviert." + +msgid "Show album artwork in Library track list" +msgstr "Album-Cover in der Titelliste der Bibliothek anzeigen" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Zeigt die Cover-Miniatur zwischen Titelnummer und Titel an. Standardmäßig deaktiviert." + +msgid "Play random" +msgstr "Zufällig abspielen" + +msgid "Play selected" +msgstr "Auswahl abspielen" + +msgid "Cancel selection" +msgstr "Auswahl aufheben" + +msgid "QOBUZ LINKS" +msgstr "QOBUZ-LINKS" + +msgid "Open Qobuz links in QBZ" +msgstr "Qobuz-Links in QBZ öffnen" + +msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here." +msgstr "Macht QBZ zum Handler für qobuzapp://-Links aus dem Browser. Ausgeschaltet behält die offizielle Qobuz-App sie; QBZ-eigene qbz://-Links öffnen immer hier." + +msgid "Look for better replacement" +msgstr "Nach einem besseren Ersatz suchen" + +msgid "Remove all favorites from this release" +msgstr "Alle Favoriten dieser Veröffentlichung entfernen" + +msgid "Remove all favorites from this release?" +msgstr "Alle Favoriten dieser Veröffentlichung entfernen?" + +msgid "Remove the album and every track from this release from your Library?" +msgstr "Das Album und alle Titel dieser Veröffentlichung aus deiner Bibliothek entfernen?" + +msgid "No replacement releases found" +msgstr "Keine Ersatzveröffentlichungen gefunden" + +msgid "Choosing a result opens the release. Your existing favorites are not changed." +msgstr "Die Auswahl öffnet die Veröffentlichung. Deine vorhandenen Favoriten werden nicht geändert." + +msgid "No favorites from this release to remove" +msgstr "Keine Favoriten dieser Veröffentlichung zum Entfernen vorhanden" + +msgid "Favorites removed from this release" +msgstr "Favoriten dieser Veröffentlichung entfernt" + +msgid "Could not remove favorites from this release" +msgstr "Favoriten dieser Veröffentlichung konnten nicht entfernt werden" + +msgid "Some favorites could not be removed" +msgstr "Einige Favoriten konnten nicht entfernt werden" + +msgid "Replace in Library" +msgstr "In der Bibliothek ersetzen" + +msgid "Replacing favorite..." +msgstr "Favorit wird ersetzt..." + +msgid "The selected release replaces the unavailable album favorite." +msgstr "Die ausgewählte Veröffentlichung ersetzt das nicht verfügbare favorisierte Album." + +msgid "The matching track from the selected release replaces the unavailable favorite." +msgstr "Der passende Titel der ausgewählten Veröffentlichung ersetzt den nicht verfügbaren Favoriten." + +msgid "Could not open the selected release" +msgstr "Die ausgewählte Veröffentlichung konnte nicht geöffnet werden" + +msgid "Could not find a matching track in this release" +msgstr "In dieser Veröffentlichung wurde kein passender Titel gefunden" + +msgid "Could not add the replacement favorite" +msgstr "Der Ersatzfavorit konnte nicht hinzugefügt werden" + +msgid "Favorite replaced" +msgstr "Favorit ersetzt" + +msgid "The replacement was added, but the unavailable favorite could not be removed" +msgstr "Der Ersatz wurde hinzugefügt, aber der nicht verfügbare Favorit konnte nicht entfernt werden" + +msgid "Failed to send playback to the selected device" +msgstr "Die Wiedergabe konnte nicht an das ausgewählte Gerät übertragen werden" + +msgid "Move up" +msgstr "Nach oben" + +msgid "Move down" +msgstr "Nach unten" + +msgid "Download" +msgstr "Herunterladen" + +msgid "Edit" +msgstr "Bearbeiten" + +msgid "Grid" +msgstr "Raster" + +msgid "List" +msgstr "Liste" + +msgid "Tree" +msgstr "Baum" + +msgid "Forward" +msgstr "Vorwärts" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po new file mode 100644 index 000000000..e7d10f264 --- /dev/null +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -0,0 +1,2429 @@ +msgid "" +msgstr "" +"Project-Id-Version: qbz-slint\n" +"PO-Revision-Date: 2026-09-05 19:52-0600\n" +"Last-Translator: QBZ contributors\n" +"Language-Team: QBZ en translators\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +# Slint polish batch (radio / playlist follow-copy / MusicBrainz / reco) +msgid "QBZ Radio" +msgstr "QBZ Radio" + +msgid "Qobuz Radio" +msgstr "Qobuz Radio" + +msgid "Create QBZ radio" +msgstr "Create QBZ radio" + +msgid "Create Qobuz radio" +msgstr "Create Qobuz radio" + +msgid "Follow on Qobuz" +msgstr "Follow on Qobuz" + +msgid "Following on Qobuz" +msgstr "Following on Qobuz" + +msgid "Copy to your library" +msgstr "Copy to your library" + +msgid "METADATA" +msgstr "METADATA" + +msgid "MusicBrainz" +msgstr "MusicBrainz" + +msgid "Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections." +msgstr "Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections." + +msgid "No tracks to add" +msgstr "No tracks to add" + +msgid "Failed to add tracks" +msgstr "Failed to add tracks" + +msgid "Copied to your library" +msgstr "Copied to your library" + +msgid "Failed to copy playlist" +msgstr "Failed to copy playlist" + +msgid "Playlist has no tracks to copy" +msgstr "Playlist has no tracks to copy" + +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "Added {} track" +msgstr[1] "Added {} tracks" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "Added {} track to {}" +msgstr[1] "Added {} tracks to {}" + +msgid "Added {} track to queue" +msgid_plural "Added {} tracks to queue" +msgstr[0] "Added {} track to queue" +msgstr[1] "Added {} tracks to queue" + +msgid "Unfollow on Qobuz" +msgstr "Unfollow on Qobuz" + +msgid "Extra large" +msgstr "Extra large" + +msgid "INTERFACE" +msgstr "INTERFACE" + +msgid "Interface size" +msgstr "Interface size" + +msgid "Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)" +msgstr "Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)" + +msgid "Interface size changed — restart QBZ to apply" +msgstr "Interface size changed — restart QBZ to apply" + +msgid "Extra small" +msgstr "Extra small" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Sign in with your browser" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Continue in your web browser — QBZ is waiting for you to finish signing in." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "The browser may open in the background without taking focus — check your other windows." + +msgid "Signing you in…" +msgstr "Signing you in…" + +msgid "Sign-in failed" +msgstr "Sign-in failed" + +msgid "Date added" +msgstr "Date added" + +msgid "Renderer setting reverted to Auto — the previous start didn't finish" +msgstr "Renderer setting reverted to Auto — the previous start didn't finish" + +msgid "Renderer switched to GPU (compatibility) — the GPU renderer failed to start" +msgstr "Renderer switched to GPU (compatibility) — the GPU renderer failed to start" + +msgid "Audio output error" +msgstr "Audio output error" + +msgid "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default" +msgstr "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default" + +msgid "Add to Library" +msgstr "Add to Library" + +msgid "Remove from Library" +msgstr "Remove from Library" + +msgid "Couldn't load your Library." +msgstr "Couldn't load your Library." + +msgid "No tracks in your Library yet." +msgstr "No tracks in your Library yet." + +msgid "No albums in your Library yet." +msgstr "No albums in your Library yet." + +msgid "No artists in your Library yet." +msgstr "No artists in your Library yet." + +msgid "No playlists in your Library yet." +msgstr "No playlists in your Library yet." + +msgid "No labels in your Library yet." +msgstr "No labels in your Library yet." + +msgid "Library Albums" +msgstr "Library Albums" + +msgid "Auto theme generation failed" +msgstr "Auto theme generation failed" + +msgid "Play all tracks" +msgstr "Play all tracks" + +msgid "Shuffle all tracks" +msgstr "Shuffle all tracks" + +msgid "Select multiple tracks" +msgstr "Select multiple tracks" + +msgid "Play a random album" +msgstr "Play a random album" + +msgid "Open a random artist" +msgstr "Open a random artist" + +msgid "Play a random playlist" +msgstr "Play a random playlist" + +msgid "Open a random label" +msgstr "Open a random label" + +msgid "Start from current theme" +msgstr "Start from current theme" + +msgid "Copy the colors of the currently applied theme into the editor as a starting point." +msgstr "Copy the colors of the currently applied theme into the editor as a starting point." + +msgid "Use current colors" +msgstr "Use current colors" + +msgid "Dark theme" +msgstr "Dark theme" + +msgid "Set the overall light or dark polarity. Affects derived shades, borders and overlays." +msgstr "Set the overall light or dark polarity. Affects derived shades, borders and overlays." + +msgid "SURFACES" +msgstr "SURFACES" + +msgid "Background" +msgstr "Background" + +msgid "Surface" +msgstr "Surface" + +msgid "Elevated" +msgstr "Elevated" + +msgid "TEXT" +msgstr "TEXT" + +msgid "Text" +msgstr "Text" + +msgid "Secondary text" +msgstr "Secondary text" + +msgid "ACCENT" +msgstr "ACCENT" + +msgid "Accent" +msgstr "Accent" + +msgid "STATUS" +msgstr "STATUS" + +msgid "Danger" +msgstr "Danger" + +msgid "Warning" +msgstr "Warning" + +msgid "Success" +msgstr "Success" + +msgid "OTHER" +msgstr "OTHER" + +msgid "Border" +msgstr "Border" + +msgid "Favorite" +msgstr "Favorite" + +# Always-visible log uploader entry (settings sub-nav footer) +msgid "Share logs" +msgstr "Share logs" +# Guest local-only mode disclaimer line (login screen, #553) +msgid "QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library)." +msgstr "QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library)." +# Renderer degradation ladder toast (generic: GL or software rung) +msgid "Renderer switched automatically — the previous renderer failed to start" +msgstr "Renderer switched automatically — the previous renderer failed to start" + +# QConnect startup behavior (Settings > Playback, ported from Tauri) +msgid "Auto-connect Qobuz Connect on startup" +msgstr "Auto-connect Qobuz Connect on startup" +msgid "Choose whether Qobuz Connect activates automatically when QBZ launches." +msgstr "Choose whether Qobuz Connect activates automatically when QBZ launches." +msgid "Remember state" +msgstr "Remember state" +msgid "On by default" +msgstr "On by default" +msgid "Off by default" +msgstr "Off by default" + +msgid "Remove album from collection" +msgstr "Remove album from collection" + +msgid "QBZ recovered from repeated startup crashes — some restored state was reset" +msgstr "QBZ recovered from repeated startup crashes — some restored state was reset" + +# QConnect device name (Settings > Playback, ported from Tauri) +msgid "Qobuz Connect device name" +msgstr "Qobuz Connect device name" + +msgid "The name other Qobuz Connect apps see for this device. Applies on the next connection." +msgstr "The name other Qobuz Connect apps see for this device. Applies on the next connection." + +# Quality downgrade tooltip (#590 follow-up) +msgid "Source: {}" +msgstr "Source: {}" + +msgid "Output: {}" +msgstr "Output: {}" + +# Local Library header Plex sync button (#573) +msgid "Sync Plex library" +msgstr "Sync Plex library" + +# Library Albums sort options (#566) +msgid "Recently added" +msgstr "Recently added" + +msgid "First added" +msgstr "First added" + +msgid "Random" +msgstr "Random" + + +# Offline cache clear — communicate it does not remove downloads (#606) +msgid "Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above." +msgstr "Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above." + +msgid "Cache cleared" +msgstr "Cache cleared" + +msgid "Couldn't clear the cache" +msgstr "Couldn't clear the cache" + + +# Task 18 — Settings > Developer > Export settings modal (daemon 04 §4.2) +msgid "SETTINGS PORTABILITY" +msgstr "SETTINGS PORTABILITY" + +msgid "Export settings…" +msgstr "Export settings…" + +msgid "Save a portable bundle of your settings to move to another machine or the qbzd daemon." +msgstr "Save a portable bundle of your settings to move to another machine or the qbzd daemon." + +msgid "Export…" +msgstr "Export…" + +msgid "Export settings" +msgstr "Export settings" + +msgid "Save your settings as a bundle to import on the qbzd daemon or another machine." +msgstr "Save your settings as a bundle to import on the qbzd daemon or another machine." + +msgid "Include Qobuz login and integration tokens" +msgstr "Include Qobuz login and integration tokens" + +msgid "Anyone with the file can use your accounts. It is saved readable only by you; delete it after importing." +msgstr "Anyone with the file can use your accounts. It is saved readable only by you; delete it after importing." + +msgid "Export" +msgstr "Export" + +msgid "Bundle saved. On the daemon box:" +msgstr "Bundle saved. On the daemon box:" + +msgid "Could not export settings" +msgstr "Could not export settings" + +# Pinned section (Home / For You mixed carousel) +msgid "Pinned" +msgstr "Pinned" + +# Pin/Unpin toggle on the detail views (playlist header, album/artist menus) +msgid "Pin" +msgstr "Pin" + +msgid "Unpin" +msgstr "Unpin" + +# Queue footer count (filtered track list) +msgid "Showing {} of {} track." +msgid_plural "Showing {} of {} tracks." +msgstr[0] "Showing {} of {} track." +msgstr[1] "Showing {} of {} tracks." + + +# Share menu Song.link/Album.link toasts (#514) +msgid "Fetching Song.link..." +msgstr "Fetching Song.link..." + +msgid "Fetching Album.link..." +msgstr "Fetching Album.link..." + +msgid "Link copied" +msgstr "Link copied" + +msgid "Failed to copy link" +msgstr "Failed to copy link" + +# Dynamic app background (Appearance settings) +msgid "Off" +msgstr "Off" + +msgid "Ambient" +msgstr "Ambient" + +msgid "Blurred art" +msgstr "Blurred art" + +msgid "Dynamic background" +msgstr "Dynamic background" + +msgid "Animated album-art background behind the whole app. High resource use — GPU accelerated." +msgstr "Animated album-art background behind the whole app. High resource use — GPU accelerated." + +# Preferred GPU (Renderer settings) +msgid "Preferred GPU" +msgstr "Preferred GPU" + +msgid "Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)" +msgstr "Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)" + +msgid "discrete" +msgstr "discrete" + +msgid "integrated" +msgstr "integrated" + +msgid "Preferred GPU changed — restart QBZ to apply" +msgstr "Preferred GPU changed — restart QBZ to apply" + +msgid "Selected GPU is unavailable in this display session — using Auto" +msgstr "Selected GPU is unavailable in this display session — using Auto" + +msgid "Search your library" +msgstr "Search your library" + +msgid "From catalog" +msgstr "From catalog" + +msgid "In library" +msgstr "In library" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Play" +msgstr "Play" + +msgid "Shuffle" +msgstr "Shuffle" + +msgid "Edit metadata" +msgstr "Edit metadata" + +msgid "Add to favorites" +msgstr "Add to favorites" + +msgid "Remove from favorites" +msgstr "Remove from favorites" + +msgid "Start radio" +msgstr "Start radio" + +msgid "Booklet" +msgstr "Booklet" + +msgid "Add to Mixtape" +msgstr "Add to Mixtape" + +msgid "Album info" +msgstr "Album info" + +msgid "More options" +msgstr "More options" + +msgid "Follow artist" +msgstr "Follow artist" + +msgid "Unfollow" +msgstr "Unfollow" + +msgid "Artist network" +msgstr "Artist network" + +msgid "Make available offline" +msgstr "Make available offline" + +msgid "Upload to Qobuz" +msgstr "Upload to Qobuz" + +msgid "Edit playlist" +msgstr "Edit playlist" + +msgid "Select tracks" +msgstr "Select tracks" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz is temporarily refusing requests — backing off. Try again shortly." + + +msgid "Flatpak" +msgstr "Flatpak" + +msgid "Snap" +msgstr "Snap" + +msgid "FLATPAK SANDBOX" +msgstr "FLATPAK SANDBOX" + +msgid "SNAP SANDBOX" +msgstr "SNAP SANDBOX" + +msgid "QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates." +msgstr "QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates." + +msgid "QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ." +msgstr "QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ." + +msgid "Exclusive DAC access (bit-perfect)" +msgstr "Exclusive DAC access (bit-perfect)" + +msgid "Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected." +msgstr "Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected." + +msgid "Music on NAS or external drives" +msgstr "Music on NAS or external drives" + +msgid "Grants access to a folder outside the sandbox. Repeat the command for each folder you need." +msgstr "Grants access to a folder outside the sandbox. Repeat the command for each folder you need." + +msgid "Required audio connections" +msgstr "Required audio connections" + +msgid "Optional — external drives & NAS" +msgstr "Optional — external drives & NAS" + +msgid "Copy" +msgstr "Copy" + +msgid "Copied!" +msgstr "Copied!" + +msgid "Limited by your streaming quality setting" +msgstr "Limited by your streaming quality setting" + +msgid "Qobuz has no higher quality for this track" +msgstr "Qobuz has no higher quality for this track" + +msgid "offline copy — cap not applied" +msgstr "offline copy — cap not applied" + +msgid "cached copy — cap not applied" +msgstr "cached copy — cap not applied" + +msgid "Limited by your output device's limit" +msgstr "Limited by your output device's limit" + +msgid "Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting." +msgstr "Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting." + +msgid "Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly." +msgstr "Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly." + +msgid "Detected device limit" +msgstr "Detected device limit" + +msgid "Quality for this device" +msgstr "Quality for this device" + +msgid "Follow app setting ({})" +msgstr "Follow app setting ({})" + +msgid "Hi-Res — 24-bit, up to 96 kHz" +msgstr "Hi-Res — 24-bit, up to 96 kHz" + +msgid "CD — 16-bit / 44.1 kHz" +msgstr "CD — 16-bit / 44.1 kHz" + +msgid "MP3 320" +msgstr "MP3 320" + +msgid "Qobuz only offers these tiers — there is no 48 kHz option. The step below Hi-Res is CD 44.1 kHz." +msgstr "Qobuz only offers these tiers — there is no 48 kHz option. The step below Hi-Res is CD 44.1 kHz." + +msgid "Limited by the quality cap for {}" +msgstr "Limited by the quality cap for {}" + +msgid "capped for this device" +msgstr "capped for this device" + +msgid "Albums by folder" +msgstr "Albums by folder" + +msgid "Albums by metadata" +msgstr "Albums by metadata" + +msgid "ALBUMS VIEW" +msgstr "ALBUMS VIEW" + +msgid "Album grouping" +msgstr "Album grouping" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." + +msgid "+{} more artists" +msgstr "+{} more artists" + +msgid "Show less" +msgstr "Show less" +# Most Played Albums rail title +msgid "Most Played Albums" +msgstr "Most Played Albums" + +# Most Played Albums View-all — per-card play count +msgid "{} plays" +msgstr "{} plays" + +# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast +msgid "Show lyrics translation" +msgstr "Show lyrics translation" + +msgid "Hide lyrics translation" +msgstr "Hide lyrics translation" + +msgid "Translation language" +msgstr "Translation language" + +msgid "Auto (account language)" +msgstr "Auto (account language)" + +msgid "Translation is not available right now. Please try again later." +msgstr "Translation is not available right now. Please try again later." +# Discover Recommendations — Not interested (reco-scoped dismissals) + +msgid "Not interested" +msgstr "Not interested" + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." + +msgid "No dismissed artists" +msgstr "No dismissed artists" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} won't appear in Recommendations anymore" + +msgid "Artist dismissed from Recommendations" +msgstr "Artist dismissed from Recommendations" + +msgid "{} restored to Recommendations" +msgstr "{} restored to Recommendations" + +msgid "LIBRARY › ALL" +msgstr "LIBRARY › ALL" + +msgid "Show local files & Plex" +msgstr "Show local files & Plex" + +msgid "Include your favorited local files and Plex items in the Library All feed." +msgstr "Include your favorited local files and Plex items in the Library All feed." + +msgid "Play indicator animation" +msgstr "Play indicator animation" + +msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU." +msgstr "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU." + +msgid "Invert swipe navigation direction" +msgstr "Invert swipe navigation direction" + +msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward." +msgstr "Swap the two-finger touchpad swipe: left goes back, right goes forward." + +msgid "Local items in Library All" +msgstr "Local items in Library All" + +msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track." +msgstr "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track." + +msgid "Favorited items only" +msgstr "Favorited items only" + +msgid "Entire local library" +msgstr "Entire local library" + +msgid "Select multiple albums" +msgstr "Select multiple albums" + +msgid "Play later" +msgstr "Play later" + +msgid "Added to play later" +msgstr "Added to play later" + +msgid "Next in queue" +msgstr "Next in queue" + +msgid "Next up" +msgstr "Next up" + +msgid "Removed {} tracks from favorites" +msgstr "Removed {} tracks from favorites" + +msgid "Removing {} tracks from favorites…" +msgstr "Removing {} tracks from favorites…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Album \"{}\" blocked" + +msgid "Album \"{}\" unblocked" +msgstr "Album \"{}\" unblocked" + +msgid "Failed to block album" +msgstr "Failed to block album" + +msgid "Failed to unblock album" +msgstr "Failed to unblock album" + +msgid "Failed to clear album blacklist" +msgstr "Failed to clear album blacklist" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "Removed {} album from blacklist" +msgstr[1] "Removed {} albums from blacklist" + +msgid "Playlist imported" +msgstr "Playlist imported" + +msgid "Playlist import failed" +msgstr "Playlist import failed" + +msgid "Not logged in to Qobuz" +msgstr "Not logged in to Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Delete folder" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Delete the folder “{}”? Its playlists move back to the root." + +msgid "Choose a folder image" +msgstr "Choose a folder image" + +msgid "Images" +msgstr "Images" + +msgid "Failed to create folder" +msgstr "Failed to create folder" + +msgid "Failed to save folder" +msgstr "Failed to save folder" + +msgid "Failed to delete folder" +msgstr "Failed to delete folder" + +msgid "Failed to rename playlist" +msgstr "Failed to rename playlist" + +msgid "Failed to delete playlist" +msgstr "Failed to delete playlist" + +msgid "Couldn't create the playlist" +msgstr "Couldn't create the playlist" + +# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard, +# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. +msgid "Open playlist" +msgstr "Open playlist" + +# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's +# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen +# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter", +# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On", +# "Bands & Projects", "Load more", "Loading…", "Artist Scene", +# "No artists found for this scene.", "No album appearances found." and +# "Select an artist to see their albums." (period-only delta) already exist. +# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7, +# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them +# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every +# call site substitutes explicitly, e.g. .replace("{artist}", name). + +msgid "Based on {artist} · {genres}" +msgstr "Based on {artist} · {genres}" + +msgid "Searching MusicBrainz for {genre} artists..." +msgstr "Searching MusicBrainz for {genre} artists..." + +msgid "Verifying availability on Qobuz..." +msgstr "Verifying availability on Qobuz..." + +msgid "Preparing results..." +msgstr "Preparing results..." + +msgid "No matching artists from this scene are currently available on Qobuz." +msgstr "No matching artists from this scene are currently available on Qobuz." + +msgid "Loading albums..." +msgstr "Loading albums..." + +msgid "Failed to load albums" +msgstr "Failed to load albums" + +msgid "Failed to load appearances" +msgstr "Failed to load appearances" + +msgid "Looking up {name}..." +msgstr "Looking up {name}..." + +msgid "Failed to look up musician" +msgstr "Failed to look up musician" + +msgid "Browse view" +msgstr "Browse view" + +msgid "Grid view" +msgstr "Grid view" + +msgid "Alphabetical (A-Z)" +msgstr "Alphabetical (A-Z)" + +msgid "This musician appears in album credits but has limited catalog information available in Qobuz." +msgstr "This musician appears in album credits but has limited catalog information available in Qobuz." + +msgid "Unable to verify this musician's catalog. The information shown is based on album credits." +msgstr "Unable to verify this musician's catalog. The information shown is based on album credits." + +msgid "This musician appears in album credits." +msgstr "This musician appears in album credits." + +msgid "Tunnel Flow" +msgstr "Tunnel Flow" + +# --- Purchases (Slice 13, Qt port) --- +msgid "Download goodies" +msgstr "Download goodies" + +# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17 +# §8-6). "Unavailable" and "This track is no longer available" already +# exist in this file and are REUSED by the render and the reactive skip toast +# — nothing below twins them. +# +# The skipped-tracks toast is a plural PAIR, per the convention already set by +# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build +# drops N rows at once, and a singular-only string reads wrong in every locale +# with plural rules. Russian carries all three forms. +# +# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled +# state (contract §5.3/F5): the track left the catalogue but the user owns a +# downloaded copy, so it still plays. It cannot fold into "This track is no +# longer available" — that sentence would be a lie on a row playing right now. +# +# Em dash is U+2014 with spaces on both sides, as everywhere else in this file. +# en is the source-language stub: msgstr == msgid, as every batch that +# introduced a genuinely new msgid here has done. +msgid "Find available version" +msgstr "Find available version" + +msgid "Replace unavailable track" +msgstr "Replace unavailable track" + +msgid "No available version found" +msgstr "No available version found" + +msgid "Track replaced" +msgstr "Track replaced" + +msgid "Could not replace the track" +msgstr "Could not replace the track" + +msgid "The replacement was added at the end of the playlist" +msgstr "The replacement was added at the end of the playlist" + +msgid "{} track skipped — no longer available" +msgid_plural "{} tracks skipped — no longer available" +msgstr[0] "{} track skipped — no longer available" +msgstr[1] "{} tracks skipped — no longer available" + +msgid "No longer on Qobuz — playing your downloaded copy" +msgstr "No longer on Qobuz — playing your downloaded copy" + +msgid "Exact match" +msgstr "Exact match" + +msgid "Low confidence" +msgstr "Low confidence" + +msgid "Replace" +msgstr "Replace" + +msgid "Replacing..." +msgstr "Replacing..." + +msgid "The replacement takes the unavailable track's place in the playlist." +msgstr "The replacement takes the unavailable track's place in the playlist." + +# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19) +msgid "Items per carousel" +msgstr "Items per carousel" + +msgid "All" +msgstr "All" + +# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19) +msgid "Recently Played Playlists" +msgstr "Recently Played Playlists" + +msgid "Playlists you play will appear here." +msgstr "Playlists you play will appear here." + +# Applied-filters tooltip (2026-08-19) +msgid "+{} more" +msgstr "+{} more" + +msgid "+{} more filters" +msgstr "+{} more filters" + +msgid "Applied filters" +msgstr "Applied filters" + +# Applied-filters tooltip: group labels (2026-08-19) +msgid "Category" +msgstr "Category" + +# Applied-filters tooltip: Library source switches (2026-08-19) +msgid "Favorites" +msgstr "Favorites" + +# Discover configurator: Refresh content (2026-08-19) +msgid "Refresh content" +msgstr "Refresh content" + +# Appearance: click-on-section opens the first tab (2026-08-19) +msgid "Click on menu item navigates to first tab" +msgstr "Click on menu item navigates to first tab" + +msgid "Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu" +msgstr "Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu" + +# Discover configurator: per-rail default item count (2026-08-19) +msgid "Default ({})" +msgstr "Default ({})" + +msgid "Default" +msgstr "Default" + +msgid "Media" +msgstr "Media" + +msgid "Open audio CD" +msgstr "Open audio CD" + +msgid "Audio CD" +msgstr "Audio CD" + +msgid "No optical drive found." +msgstr "No optical drive found." + +msgid "No disc in the drive." +msgstr "No disc in the drive." + +msgid "That disc has no audio tracks." +msgstr "That disc has no audio tracks." + +msgid "Could not read the disc." +msgstr "Could not read the disc." + +msgid "Track {}" +msgstr "Track {}" + +msgid "Open SACD image…" +msgstr "Open SACD image…" + +msgid "Choose a disc image" +msgstr "Choose a disc image" + +msgid "That image has no stereo audio area." +msgstr "That image has no stereo audio area." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "This disc is DST-compressed, which QBZ cannot play yet." + +msgid "That file is not a disc image." +msgstr "That file is not a disc image." + +msgid "That file is not a SACD image." +msgstr "That file is not a SACD image." + +msgid "That SACD image is damaged or unsupported." +msgstr "That SACD image is damaged or unsupported." + +msgid "SACD" +msgstr "SACD" + +msgid "Library Explorer" +msgstr "Library Explorer" + +msgid "Listen list" +msgstr "Listen list" + +msgid "Genres" +msgstr "Genres" + +msgid "All genres" +msgstr "All genres" + +msgid "All artists" +msgstr "All artists" + +msgid "All albums" +msgstr "All albums" + +msgid "No albums match these filters" +msgstr "No albums match these filters" + +msgid "Try a different genre, artist, album, source or quality." +msgstr "Try a different genre, artist, album, source or quality." + +msgid "Favorites only" +msgstr "Favorites only" + +msgid "Where should the ripped album go?" +msgstr "Where should the ripped album go?" + +msgid "Ripping" +msgstr "Ripping" + +msgid "Nothing to rip." +msgstr "Nothing to rip." + +msgid "Ripped {} track to your library" +msgid_plural "Ripped {} tracks to your library" +msgstr[0] "Ripped {} track to your library" +msgstr[1] "Ripped {} tracks to your library" + +# Discs: rip wizard, metadata lookup and the disc tooltips (2026-08-21) + +msgid "Play a folder, CD or disc image without adding it to your library" +msgstr "Play a folder, CD or disc image without adding it to your library" + +msgid "Reading the disc…" +msgstr "Reading the disc…" + +msgid "Re-scan your folders and media servers for new music" +msgstr "Re-scan your folders and media servers for new music" + +msgid "Manage the folders your library is built from" +msgstr "Manage the folders your library is built from" + +msgid "Play the whole disc from the first track" +msgstr "Play the whole disc from the first track" + +msgid "Play it in a shuffled order" +msgstr "Play it in a shuffled order" + +msgid "Look this disc up and correct its details" +msgstr "Look this disc up and correct its details" + +msgid "Copy this CD to your computer as FLAC files" +msgstr "Copy this CD to your computer as FLAC files" + +msgid "Close this disc and go back to your library" +msgstr "Close this disc and go back to your library" + +msgid "Correct disc details" +msgstr "Correct disc details" + +msgid "SACD image" +msgstr "SACD image" + +msgid "Close without changing anything" +msgstr "Close without changing anything" + +msgid "Community database. Knows discs by their table of contents." +msgstr "Community database. Knows discs by their table of contents." + +msgid "Collector database. Stronger on pressings, editions and catalogue numbers." +msgstr "Collector database. Stronger on pressings, editions and catalogue numbers." + +msgid "Search this provider" +msgstr "Search this provider" + +msgid "The provider is rate-limiting right now. Try again in a moment." +msgstr "The provider is rate-limiting right now. Try again in a moment." + +msgid "No releases matched. Try fewer words, or the other provider." +msgstr "No releases matched. Try fewer words, or the other provider." + +msgid "Search for the release this disc actually is." +msgstr "Search for the release this disc actually is." + +msgid "Remove my correction" +msgstr "Remove my correction" + +msgid "Forget the saved details for this disc and look it up fresh next time" +msgstr "Forget the saved details for this disc and look it up fresh next time" + +msgid "Applying..." +msgstr "Applying..." + +msgid "Use these details" +msgstr "Use these details" + +msgid "Rename this disc and remember it for next time" +msgstr "Rename this disc and remember it for next time" + +msgid "Rip this CD" +msgstr "Rip this CD" + +msgid "What will happen" +msgstr "What will happen" + +msgid "Track names" +msgstr "Track names" + +msgid "Your library" +msgstr "Your library" + +msgid "Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags." +msgstr "Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags." + +msgid "Format: FLAC (lossless) — the only format QBZ writes" +msgstr "Format: FLAC (lossless) — the only format QBZ writes" + +msgid "Album artist" +msgstr "Album artist" + +msgid "Where the album goes" +msgstr "Where the album goes" + +msgid "Choose folder…" +msgstr "Choose folder…" + +msgid "Change…" +msgstr "Change…" + +msgid "Pick the folder the album folder will be created in" +msgstr "Pick the folder the album folder will be created in" + +msgid "Nothing chosen yet" +msgstr "Nothing chosen yet" + +msgid "This folder is already part of your Local Library." +msgstr "This folder is already part of your Local Library." + +msgid "This folder is outside your Local Library, so the album will not appear there on its own." +msgstr "This folder is outside your Local Library, so the album will not appear there on its own." + +msgid "Choose a destination first and this step will know what to offer." +msgstr "Choose a destination first and this step will know what to offer." + +msgid "Re-scan that folder when the rip finishes" +msgstr "Re-scan that folder when the rip finishes" + +msgid "Only that folder, not your whole library." +msgstr "Only that folder, not your whole library." + +msgid "Do nothing" +msgstr "Do nothing" + +msgid "The album appears the next time you scan." +msgstr "The album appears the next time you scan." + +msgid "Add this folder to Local Library and scan it" +msgstr "Add this folder to Local Library and scan it" + +msgid "Future rips into the same folder will be picked up too." +msgstr "Future rips into the same folder will be picked up too." + +msgid "The files are written; the library will not know about them." +msgstr "The files are written; the library will not know about them." + +msgid "A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool." +msgstr "A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool." + +msgid "Close without ripping anything" +msgstr "Close without ripping anything" + +msgid "Go back a step" +msgstr "Go back a step" + +msgid "Start ripping" +msgstr "Start ripping" + +msgid "Go to the next step" +msgstr "Go to the next step" + +msgid "Read the disc and write the FLAC files" +msgstr "Read the disc and write the FLAC files" + +msgid "Choose a destination folder first" +msgstr "Choose a destination folder first" + +msgid "No disc is open." +msgstr "No disc is open." + +msgid "Couldn't load that release." +msgstr "Couldn't load that release." + +msgid "Disc details updated" +msgstr "Disc details updated" + +msgid "Correction removed. Re-open the disc to look it up again." +msgstr "Correction removed. Re-open the disc to look it up again." + +msgid "Only a physical CD can be ripped." +msgstr "Only a physical CD can be ripped." + +msgid "Choose where the album should go first." +msgstr "Choose where the album should go first." + +msgid "Couldn't add that folder to the library" +msgstr "Couldn't add that folder to the library" + +# Rip progress panel, cancel and partial rips (2026-08-21) + +msgid "Writing FLAC files to" +msgstr "Writing FLAC files to" + +msgid "Hide this panel — the rip keeps going" +msgstr "Hide this panel — the rip keeps going" + +msgid "Ripping — don't eject the disc until this finishes." +msgstr "Ripping — don't eject the disc until this finishes." + +msgid "Track" +msgstr "Track" + +msgid "Finished" +msgstr "Finished" + +msgid "Files already written stay on disk. Delete them yourself if you don't want them." +msgstr "Files already written stay on disk. Delete them yourself if you don't want them." + +msgid "Stop ripping" +msgstr "Stop ripping" + +msgid "Stopping…" +msgstr "Stopping…" + +msgid "Stop after the current track. Nothing is deleted." +msgstr "Stop after the current track. Nothing is deleted." + +msgid "Rip stopped. The files already written are still there." +msgstr "Rip stopped. The files already written are still there." + +msgid "See what the rip is doing, or stop it" +msgstr "See what the rip is doing, or stop it" + +msgid "selected" +msgstr "selected" + +msgid "Pick at least one track to rip." +msgstr "Pick at least one track to rip." + +# Ephemeral pane: per-disc blocks and the track filter (2026-08-21) + +msgid "Disc {}" +msgstr "Disc {}" + +msgid "Filter tracks" +msgstr "Filter tracks" + +msgid "No track matches that." +msgstr "No track matches that." + +msgid "Continuous playback" +msgstr "Continuous playback" + +msgid "Open" +msgstr "Open" + +msgid "Folder view" +msgstr "Folder view" + +msgid "Open media" +msgstr "Open media" + +msgid "Open folder…" +msgstr "Open folder…" + +# Local Library tab order and logged-off landing (2026-08-24) +msgid "LIBRARY TABS" +msgstr "LIBRARY TABS" + +msgid "Tab order and default" +msgstr "Tab order and default" + +msgid "Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session." +msgstr "Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session." + +msgid "Customize" +msgstr "Customize" + +msgid "Local Library tabs" +msgstr "Local Library tabs" + +msgid "Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session." +msgstr "Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session." + +msgid "Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu" +msgstr "Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu" + +msgid "Reset to defaults" +msgstr "Reset to defaults" + +# Local Library Genres filter placement (2026-08-24) +msgid "GENRES VIEW" +msgstr "GENRES VIEW" + +msgid "Genre filters position" +msgstr "Genre filters position" + +msgid "Place the chained genre filters above, beside or below the album results." +msgstr "Place the chained genre filters above, beside or below the album results." + +msgid "Top" +msgstr "Top" + +msgid "Right" +msgstr "Right" + +msgid "Left" +msgstr "Left" + +msgid "Bottom" +msgstr "Bottom" + +msgid "Album" +msgstr "Album" + +msgid "Album metadata saved." +msgstr "Album metadata saved." + +msgid "Album title" +msgstr "Album title" + +msgid "Album title is required." +msgstr "Album title is required." + +msgid "Apply to form" +msgstr "Apply to form" + +msgid "CUE-based albums can only be edited with a sidecar." +msgstr "CUE-based albums can only be edited with a sidecar." + +msgid "CUE-based albums use sidecar metadata." +msgstr "CUE-based albums use sidecar metadata." + +msgid "Cancel" +msgstr "Cancel" + +msgid "Canonical" +msgstr "Canonical" + +msgid "Catalog number" +msgstr "Catalog number" + +msgid "Close without saving" +msgstr "Close without saving" + +msgid "Couldn't inspect album metadata." +msgstr "Couldn't inspect album metadata." + +msgid "Couldn't load that metadata result." +msgstr "Couldn't load that metadata result." + +msgid "Couldn't open the metadata source." +msgstr "Couldn't open the metadata source." + +msgid "Couldn't save metadata: {}" +msgstr "Couldn't save metadata: {}" + +msgid "Detected" +msgstr "Detected" + +msgid "Disc" +msgstr "Disc" + +msgid "Disc number" +msgstr "Disc number" + +msgid "Editing the selected physical version" +msgstr "Editing the selected physical version" + +msgid "Enter an album title or artist to search." +msgstr "Enter an album title or artist to search." + +msgid "Every track needs a title." +msgstr "Every track needs a title." + +msgid "File names are shown for reference and cannot be changed here." +msgstr "File names are shown for reference and cannot be changed here." + +msgid "Find metadata" +msgstr "Find metadata" + +msgid "Genre" +msgstr "Genre" + +msgid "ID3 writing" +msgstr "ID3 writing" + +msgid "Metadata editing is available for local file versions only." +msgstr "Metadata editing is available for local file versions only." + +msgid "Metadata search failed." +msgstr "Metadata search failed." + +msgid "No local album version is selected." +msgstr "No local album version is selected." + +msgid "None" +msgstr "None" + +msgid "One or more audio files are unavailable." +msgstr "One or more audio files are unavailable." + +msgid "Open source" +msgstr "Open source" + +msgid "QBZ will preflight every file, update its canonical tag layer, then read it back to verify the change. Keep a backup if these files cannot be replaced." +msgstr "QBZ will preflight every file, update its canonical tag layer, then read it back to verify the change. Keep a backup if these files cannot be replaced." + +msgid "Save" +msgstr "Save" + +msgid "Save changes to" +msgstr "Save changes to" + +msgid "Saving…" +msgstr "Saving…" + +msgid "Search" +msgstr "Search" + +msgid "Searching…" +msgstr "Searching…" + +msgid "Sidecar file (recommended)" +msgstr "Sidecar file (recommended)" + +msgid "Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled." +msgstr "Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled." + +msgid "Synchronize compatible tag layers" +msgstr "Synchronize compatible tag layers" + +msgid "Tag layers" +msgstr "Tag layers" + +msgid "The canonical tag is not writable for every file." +msgstr "The canonical tag is not writable for every file." + +msgid "The metadata editor is no longer open." +msgstr "The metadata editor is no longer open." + +msgid "The metadata form could not be read." +msgstr "The metadata form could not be read." + +msgid "The track list changed; reopen the editor." +msgstr "The track list changed; reopen the editor." + +msgid "The track list contains a duplicate row." +msgstr "The track list contains a duplicate row." + +msgid "Track number" +msgstr "Track number" + +msgid "Tracks" +msgstr "Tracks" + +msgid "Unknown metadata persistence mode." +msgstr "Unknown metadata persistence mode." + +msgid "Write and verify" +msgstr "Write and verify" + +msgid "Write metadata into audio files?" +msgstr "Write metadata into audio files?" + +msgid "Write to audio files" +msgstr "Write to audio files" + +msgid "Writing" +msgstr "Writing" + +msgid "Year" +msgstr "Year" + +msgid "Year must be a valid number." +msgstr "Year must be a valid number." + +msgid "tracks" +msgstr "tracks" + +msgid "{} must be a positive number." +msgstr "{} must be a positive number." + +msgid "The ephemeral session changed; reopen the metadata editor." +msgstr "The ephemeral session changed; reopen the metadata editor." + +msgid "Album artists" +msgstr "Album artists" + +msgid "Album artists (ordered; separate with semicolons)" +msgstr "Album artists (ordered; separate with semicolons)" + +msgid "Artist credit" +msgstr "Artist credit" + +msgid "Artists" +msgstr "Artists" + +msgid "Artists (ordered; semicolon separated)" +msgstr "Artists (ordered; semicolon separated)" + +msgid "Artwork is larger than 25 MiB." +msgstr "Artwork is larger than 25 MiB." + +msgid "Artwork search failed." +msgstr "Artwork search failed." + +msgid "Artwork source did not provide a secure URL." +msgstr "Artwork source did not provide a secure URL." + +msgid "Choose album artwork" +msgstr "Choose album artwork" + +msgid "Choose another file" +msgstr "Choose another file" + +msgid "Choose file" +msgstr "Choose file" + +msgid "Compilation / Various Artists" +msgstr "Compilation / Various Artists" + +msgid "Composers" +msgstr "Composers" + +msgid "Couldn't download that artwork." +msgstr "Couldn't download that artwork." + +msgid "Couldn't stage artwork" +msgstr "Couldn't stage artwork" + +msgid "Current artwork" +msgstr "Current artwork" + +msgid "Discogs release ID" +msgstr "Discogs release ID" + +msgid "Enter an album title to find artwork." +msgstr "Enter an album title to find artwork." + +msgid "File" +msgstr "File" + +msgid "Find artwork" +msgstr "Find artwork" + +msgid "Local file" +msgstr "Local file" + +msgid "MusicBrainz album artist IDs" +msgstr "MusicBrainz album artist IDs" + +msgid "MusicBrainz artist IDs" +msgstr "MusicBrainz artist IDs" + +msgid "MusicBrainz recording ID" +msgstr "MusicBrainz recording ID" + +msgid "MusicBrainz release ID" +msgstr "MusicBrainz release ID" + +msgid "MusicBrainz release-group ID" +msgstr "MusicBrainz release-group ID" + +msgid "MusicBrainz track ID" +msgstr "MusicBrainz track ID" + +msgid "Performers" +msgstr "Performers" + +msgid "Revert selection" +msgstr "Revert selection" + +msgid "Selected track details" +msgstr "Selected track details" + +msgid "That artwork result is no longer available." +msgstr "That artwork result is no longer available." + +msgid "The selected artwork changed; choose it again." +msgstr "The selected artwork changed; choose it again." + +msgid "The selected artwork has unsupported dimensions." +msgstr "The selected artwork has unsupported dimensions." + +msgid "The selected file is not a supported image." +msgstr "The selected file is not a supported image." + +msgid "Title" +msgstr "Title" + +msgid "Goniometer" +msgstr "Goniometer" + +msgid "Oscilloscope" +msgstr "Oscilloscope" + +msgid "Track waveform" +msgstr "Track waveform" + +msgid "Show the full-track waveform in the player seek bar." +msgstr "Show the full-track waveform in the player seek bar." + +msgid "The typeface used across the app. System follows your operating system; the other choices are bundled with QBZ (requires restart)" +msgstr "The typeface used across the app. System follows your operating system; the other choices are bundled with QBZ (requires restart)" + +msgid "Compact header" +msgstr "Compact header" + +msgid "Show less data in album headers." +msgstr "Show less data in album headers." + +msgid "Show full album header" +msgstr "Show full album header" + +msgid "Show compact album header" +msgstr "Show compact album header" + +msgid "Immediate scrobbling" +msgstr "Immediate scrobbling" + +msgid "Send scrobbles immediately while manual offline mode is enabled." +msgstr "Send scrobbles immediately while manual offline mode is enabled." + +msgid "Accumulated scrobbling" +msgstr "Accumulated scrobbling" + +msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old." +msgstr "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old." + +msgid "Scrobble while logged out" +msgstr "Scrobble while logged out" + +msgid "Continue sending plays to connected scrobblers without a Qobuz session." +msgstr "Continue sending plays to connected scrobblers without a Qobuz session." + +msgid "Focus on playing" +msgstr "Focus on playing" + +msgid "Go to top" +msgstr "Go to top" + +msgid "Quick view" +msgstr "Quick view" + +msgid "Loading album…" +msgstr "Loading album…" + +msgid "Could not load album" +msgstr "Could not load album" + +msgid "This album has no tracks." +msgstr "This album has no tracks." + +msgid "Reactive Rings" +msgstr "Reactive Rings" + +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ could not fully release the previous audio device. See the audio log for details." +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker + +msgid "Volume Up" +msgstr "Volume Up" + +msgid "Volume Down" +msgstr "Volume Down" + +msgid "Mute / Unmute" +msgstr "Mute / Unmute" + +msgid "Toggle Shuffle" +msgstr "Toggle Shuffle" + +msgid "Cycle Repeat" +msgstr "Cycle Repeat" + +msgid "Like Track" +msgstr "Like Track" + +msgid "Seek Forward" +msgstr "Seek Forward" + +msgid "Seek Back" +msgstr "Seek Back" + +msgid "Keymap" +msgstr "Keymap" + +msgid "Vim" +msgstr "Vim" + +# Local Library QoL: Explorer, scan progress, and per-track metadata editor +msgid "All years" +msgstr "All years" + +msgid "Explorer columns" +msgstr "Explorer columns" + +msgid "Genre and year" +msgstr "Genre and year" + +msgid "Years" +msgstr "Years" + +msgid "Edit track metadata" +msgstr "Edit track metadata" + +msgid "Building library index" +msgstr "Building library index" + +msgid "Local folders" +msgstr "Local folders" + +msgid "Next track" +msgstr "Next track" + +msgid "Overall" +msgstr "Overall" + +msgid "Previous track" +msgstr "Previous track" + +msgid "Scanning library" +msgstr "Scanning library" + +msgid "Source" +msgstr "Source" + +msgid "The track is no longer in the library." +msgstr "The track is no longer in the library." + +msgid "Updating library index" +msgstr "Updating library index" + +msgid "Edit album" +msgstr "Edit album" + +msgid "Editing a local metadata sidecar" +msgstr "Editing a local metadata sidecar" + +msgid "Media-server metadata is stored in a local sidecar." +msgstr "Media-server metadata is stored in a local sidecar." + +msgid "Media-server metadata can only be edited with a sidecar." +msgstr "Media-server metadata can only be edited with a sidecar." + +msgid "Network file" +msgstr "Network file" + +msgid "Offline download" +msgstr "Offline download" + +msgid "File path" +msgstr "File path" + +msgid "Containing folder" +msgstr "Containing folder" + +msgid "Media item ID" +msgstr "Media item ID" + +msgid "Album ID" +msgstr "Album ID" + +msgid "Server" +msgstr "Server" + +msgid "Duration" +msgstr "Duration" + +msgid "Format" +msgstr "Format" + +msgid "Quality" +msgstr "Quality" + +msgid "Channels" +msgstr "Channels" + +msgid "File size" +msgstr "File size" + +msgid "Location" +msgstr "Location" + +msgid "The media item is no longer available." +msgstr "The media item is no longer available." + +msgid "The local album folder is not currently available." +msgstr "The local album folder is not currently available." + +msgid "Everything is already available offline" +msgstr "Everything is already available offline" + +msgid "Couldn't load that playlist" +msgstr "Couldn't load that playlist" + +msgid "This playlist has no playable tracks" +msgstr "This playlist has no playable tracks" + +msgid "Some tracks are already available offline" +msgstr "Some tracks are already available offline" + +msgid "{} of {} tracks in “{}” are already available offline." +msgstr "{} of {} tracks in “{}” are already available offline." + +msgid "Download the entire playlist again" +msgstr "Download the entire playlist again" + +msgid "Download the entire album again" +msgstr "Download the entire album again" + +msgid "Re-downloads every track and can repair corrupt offline copies." +msgstr "Re-downloads every track and can repair corrupt offline copies." + +msgid "Only missing tracks" +msgstr "Only missing tracks" + +msgid "Keeps existing offline copies and downloads tracks that are not available yet." +msgstr "Keeps existing offline copies and downloads tracks that are not available yet." + +msgid "Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services." +msgstr "Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services." + +msgid "PRIVACY" +msgstr "PRIVACY" + +msgid "Listening history" +msgstr "Listening history" + +msgid "Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler." +msgstr "Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler." + +msgid "Clear listening history" +msgstr "Clear listening history" + +msgid "Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected." +msgstr "Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected." + +msgid "Listening history cleared." +msgstr "Listening history cleared." + +msgid "Couldn't clear listening history." +msgstr "Couldn't clear listening history." + +msgid "Checking ALSA mixer controls…" +msgstr "Checking ALSA mixer controls…" + +msgid "This device exposes multiple playback controls. Choose which one QBZ should use." +msgstr "This device exposes multiple playback controls. Choose which one QBZ should use." + +msgid "The saved mixer control is no longer available. Choose another control." +msgstr "The saved mixer control is no longer available. Choose another control." + +msgid "QBZ does not have permission to read this device's ALSA mixer." +msgstr "QBZ does not have permission to read this device's ALSA mixer." + +msgid "The ALSA mixer is busy. Close the app using it and try again." +msgstr "The ALSA mixer is busy. Close the app using it and try again." + +msgid "The selected ALSA device is no longer available." +msgstr "The selected ALSA device is no longer available." + +msgid "This ALSA device does not expose a mixer." +msgstr "This ALSA device does not expose a mixer." + +msgid "QBZ could not write the selected ALSA mixer control. Direct playback remains fixed at 100%." +msgstr "QBZ could not write the selected ALSA mixer control. Direct playback remains fixed at 100%." + +msgid "Selected mixer control" +msgstr "Selected mixer control" + +msgid "Choose mixer control" +msgstr "Choose mixer control" + +msgid "Change mixer control" +msgstr "Change mixer control" + +msgid "Qobuz Connect could not shut down safely. Quit and reopen QBZ, then try logging out again." +msgstr "Qobuz Connect could not shut down safely. Quit and reopen QBZ, then try logging out again." + +msgid "Show filter panels" +msgstr "Show filter panels" + +msgid "Hide filter panels" +msgstr "Hide filter panels" + +msgid "Already in" +msgstr "Already in" + +msgid "Playlist index updating..." +msgstr "Playlist index updating..." + +msgid "{} of {}" +msgstr "{} of {}" + +msgid "Last playlist" +msgstr "Last playlist" + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgstr[1] "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." + +msgid "Qobuz Connect device" +msgstr "Qobuz Connect device" + +msgid "Continue playback on %1" +msgstr "Continue playback on %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Use the Qobuz Connect queue and keep that device active." + +msgid "Continue playback on this device" +msgstr "Continue playback on this device" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Use the Qobuz Connect queue and replace the local queue." + +msgid "Continue this device's current playback" +msgstr "Continue this device's current playback" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Replace the Qobuz Connect queue and playback state with this device's current queue and position." + +msgid "Cancel Qobuz Connect" +msgstr "Cancel Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Keep both queues unchanged and continue the current local playback." + +msgid "Choose how to continue" +msgstr "Choose how to continue" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." + +msgid "Ask every time" +msgstr "Ask every time" + +msgid "Continue on the active renderer" +msgstr "Continue on the active renderer" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Continue here with the Qobuz Connect queue" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Continue local playback and replace the Qobuz Connect queue" + +msgid "When playback conflicts" +msgstr "When playback conflicts" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Ask each time or automatically choose which queue and device should continue." + +msgid "Import / Export" +msgstr "Import / Export" + +msgid "This bundle holds the app's own settings: audio, playback, appearance, integrations. It does not contain your Qobuz favorites, playlists, blacklist or purchases." +msgstr "This bundle holds the app's own settings: audio, playback, appearance, integrations. It does not contain your Qobuz favorites, playlists, blacklist or purchases." + +msgid "BLACKLIST" +msgstr "BLACKLIST" + +msgid "Export blacklist…" +msgstr "Export blacklist…" + +msgid "Save your blocked artists and albums as a file you can hand to another QBZ user." +msgstr "Save your blocked artists and albums as a file you can hand to another QBZ user." + +msgid "Import blacklist…" +msgstr "Import blacklist…" + +msgid "Add blocked artists and albums from a file. Nothing already on your blacklist is removed." +msgstr "Add blocked artists and albums from a file. Nothing already on your blacklist is removed." + +msgid "Import…" +msgstr "Import…" + +msgid "Import failed." +msgstr "Import failed." + +msgid "Not a QBZ blacklist file." +msgstr "Not a QBZ blacklist file." + +msgid "Choose a blacklist file" +msgstr "Choose a blacklist file" + +msgid "Imported {} artists and {} albums ({} already blocked)." +msgstr "Imported {} artists and {} albums ({} already blocked)." + +msgid "Member" +msgstr "Member" + +msgid "QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™." +msgstr "QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™." + +msgid "ACCOUNT MIGRATION" +msgstr "ACCOUNT MIGRATION" + +msgid "Moves favorites, playlists and followed playlists from one Qobuz account into another, adding only what is missing and deleting nothing on either side. It does not move subscriptions or purchases: those stay with the Qobuz account that holds them." +msgstr "Moves favorites, playlists and followed playlists from one Qobuz account into another, adding only what is missing and deleting nothing on either side. It does not move subscriptions or purchases: those stay with the Qobuz account that holds them." + +msgid "Create migration snapshot" +msgstr "Create migration snapshot" + +msgid "Sign in with the account you are moving FROM, then save its favorites and playlists to a file in its QBZ profile." +msgstr "Sign in with the account you are moving FROM, then save its favorites and playlists to a file in its QBZ profile." + +msgid "Create…" +msgstr "Create…" + +msgid "Migrate into this account" +msgstr "Migrate into this account" + +msgid "Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing." +msgstr "Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing." + +msgid "No snapshots yet. Sign in with the old account and create one first." +msgstr "No snapshots yet. Sign in with the old account and create one first." + +msgid "{} favorites · {} playlists · {} followed" +msgstr "{} favorites · {} playlists · {} followed" + +msgid "this account" +msgstr "this account" + +msgid "Migrate…" +msgstr "Migrate…" + +msgid "Account {} · {}" +msgstr "Account {} · {}" + +msgid "Reading favorites and playlists…" +msgstr "Reading favorites and playlists…" + +msgid "Reading this account…" +msgstr "Reading this account…" + +msgid "Adding favorites…" +msgstr "Adding favorites…" + +msgid "Adding playlists…" +msgstr "Adding playlists…" + +msgid "Following playlists…" +msgstr "Following playlists…" + +msgid "{} / {} — {}" +msgstr "{} / {} — {}" + +msgid "Snapshot failed." +msgstr "Snapshot failed." + +msgid "Migration failed." +msgstr "Migration failed." + +msgid "Saved {} favorites, {} playlists and {} followed playlists to {}" +msgstr "Saved {} favorites, {} playlists and {} followed playlists to {}" + +msgid "That snapshot was taken from this same account." +msgstr "That snapshot was taken from this same account." + +msgid "Nothing to migrate: everything is already in this account." +msgstr "Nothing to migrate: everything is already in this account." + +msgid "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there." +msgstr "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there." + +msgid "{} items failed; see the log." +msgstr "{} items failed; see the log." + +msgid "Also copy the local profile" +msgstr "Also copy the local profile" + +msgid "Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer." +msgstr "Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer." + +msgid "Media server connections" +msgstr "Media server connections" + +msgid "Plex, Jellyfin and Subsonic settings, including their credentials." +msgstr "Plex, Jellyfin and Subsonic settings, including their credentials." + +msgid "Scrobbler accounts" +msgstr "Scrobbler accounts" + +msgid "Last.fm and ListenBrainz settings, including their credentials." +msgstr "Last.fm and ListenBrainz settings, including their credentials." + +msgid "The listen log and the events behind offline recommendations." +msgstr "The listen log and the events behind offline recommendations." + +msgid "Local profile: {} rows copied; {} playlist rows had no match." +msgstr "Local profile: {} rows copied; {} playlist rows had no match." + +msgid "The library already had folders: run a rescan to index the added ones." +msgstr "The library already had folders: run a rescan to index the added ones." + +msgid "Restart QBZ to see the migrated local profile." +msgstr "Restart QBZ to see the migrated local profile." + +msgid "The local profile could not be copied; see the log." +msgstr "The local profile could not be copied; see the log." +msgid "Delete…" +msgstr "Delete…" + +msgid "Delete migration snapshot?" +msgstr "Delete migration snapshot?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "This removes only the migration bundle. The old account's QBZ profile and user data are kept." + +msgid "Delete snapshot" +msgstr "Delete snapshot" + +msgid "Migration snapshot deleted." +msgstr "Migration snapshot deleted." + +msgid "Could not delete the migration snapshot." +msgstr "Could not delete the migration snapshot." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." + +msgid "Media server connections copied: {} of {}." +msgstr "Media server connections copied: {} of {}." + +msgid "Copying local profile…" +msgstr "Copying local profile…" + +msgid "Refreshing migrated data…" +msgstr "Refreshing migrated data…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "This only happens once. Please wait for it to finish and do not close the window." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "You can close this panel; the migration will continue in the background." + +msgid "From" +msgstr "From" + +msgid "To" +msgstr "To" + +msgid "Step {} of {}" +msgstr "Step {} of {}" + +msgid "Elapsed: {}" +msgstr "Elapsed: {}" + +msgid "Remaining: {}" +msgstr "Remaining: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." + +msgid "Migrate account data" +msgstr "Migrate account data" + +msgid "Start migration" +msgstr "Start migration" + +msgid "What is a migration profile?" +msgstr "What is a migration profile?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." + +msgid "SAVED SOURCE PROFILES" +msgstr "SAVED SOURCE PROFILES" + +msgid "No saved migration profiles are available." +msgstr "No saved migration profiles are available." + +msgid "Selected" +msgstr "Selected" + +msgid "Select" +msgstr "Select" + +msgid "MIGRATION DIRECTION" +msgstr "MIGRATION DIRECTION" + +msgid "Select a source profile above" +msgstr "Select a source profile above" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Signed-in Qobuz account · ID {}" + +msgid "WHAT TO COPY" +msgstr "WHAT TO COPY" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "Migration is additive: it does not remove favorites or playlists from either account." + +msgid "That library folder is disabled." +msgstr "That library folder is disabled." + +msgid "That folder overlaps an existing library folder." +msgstr "That folder overlaps an existing library folder." + +msgid "This purchase is not available for download." +msgstr "This purchase is not available for download." + +msgid "Could not save playback preference." +msgstr "Could not save playback preference." + +msgid "Qobuz · stream/offline copy" +msgstr "Qobuz · stream/offline copy" + +msgid "Local purchase" +msgstr "Local purchase" + +msgid "Play with" +msgstr "Play with" + +msgid "Download purchase" +msgstr "Download purchase" + +msgid "1 track purchased" +msgstr "1 track purchased" + +msgid "{} tracks purchased" +msgstr "{} tracks purchased" + +msgid "This copy cannot be continued right now." +msgstr "This copy cannot be continued right now." + +msgid "Library folder refreshed." +msgstr "Library folder refreshed." + +msgid "Download another copy" +msgstr "Download another copy" + +msgid "Complete" +msgstr "Complete" + +msgid "Partial" +msgstr "Partial" + +msgid "Missing files" +msgstr "Missing files" + +msgid "Changed files" +msgstr "Changed files" + +msgid "Unreadable files" +msgstr "Unreadable files" + +msgid "Local purchase copies" +msgstr "Local purchase copies" + +msgid "{} of {} tracks" +msgstr "{} of {} tracks" + +msgid "Continue copy" +msgstr "Continue copy" + +msgid "Add to Local Library" +msgstr "Add to Local Library" + +msgid "In Local Library" +msgstr "In Local Library" + +msgid "{} of {} tracks available in this folder." +msgstr "{} of {} tracks available in this folder." + +msgid "{} failed" +msgstr "{} failed" + +msgid "{} cancelled" +msgstr "{} cancelled" + +msgid "Download finished" +msgstr "Download finished" + +msgid "Not now" +msgstr "Not now" + +msgid "Show location" +msgstr "Show location" + +msgid "Remove from Local Library" +msgstr "Remove from Local Library" + +msgid "Forget download record" +msgstr "Forget download record" + +msgid "Could not open that folder." +msgstr "Could not open that folder." + +msgid "Could not remove that library folder." +msgstr "Could not remove that library folder." + +msgid "Could not forget that download." +msgstr "Could not forget that download." + +msgid "Shuffle: On" +msgstr "Shuffle: On" + +msgid "Shuffle: Off" +msgstr "Shuffle: Off" + +msgid "Repeat: Off" +msgstr "Repeat: Off" + +msgid "Repeat: All" +msgstr "Repeat: All" + +msgid "Repeat: One" +msgstr "Repeat: One" + +msgid "Show album artwork in Queue track list" +msgstr "Show album artwork in Queue track list" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Replace queue track numbers with album cover thumbnails. Off by default." + +msgid "Show album artwork in Library track list" +msgstr "Show album artwork in Library track list" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Display the album cover thumbnail between the track number and title. Off by default." + +msgid "Play random" +msgstr "Play random" + +msgid "Play selected" +msgstr "Play selected" + +msgid "Cancel selection" +msgstr "Cancel selection" + +msgid "QOBUZ LINKS" +msgstr "QOBUZ LINKS" + +msgid "Open Qobuz links in QBZ" +msgstr "Open Qobuz links in QBZ" + +msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here." +msgstr "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here." + +msgid "Look for better replacement" +msgstr "Look for better replacement" + +msgid "Remove all favorites from this release" +msgstr "Remove all favorites from this release" + +msgid "Remove all favorites from this release?" +msgstr "Remove all favorites from this release?" + +msgid "Remove the album and every track from this release from your Library?" +msgstr "Remove the album and every track from this release from your Library?" + +msgid "No replacement releases found" +msgstr "No replacement releases found" + +msgid "Choosing a result opens the release. Your existing favorites are not changed." +msgstr "Choosing a result opens the release. Your existing favorites are not changed." + +msgid "No favorites from this release to remove" +msgstr "No favorites from this release to remove" + +msgid "Favorites removed from this release" +msgstr "Favorites removed from this release" + +msgid "Could not remove favorites from this release" +msgstr "Could not remove favorites from this release" + +msgid "Some favorites could not be removed" +msgstr "Some favorites could not be removed" + +msgid "Replace in Library" +msgstr "Replace in Library" + +msgid "Replacing favorite..." +msgstr "Replacing favorite..." + +msgid "The selected release replaces the unavailable album favorite." +msgstr "The selected release replaces the unavailable album favorite." + +msgid "The matching track from the selected release replaces the unavailable favorite." +msgstr "The matching track from the selected release replaces the unavailable favorite." + +msgid "Could not open the selected release" +msgstr "Could not open the selected release" + +msgid "Could not find a matching track in this release" +msgstr "Could not find a matching track in this release" + +msgid "Could not add the replacement favorite" +msgstr "Could not add the replacement favorite" + +msgid "Favorite replaced" +msgstr "Favorite replaced" + +msgid "The replacement was added, but the unavailable favorite could not be removed" +msgstr "The replacement was added, but the unavailable favorite could not be removed" + +msgid "Failed to send playback to the selected device" +msgstr "Failed to send playback to the selected device" + +msgid "Move up" +msgstr "Move up" + +msgid "Move down" +msgstr "Move down" + +msgid "Download" +msgstr "Download" + +msgid "Edit" +msgstr "Edit" + +msgid "Grid" +msgstr "Grid" + +msgid "List" +msgstr "List" + +msgid "Tree" +msgstr "Tree" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po new file mode 100644 index 000000000..6c23f5acb --- /dev/null +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -0,0 +1,6851 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: qbz-slint\n" +"PO-Revision-Date: 2026-09-05 19:52-0600\n" +"Last-Translator: QBZ contributors\n" +"Language-Team: QBZ es translators\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "System Colors" +msgstr "Colores del Sistema" + +msgid "Wallpaper Sync" +msgstr "Sincronizar con el fondo" + +msgid "Custom Image" +msgstr "Imagen Personalizada" + +msgid "Auto" +msgstr "Automático" + +msgid "Disabled" +msgstr "Deshabilitada" + +msgid "Replace current queue" +msgstr "Reemplazar cola actual" + +msgid "Play next" +msgstr "Reproducir a continuación" + +msgid "Add to queue" +msgstr "Agregar a la cola" + +msgid "Remember last" +msgstr "Recordar la última" + +msgid "Album Reactive" +msgstr "Álbum Reactivo" + +msgid "Static" +msgstr "Estática" + +msgid "Coverflow" +msgstr "Coverflow" + +msgid "Spectrum" +msgstr "Espectro" + +msgid "Lyrics" +msgstr "Letras" + +msgid "Queue" +msgstr "Cola" + +msgid "Auto (opposite to window controls)" +msgstr "Automatico (opuesto a controles de ventana)" + +msgid "Left" +msgstr "Izquierda" + +msgid "Right" +msgstr "Derecha" + +msgid "Rectangular" +msgstr "Rectangular" + +msgid "Full-height rounded" +msgstr "Rectángulo a la altura" + +msgid "Circular" +msgstr "Circular" + +msgid "Square" +msgstr "Cuadrado" + +msgid "Small" +msgstr "Pequeño" + +msgid "Normal" +msgstr "Normal" + +msgid "Large" +msgstr "Grande" + +msgid "Default" +msgstr "Predeterminado" + +msgid "Monochrome" +msgstr "Monocromático" + +msgid "Custom" +msgstr "Personalizado" + +msgid "Remember last used" +msgstr "Recordar la última usada" + +msgid "Micro" +msgstr "Micro" + +msgid "Compact" +msgstr "Compacto" + +msgid "Artwork" +msgstr "Portada" + +msgid "Home" +msgstr "Inicio" + +msgid "Where you left off" +msgstr "Donde te quedaste" + +msgid "Mono light" +msgstr "Mono claro" + +msgid "Mono dark" +msgstr "Mono oscuro" + +msgid "Color" +msgstr "Color" + +msgid "Vinyl" +msgstr "Vinilo" + +msgid "Visualizer" +msgstr "Visualizador" + +msgid "Neon Flow" +msgstr "Flujo Neón" + +msgid "Tunnel" +msgstr "Túnel" + +msgid "Comet" +msgstr "Cometa" + +msgid "Split: Lyrics" +msgstr "Dividido: Letras" + +msgid "Split: Track Info" +msgstr "Dividido: Info de pista" + +msgid "Split: Suggestions" +msgstr "Dividido: Sugerencias" + +msgid "Split: Queue" +msgstr "Dividido: Cola" + +msgid "Full" +msgstr "Completo" + +msgid "Lite" +msgstr "Ligero" + +msgid "Off" +msgstr "Nada" + +msgid "versions" +msgstr "versiones" + +msgid "Search tracks..." +msgstr "Buscar pistas..." + +msgid "Title" +msgstr "Título" + +msgid "Duration" +msgstr "Duración" + +msgid "Quality" +msgstr "Calidad" + +msgid "Disc" +msgstr "Disco" + +msgid "Loading…" +msgstr "Cargando…" + +msgid "Failed to load booklet" +msgstr "No se pudo cargar el folleto" + +msgid "Add cover" +msgstr "Agregar portada" + +msgid "Change cover" +msgstr "Cambiar portada" + +msgid "Remove cover" +msgstr "Quitar portada" + +msgid "Open in browser" +msgstr "Abrir en navegador" + +msgid "Save as…" +msgstr "Guardar como…" + +msgid "Read more" +msgstr "Leer más" + +msgid "Loading album…" +msgstr "Cargando álbum…" + +msgid "Select all" +msgstr "Seleccionar todos" + +msgid "Add to playlist" +msgstr "Agregar a la lista" + +msgid "Add to Library" +msgstr "Agregar a la biblioteca" + +msgid "Make available offline" +msgstr "Disponible offline" + +msgid "Clear" +msgstr "Limpiar" + +msgid "LABEL" +msgstr "SELLO" + +msgid "AWARDS" +msgstr "PREMIOS" + +msgid "EXTERNAL LINKS" +msgstr "ENLACES EXTERNOS" + +msgid "About this album" +msgstr "Acerca de este álbum" + +msgid "Released by" +msgstr "Publicado en" + +msgid "on" +msgstr "el" + +msgid "Credits" +msgstr "Créditos" + +msgid "Review" +msgstr "Reseña" + +msgid "Failed to load album credits" +msgstr "No se pudieron cargar los créditos del álbum" + +msgid "Loading credits..." +msgstr "Cargando créditos..." + +msgid "Loading track info..." +msgstr "Cargando información de la pista..." + +msgid "Failed to load track info" +msgstr "No se pudo cargar la información de la pista" + +msgid "Label" +msgstr "Sello" + +msgid "Newest" +msgstr "Mas recientes" + +msgid "Oldest" +msgstr "Mas antiguos" + +msgid "A–Z" +msgstr "A–Z" + +msgid "Z–A" +msgstr "Z–A" + +msgid "Failed to load Library" +msgstr "Error al cargar la Biblioteca" + +msgid "Retry" +msgstr "Reintentar" + +msgid "No releases found." +msgstr "No se encontraron lanzamientos." + +msgid "See discography" +msgstr "Ver discografía" + +msgid "Load more" +msgstr "Cargar más" + +msgid "Add image" +msgstr "Agregar imagen" + +msgid "Change image" +msgstr "Cambiar imagen" + +msgid "Remove image" +msgstr "Quitar imagen" + +msgid "Create Artist Collection" +msgstr "Crear colección de artistas" + +msgid "Artist Scene" +msgstr "Escena del artista" + +msgid "Share" +msgstr "Compartir" + +msgid "Show artist" +msgstr "Mostrar artista" + +msgid "Blacklist artist" +msgstr "Bloquear artista" + +msgid "This artist is hidden from discovery" +msgstr "Este artista está oculto del descubrimiento" + +msgid "Loading artist…" +msgstr "Cargando artista…" + +msgid "Popular Tracks" +msgstr "Pistas Populares" + +msgid "Play all next" +msgstr "Reproducir todo a continuación" + +msgid "Play all later" +msgstr "Reproducir todo después" + +msgid "Add all to queue" +msgstr "Añadir todo a la cola" + +msgid "Shuffle all" +msgstr "Reproducir todo al azar" + +msgid "Add all to playlist" +msgstr "Añadir todo a la lista de reproducción" + +msgid "View less" +msgstr "Ver menos" + +msgid "Latest release" +msgstr "Último lanzamiento" + +msgid "Appears On" +msgstr "Aparece En" + +msgid "Hide" +msgstr "Ocultar" + +msgid "Show" +msgstr "Mostrar" + +msgid "Network" +msgstr "Red" + +msgid "Magazine" +msgstr "Revista" + +msgid "Loading origin…" +msgstr "Cargando origen…" + +msgid "BORN" +msgstr "NACIMIENTO" + +msgid "FOUNDED" +msgstr "FUNDACIÓN" + +msgid "BORN IN" +msgstr "NACIDO EN" + +msgid "FOUNDED IN" +msgstr "FUNDADO EN" + +msgid "DIED" +msgstr "FALLECIMIENTO" + +msgid "DISBANDED" +msgstr "DISOLUCIÓN" + +msgid "LABELS" +msgstr "SELLOS" + +msgid "No label info" +msgstr "Sin información del sello" + +msgid "SIMILAR ARTISTS" +msgstr "ARTISTAS SIMILARES" + +msgid "RELATIONSHIPS" +msgstr "RELACIONES" + +msgid "Members & Former" +msgstr "Miembros y exmiembros" + +msgid "Member Of" +msgstr "Miembro de" + +msgid "Collaborators" +msgstr "Colaboradores" + +msgid "YOU MAY ALSO LIKE" +msgstr "TAMBIÉN TE PUEDE GUSTAR" + +msgid "No stories for this artist" +msgstr "No hay historias para este artista" + +msgid "About this artist" +msgstr "Acerca de este artista" + +msgid "Source" +msgstr "Fuente" + +msgid "Following" +msgstr "Siguiendo" + +msgid "Follow" +msgstr "Seguir" + +msgid "View all" +msgstr "Ver todo" + +msgid "View all →" +msgstr "Ver todo →" + +msgid "Filter by genre" +msgstr "Filtrar por género" + +msgid "Remember selection" +msgstr "Recordar selección" + +msgid "Advanced view" +msgstr "Vista avanzada" + +msgid "Search genres…" +msgstr "Buscar géneros…" + +msgid "Clear filter" +msgstr "Limpiar filtro" + +msgid "Qobuz Mixes" +msgstr "Mixes de Qobuz" + +msgid "Elevate your day with a customized selection of music." +msgstr "Eleva tu dia con una seleccion personalizada de musica." + +msgid "Take a weekly journey with a fresh mix every Friday." +msgstr "Disfruta un viaje semanal con una mezcla nueva cada viernes." + +msgid "A fresh shuffle from your personal library." +msgstr "Una mezcla fresca de tu biblioteca personal." + +msgid "Discover new music from your most-played playlists." +msgstr "Descubre música nueva a partir de tus listas más reproducidas." + +msgid "Open album" +msgstr "Abrir álbum" + +msgid "Open containing album" +msgstr "Abrir el álbum contenedor" + +msgid "Play" +msgstr "Reproducir" + +msgid "Search…" +msgstr "Buscar…" + +msgid "1 genre" +msgstr "1 género" + +msgid "{} genres" +msgstr "{} géneros" + +msgid "No results found." +msgstr "No se encontraron resultados." + +msgid "Editor's Picks" +msgstr "Selección editorial" + +msgid "For You" +msgstr "Para ti" + +msgid "Qobuz Playlists" +msgstr "Playlists de Qobuz" + +msgid "No playlists match the selected categories." +msgstr "Ninguna lista coincide con las categorías seleccionadas." + +msgid "Recently Played Albums" +msgstr "Álbumes reproducidos recientemente" + +msgid "Albums you play will appear here." +msgstr "Los álbumes que reproduzcas aparecerán aquí." + +msgid "Recently Played Tracks" +msgstr "Pistas reproducidas recientemente" + +msgid "Tracks you play will appear here." +msgstr "Las pistas que reproduzcas aparecerán aquí." + +msgid "Popular albums" +msgstr "Álbumes populares" + +msgid "{} selected" +msgstr "{} seleccionados" + +msgid "Filter by category" +msgstr "Filtrar por categoría" + +msgid "All categories" +msgstr "Todas las categorías" + +msgid "Customize" +msgstr "Personalizar" + +msgid "Enabling more sections increases load time." +msgstr "Activar más secciones aumenta el tiempo de carga." + +msgid "{} of {} enabled" +msgstr "{} de {} activados" + +msgid "Reset to defaults" +msgstr "Restaurar valores por defecto" + +msgid "Radio Stations" +msgstr "Estaciones de Radio" + +msgid "RADIO" +msgstr "RADIO" + +msgid "Your Top Artists" +msgstr "Tus Artistas Favoritos" + +msgid "Artists to Follow" +msgstr "Artistas para Seguir" + +msgid "Pinned" +msgstr "Fijados" + +msgid "Pin" +msgstr "Fijar" + +msgid "Unpin" +msgstr "Dejar de fijar" + +msgid "Showing {} of {} track." +msgid_plural "Showing {} of {} tracks." +msgstr[0] "Mostrando {} de {} pista." +msgstr[1] "Mostrando {} de {} pistas." + +msgid "Spotlight" +msgstr "Spotlight" + +msgid "Shine a light on one of your favourite artists." +msgstr "Ilumina a uno de tus artistas favoritos." + +msgid "ARTIST" +msgstr "ARTISTA" + +msgid "Top tracks" +msgstr "Canciones populares" + +msgid "By {}" +msgstr "Por {}" + +msgid "TOP TRACKS" +msgstr "TOP TRACKS" + +msgid "Qobuz Radio Station" +msgstr "Estación de Radio Qobuz" + +msgid "Available offline" +msgstr "Disponible sin conexión" + +msgid "Tracks" +msgstr "Pistas" + +msgid "Albums" +msgstr "Álbumes" + +msgid "Artists" +msgstr "Artistas" + +msgid "Playlists" +msgstr "Playlists" + +msgid "Labels" +msgstr "Sellos" + +msgid "Favorite" +msgstr "Favorito" + +msgid "Library" +msgstr "Biblioteca" + +msgid "Search" +msgstr "Buscar" + +msgid "Group: Off" +msgstr "Grupo: Desactivado" + +msgid "Group: Album" +msgstr "Grupo: Album" + +msgid "Group: Artist" +msgstr "Grupo: Artista" + +msgid "Group: Name" +msgstr "Grupo: Nombre" + +msgid "Group: A-Z" +msgstr "Grupo: A-Z" + +msgid "Title A-Z" +msgstr "Titulo A-Z" + +msgid "Title Z-A" +msgstr "Titulo Z-A" + +msgid "Artist A-Z" +msgstr "Artista A-Z" + +msgid "Couldn't load your Library." +msgstr "No se pudo cargar tu biblioteca." + +msgid "Remove from Library" +msgstr "Quitar de la biblioteca" + +msgid "No tracks in your Library yet." +msgstr "Aún no hay pistas en tu biblioteca." + +msgid "No tracks match your search." +msgstr "Ninguna pista coincide con la búsqueda." + +msgid "No albums in your Library yet." +msgstr "Aún no hay álbumes en tu biblioteca." + +msgid "No albums match your search." +msgstr "Ningún álbum coincide con tu búsqueda." + +msgid "No artists in your Library yet." +msgstr "Aún no hay artistas en tu biblioteca." + +msgid "No artists match your search." +msgstr "Ningún artista coincide con tu búsqueda." + +msgid "No playlists match your search." +msgstr "Ninguna lista de reproducción coincide con tu búsqueda." + +msgid "No followed playlists yet." +msgstr "Aún no sigues ninguna lista de reproducción." + +msgid "No playlists in your Library yet." +msgstr "Aún no hay playlists en tu biblioteca." + +msgid "No labels in your Library yet." +msgstr "Aún no hay sellos en tu biblioteca." + +msgid "No labels match your search." +msgstr "Ningún sello coincide con tu búsqueda." + +msgid "Select an artist to see their albums." +msgstr "Selecciona un artista para ver sus álbumes." + +msgid "Couldn't load albums." +msgstr "No se pudieron cargar los álbumes." + +msgid "No albums found." +msgstr "No se encontraron álbumes." + +msgid "Go to artist page" +msgstr "Ir a la página del artista" + +msgid "albums" +msgstr "álbumes" + +msgid "Releases" +msgstr "Lanzamientos" + +msgid "Search this label…" +msgstr "Buscar en este sello…" + +msgid "No results" +msgstr "Sin resultados" + +msgid "More labels" +msgstr "Más sellos" + +msgid "About" +msgstr "Acerca de" + +msgid "Folders" +msgstr "Carpetas" + +msgid "tracks" +msgstr "pistas" + +msgid "Play all" +msgstr "Reproducir todo" + +msgid "Clear session" +msgstr "Limpiar sesión" + +msgid "No playable tracks in this folder." +msgstr "No hay pistas reproducibles en esta carpeta." + +msgid "Play this album" +msgstr "Reproducir este álbum" + +msgid "Local Library" +msgstr "Biblioteca Local" + +msgid "Artist Z-A" +msgstr "Artista Z-A" + +msgid "Year (newest)" +msgstr "Año (más reciente)" + +msgid "Year (oldest)" +msgstr "Año (más antiguo)" + +msgid "No grouping" +msgstr "Sin agrupación" + +msgid "Group by letter" +msgstr "Agrupar por letra" + +msgid "Group by artist" +msgstr "Agrupar por artista" + +msgid "By album" +msgstr "Por álbum" + +msgid "By artist" +msgstr "Por artista" + +msgid "By name" +msgstr "Por nombre" + +msgid "Search artists" +msgstr "Buscar artistas" + +msgid "Couldn't load your local albums." +msgstr "No se pudieron cargar tus álbumes locales." + +msgid "No albums in your local library yet." +msgstr "Aún no hay álbumes en tu biblioteca local." + +msgid "Couldn't load your local tracks." +msgstr "No se pudieron cargar tus pistas locales." + +msgid "No tracks in your local library yet." +msgstr "Aún no hay pistas en tu biblioteca local." + +msgid "Add to Mixtape/Collection" +msgstr "Agregar a Mixtape/Collection" + +msgid "No folders in your local library yet." +msgstr "Aún no hay carpetas en tu biblioteca local." + +msgid "No folders match your search." +msgstr "Ninguna carpeta coincide con tu búsqueda." + +msgid "Search folders" +msgstr "Buscar carpetas" + +msgid "sel" +msgstr "sel" + +msgid "Check all" +msgstr "Seleccionar todo" + +msgid "Select a folder to see its contents." +msgstr "Selecciona una carpeta para ver su contenido." + +msgid "Search albums..." +msgstr "Buscar álbumes..." + +msgid "This folder has no tracks." +msgstr "Esta carpeta no tiene pistas." + +msgid "No artists in your local library yet." +msgstr "Aún no hay artistas en tu biblioteca local." + +msgid "artists" +msgstr "artistas" + +msgid "Select an artist to view their albums" +msgstr "Selecciona un artista para ver sus álbumes" + +msgid "No albums found" +msgstr "No se encontraron álbumes" + +msgid "Local Library port in progress" +msgstr "Migración de la biblioteca local en curso" + +msgid "This tab is coming next." +msgstr "Esta pestaña llegará pronto." + +msgid "Filter" +msgstr "Filtrar" + +msgid "Hi-Res" +msgstr "Hi-Res" + +msgid "CD" +msgstr "CD" + +msgid "Lossy" +msgstr "Con pérdida" + +msgid "Format" +msgstr "Formato" + +msgid "Other" +msgstr "Otra" + +msgid "Local" +msgstr "Local" + +msgid "Offline cache" +msgstr "Caché offline" + +msgid "Scene" +msgstr "Escena" + +msgid "No artists found for this scene." +msgstr "No se encontraron artistas para esta escena." + +msgid "QBZ" +msgstr "QBZ" + +msgid "QOBUZ™ PLAYER" +msgstr "REPRODUCTOR QOBUZ™" + +msgid "I have read the" +msgstr "He leído los" + +msgid "Qobuz™ Terms of Service" +msgstr "Términos de servicio de Qobuz™" + +msgid "Sign in via Browser" +msgstr "Iniciar sesión en el navegador" + +msgid "Use your system browser instead" +msgstr "Usar tu navegador del sistema" + +msgid "No internet connection — you can start in offline mode with your local library and downloads" +msgstr "Sin conexión a internet — puedes iniciar en modo sin conexión con tu biblioteca local y tus descargas" + +msgid "A network sign-in page may be blocking the connection" +msgstr "Una página de inicio de sesión de la red puede estar bloqueando la conexión" + +msgid "Could not restore your session" +msgstr "No se pudo restaurar tu sesión" + +msgid "Start offline (no access to Qobuz™ services)" +msgstr "Iniciar en modo offline (sin acceso a los servicios de Qobuz™)" + +msgid "QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™." +msgstr "QBZ requiere una suscripción activa a Qobuz™. Tus datos de acceso se envían directamente a Qobuz™." + +msgid "This application uses the Qobuz API but is not certified by Qobuz." +msgstr "Esta aplicación utiliza la API de Qobuz, pero no está certificada por Qobuz." + +msgid "Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz." +msgstr "Qobuz™ es una marca comercial de Qobuz. QBZ es una aplicación de código abierto bajo la licencia MIT y no está afiliada, respaldada ni certificada por Qobuz." + +msgid "No tracks for this mix." +msgstr "No hay pistas para esta mezcla." + +msgid "Album" +msgstr "Álbum" + +msgid "Bands & Projects" +msgstr "Bandas y proyectos" + +msgid "No album appearances found." +msgstr "No se encontraron apariciones en álbumes." + +msgid "You're offline" +msgstr "Estás sin conexión" + +msgid "Offline mode is enabled. Disable it in Settings to use Qobuz." +msgstr "El modo sin conexión está activado. Desactívalo en Ajustes para usar Qobuz." + +msgid "No internet connection. Your local library and downloads keep working." +msgstr "Sin conexión a internet. Tu biblioteca local y tus descargas siguen funcionando." + +msgid "Open Settings" +msgstr "Abrir Ajustes" + +msgid "Offline Cache" +msgstr "Caché sin conexión" + +msgid "{} track" +msgid_plural "{} tracks" +msgstr[0] "{} pista" +msgstr[1] "{} pistas" + +msgid "Sort: A-Z" +msgstr "Ordenar: A-Z" + +msgid "Sort: Recent" +msgstr "Ordenar: Reciente" + +msgid "Sort: Largest" +msgstr "Ordenar: Mayor tamaño" + +msgid "Sort: Smallest" +msgstr "Ordenar: Menor tamaño" + +msgid "Failed only" +msgstr "Solo fallidos" + +msgid " selected" +msgstr " seleccionados" + +msgid "Re-download" +msgstr "Volver a descargar" + +msgid "Remove" +msgstr "Eliminar" + +msgid "All artists" +msgstr "Todos los artistas" + +msgid "No offline tracks yet" +msgstr "Aún no hay pistas sin conexión" + +msgid "Search playlists…" +msgstr "Buscar listas de reproducción…" + +msgid "1 playlist" +msgstr "1 lista de reproducción" + +msgid "{} playlists" +msgstr "{} listas de reproducción" + +msgid "Move to folder" +msgstr "Mover a carpeta" + +msgid "No folder" +msgstr "Sin carpeta" + +msgid "Playlist manager" +msgstr "Administrador de listas" + +msgid "Visible" +msgstr "Visible" + +msgid "Hidden" +msgstr "Ocultado" + +msgid "All" +msgstr "Todos" + +msgid "Recent" +msgstr "Reciente" + +msgid "Play Count" +msgstr "Reproducciones" + +msgid "Track Count" +msgstr "Número de pistas" + +msgid "Name A-Z" +msgstr "Nombre A-Z" + +msgid "New folder" +msgstr "Nueva carpeta" + +msgid "{} folders, {} playlists" +msgstr "{} carpetas, {} listas de reproducción" + +msgid "Use the arrows to reorder playlists" +msgstr "Usa las flechas para reordenar las listas de reproducción" + +msgid "Folders ({})" +msgstr "Carpetas ({})" + +msgid "Playlists ({})" +msgstr "Listas de reproducción ({})" + +msgid "No playlists found." +msgstr "No se encontraron listas de reproducción." + +msgid "Artist" +msgstr "Artista" + +msgid "Playlist" +msgstr "Playlist" + +msgid "Search tracks" +msgstr "Buscar canciones" + +msgid "Sort" +msgstr "Ordenar" + +msgid "This playlist is empty." +msgstr "Esta lista de reproducción está vacía." + +msgid "Upload to Qobuz?" +msgstr "¿Subir a Qobuz?" + +msgid "This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy." +msgstr "Esto crea la lista de reproducción en Qobuz con sus canciones de Qobuz, mantiene adjuntos los archivos locales y elimina la copia solo local." + +msgid "Cancel" +msgstr "Cancelar" + +msgid "Upload" +msgstr "Subir" + +msgid "About this playlist" +msgstr "Acerca de esta lista de reproducción" + +msgid "ITEM" +msgstr "ELEMENTO" + +msgid "TYPE" +msgstr "TIPO" + +msgid "SOURCE" +msgstr "ORIGEN" + +msgid "QUALITY" +msgstr "CALIDAD" + +msgid "TRACKS" +msgstr "CANCIONES" + +msgid "YEAR" +msgstr "AÑO" + +msgid "Folder name" +msgstr "Nombre de la carpeta" + +msgid "Creating..." +msgstr "Creando..." + +msgid "Create" +msgstr "Crear" + +msgid "Edit folder" +msgstr "Editar carpeta" + +msgid "Enter folder name" +msgstr "Ingresa el nombre de la carpeta" + +msgid "Icon" +msgstr "Icono" + +msgid "Hide from sidebar" +msgstr "Ocultar de la barra lateral" + +msgid "Delete" +msgstr "Eliminar" + +msgid "Save" +msgstr "Guardar" + +msgid "JUMP TO" +msgstr "IR A" + +msgid "Search this page…" +msgstr "Buscar en esta página…" + +msgid "Something is already playing" +msgstr "Ya se está reproduciendo algo" + +msgid "Add this to the current queue, or clear the queue and start playing it?" +msgstr "¿Agregar esto a la cola actual o vaciar la cola y empezar a reproducirlo?" + +msgid "Clear queue and play" +msgstr "Vaciar la cola y reproducir" + +msgid "Edit metadata" +msgstr "Editar metadatos" + +msgid "Album title" +msgstr "Título del álbum" + +msgid "Album artist" +msgstr "Artista del álbum" + +msgid "Year" +msgstr "Año" + +msgid "Genre" +msgstr "Género" + +msgid "Catalog number" +msgstr "Número de catálogo" + +msgid "Total discs" +msgstr "Total de discos" + +msgid "Searching…" +msgstr "Buscando…" + +msgid "No results, try a different search" +msgstr "Sin resultados, prueba con otra búsqueda" + +msgid "Apply" +msgstr "Aplicar" + +msgid "Album folder" +msgstr "Carpeta del álbum" + +msgid "Sidecar file (recommended)" +msgstr "Archivo sidecar (recomendado)" + +msgid "Write to audio files" +msgstr "Escribir en los archivos de audio" + +msgid "This writes tags directly into your audio files." +msgstr "Esto escribe las etiquetas directamente en tus archivos de audio." + +msgid "Direct write is unavailable for CUE-based albums." +msgstr "La escritura directa no está disponible para álbumes basados en CUE." + +msgid "Writing" +msgstr "Escribiendo" + +msgid "Saving…" +msgstr "Guardando…" + +msgid "Edit playlist" +msgstr "Editar lista de reproducción" + +msgid "Playlist name" +msgstr "Nombre de la playlist" + +msgid "Description" +msgstr "Descripción" + +msgid "Offline-only playlist" +msgstr "Lista solo sin conexión" + +msgid "New playlist" +msgstr "Nueva lista de reproducción" + +msgid "Name" +msgstr "Nombre" + +msgid "My Playlist" +msgstr "Mi Lista" + +msgid "Description (optional)" +msgstr "Descripción (opcional)" + +msgid "Folder" +msgstr "Carpeta" + +msgid "Make playlist public" +msgstr "Hacer lista pública" + +msgid "Created locally — you're offline" +msgstr "Creada localmente: estás sin conexión" + +msgid "Add to mixtape" +msgstr "Añadir a mixtape" + +msgid "Share Qobuz link" +msgstr "Compartir enlace de Qobuz" + +msgid "Share Album.link" +msgstr "Compartir Album.link" + +msgid "Refresh offline copy" +msgstr "Actualizar la copia offline" + +msgid "Some of these tracks were already added" +msgstr "Algunas de estas pistas ya fueron agregadas" + +msgid "This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates." +msgstr "Esta lista ya contiene {} de las {} pistas que estás añadiendo. Añade solo las nuevas o añade todo, incluidos los duplicados." + +msgid "Adding..." +msgstr "Agregando..." + +msgid "Add all tracks" +msgstr "Agregar todas" + +msgid "Add only new tracks" +msgstr "Agregar solo nuevas" + +msgid "Add to Playlist" +msgstr "Agregar a Lista" + +msgid "Choose playlist" +msgstr "Elegir lista" + +msgid "Adding {} local track(s)" +msgstr "Añadiendo {} pista(s) local(es)" + +msgid "Adding {} track(s)" +msgstr "Añadiendo {} pista(s)" + +msgid "+ Create new playlist" +msgstr "+ Crear nueva lista de reproducción" + +msgid "Search playlists..." +msgstr "Buscar playlists..." + +msgid "Playlist Name" +msgstr "Nombre de la Lista" + +msgid "Saving..." +msgstr "Guardando..." + +msgid "Create & Add" +msgstr "Crear y Agregar" + +msgid "Add" +msgstr "Agregar" + +msgid "No playlists found" +msgstr "No se encontraron playlists" + +msgid "{} more playlist(s)..." +msgstr "{} lista(s) más..." + +msgid "Import Playlist" +msgstr "Importar Playlist" + +msgid "This feature requires internet connection" +msgstr "Esta funcion requiere conexion a internet" + +msgid "Playlist URL" +msgstr "URL de la playlist" + +msgid "ALLOWED SOURCES" +msgstr "FUENTES PERMITIDAS" + +msgid "Conversion progress" +msgstr "Progreso de conversión" + +msgid "Summary" +msgstr "Resumen" + +msgid "Close" +msgstr "Cerrar" + +msgid "Importing..." +msgstr "Importando..." + +msgid "Import" +msgstr "Importar" + +msgid "Fetching..." +msgstr "Obteniendo..." + +msgid "Fetch playlist" +msgstr "Obtener playlist" + +msgid "Shuffle" +msgstr "Aleatorio" + +msgid "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" +msgstr "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" + +msgid "24-bit / 44.1 kHz — Billie Eilish · LUNCH" +msgstr "24-bit / 44.1 kHz — Billie Eilish · LUNCH" + +msgid "24-bit / 96 kHz — Iron Maiden · Stratego" +msgstr "24-bit / 96 kHz — Iron Maiden · Stratego" + +msgid "24-bit / 192 kHz — Toto · Africa" +msgstr "24-bit / 192 kHz — Toto · Africa" + +msgid "Welcome" +msgstr "Bienvenida" + +msgid "Check" +msgstr "Verificar" + +msgid "DACs" +msgstr "DACs" + +msgid "Configure" +msgstr "Configurar" + +msgid "Test" +msgstr "Probar" + +msgid "Done" +msgstr "Completado" + +msgid "HiFi Wizard — DAC Setup" +msgstr "Asistente HiFi — Configuración del DAC" + +msgid "Guided DAC Setup" +msgstr "Configuración Guiada de DAC" + +msgid "This wizard helps you configure your DAC for bit-perfect playback on Linux. QBZ never changes your system automatically — it auto-detects your DACs, shows you the exact commands and config to copy, and lets you test playback right here." +msgstr "Este asistente te ayuda a configurar tu DAC para reproducción bit-perfect en Linux. QBZ nunca cambia tu sistema automáticamente: detecta tus DACs, te muestra los comandos y la configuración exactos para copiar, y te permite probar la reproducción aquí mismo." + +msgid "You are responsible for your own backups. This is a helper, not a system recovery tool." +msgstr "Eres responsable de tus propios respaldos. Esto es una ayuda, no una herramienta de recuperación del sistema." + +msgid "What to test with" +msgstr "Con qué probar" + +msgid "The built-in test plays four tracks spanning different bit-depths and sample rates (16/44.1, 24/44.1, 24/96, 24/192) so you can see your DAC switch. If you'd rather use your own queue, add one track of each kind first so every rate is exercised." +msgstr "La prueba integrada reproduce cuatro pistas con distintas profundidades de bits y frecuencias de muestreo (16/44.1, 24/44.1, 24/96, 24/192) para que veas a tu DAC cambiar. Si prefieres usar tu propia cola, añade primero una pista de cada tipo para que se ejerciten todas las frecuencias." + +msgid "I understand and accept these terms" +msgstr "Entiendo y acepto estos términos" + +msgid "Check Audio Stack" +msgstr "Verificar Stack de Audio" + +msgid "Sandboxed — host check unavailable" +msgstr "En entorno aislado: verificación del host no disponible" + +msgid "QBZ is packaged as Flatpak/Snap and can't inspect your host audio stack. If playback already works, you're set. Pick your distribution and init system below for the right setup commands." +msgstr "QBZ está empaquetado como Flatpak/Snap y no puede inspeccionar la pila de audio del host. Si la reproducción ya funciona, todo está listo. Elige tu distribución y sistema de inicio abajo para obtener los comandos de configuración correctos." + +msgid "Audio stack ready" +msgstr "Pila de audio lista" + +msgid "Some packages are missing" +msgstr "Faltan algunos paquetes" + +msgid "Your distribution" +msgstr "Tu distribución" + +msgid "Init system (service commands)" +msgstr "Sistema de inicio (comandos de servicio)" + +msgid "Which DACs do you want to configure?" +msgstr "¿Qué DACs quieres configurar?" + +msgid "Detecting your DACs…" +msgstr "Detectando tus DAC…" + +msgid "default" +msgstr "predeterminado" + +msgid "Hide manual entry" +msgstr "Ocultar entrada manual" + +msgid "Can't see your DAC?" +msgstr "¿No ves tu DAC?" + +msgid "Find your DAC's PipeWire node name and paste it here:" +msgstr "Encuentra el nombre del nodo PipeWire de tu DAC y pégalo aquí:" + +msgid "1. Find your DAC under 'Audio > Sinks' and note its number" +msgstr "1. Encuentra tu DAC en 'Audio > Sinks' y anota su número" + +msgid "2. Run with that number to read the node.name" +msgstr "2. Ejecuta con ese número para leer el node.name" + +msgid "Valid node name" +msgstr "Nombre de nodo válido" + +msgid "Invalid — should contain alsa_output or alsa_input" +msgstr "No válido — debe contener alsa_output o alsa_input" + +msgid "Review & Apply" +msgstr "Revisar y aplicar" + +msgid "QBZ never writes these files" +msgstr "QBZ nunca escribe estos archivos" + +msgid "QBZ already pins rate, sink and exclusive mode at runtime. These files only change system-wide behavior — copy and run them yourself if you want them. Back up first." +msgstr "QBZ ya fija la frecuencia, el sink y el modo exclusivo en tiempo de ejecución. Estos archivos solo cambian el comportamiento de todo el sistema — cópialos y ejecútalos tú mismo si los quieres. Haz una copia de seguridad primero." + +msgid "1. Back up your current PipeWire/WirePlumber config" +msgstr "1. Haz una copia de seguridad de tu configuración actual de PipeWire/WirePlumber" + +msgid "I created a backup" +msgstr "Creé una copia de seguridad" + +msgid "Sample-rate switching (PipeWire)" +msgstr "Cambio de frecuencia de muestreo (PipeWire)" + +msgid "Per-app bit-perfect (PipeWire client)" +msgstr "Bit-perfect por aplicación (cliente PipeWire)" + +msgid "Pin the DAC + rates (WirePlumber)" +msgstr "Fijar el DAC + frecuencias (WirePlumber)" + +msgid "I created the configuration files" +msgstr "Creé los archivos de configuración" + +msgid "3. Restart the audio services to load the config" +msgstr "3. Reinicia los servicios de audio para cargar la configuración" + +msgid "I restarted the audio services" +msgstr "Reinicié los servicios de audio" + +msgid "Test Playback" +msgstr "Probar reproducción" + +msgid "Plays four tracks at different bit-depths and sample rates through the selected DAC. Watch your DAC's display switch rates — and confirm it below." +msgstr "Reproduce cuatro pistas con diferentes profundidades de bits y frecuencias de muestreo a través del DAC seleccionado. Observa cómo cambia de frecuencia la pantalla de tu DAC — y confírmalo abajo." + +msgid "Stop" +msgstr "Detener" + +msgid "Play test" +msgstr "Reproducir prueba" + +msgid "Use my current queue" +msgstr "Usar mi cola actual" + +msgid "All set" +msgstr "Todo listo" + +msgid "Your DAC is ready for bit-perfect playback. If you applied the config files, restart the audio services (or log out and back in) for them to take effect." +msgstr "Tu DAC está listo para la reproducción bit-perfect. Si aplicaste los archivos de configuración, reinicia los servicios de audio (o cierra sesión y vuelve a entrar) para que surtan efecto." + +msgid "Config files you can create:" +msgstr "Archivos de configuración que puedes crear:" + +msgid "Back" +msgstr "Atrás" + +msgid "Start" +msgstr "Comenzar" + +msgid "Finish" +msgstr "Finalizar" + +msgid "Next" +msgstr "Siguiente" + +msgid "Play now" +msgstr "Reproducir ahora" + +msgid "Start playing now?" +msgstr "¿Iniciar la reproducción ahora?" + +msgid "Not now" +msgstr "Ahora no" + +msgid "Show location" +msgstr "Mostrar ubicación" + +msgid "Remove from Local Library" +msgstr "Quitar de la biblioteca local" + +msgid "Forget download record" +msgstr "Olvidar registro de descarga" + +msgid "Could not open that folder." +msgstr "No se pudo abrir esa carpeta." + +msgid "Could not remove that library folder." +msgstr "No se pudo quitar esa carpeta de la biblioteca." + +msgid "Could not forget that download." +msgstr "No se pudo olvidar esa descarga." + +msgid "Create radio" +msgstr "Crear radio" + +msgid "Add to library" +msgstr "Añadir a la biblioteca" + +msgid "Remove from playlist" +msgstr "Quitar de la lista de reproducción" + +msgid "Share Song.link" +msgstr "Compartir Song.link" + +msgid "Remove offline copy" +msgstr "Eliminar copia sin conexión" + +msgid "Go to album" +msgstr "Ir al álbum" + +msgid "Go to artist" +msgstr "Ir al artista" + +msgid "Track info" +msgstr "Información de la pista" + +msgid "selected" +msgstr "seleccionado" + +msgid "Artist is blacklisted" +msgstr "El artista está en la lista negra" + +msgid "Main Artist" +msgstr "Artista principal" + +msgid "Performer" +msgstr "Ejecutante" + +msgid "Composer" +msgstr "Compositor" + +msgid "Release Name" +msgstr "Nombre Publicación" + +msgid "Most popular" +msgstr "Lo más escuchado" + +msgid "No results." +msgstr "Sin resultados." + +msgid "No results for" +msgstr "Sin resultados para" + +msgid "Top result" +msgstr "Mejor resultado" + +msgid "View more" +msgstr "Ver más" + +msgid "QOBUZ CONNECT" +msgstr "QOBUZ CONNECT" + +msgid "Connect diagnostics" +msgstr "Diagnóstico de Connect" + +msgid "Live session topology and a rolling event log for debugging Qobuz Connect at runtime." +msgstr "Topología de sesión en vivo y un registro continuo de eventos para depurar Qobuz Connect en tiempo de ejecución." + +msgid "Open diagnostics" +msgstr "Abrir diagnóstico" + +msgid "PLAYBACK" +msgstr "REPRODUCCIÓN" + +msgid "Continue playback after track ends" +msgstr "Continuar reproducción al finalizar pista" + +msgid "Keep playing the rest of the album or playlist instead of stopping." +msgstr "Sigue reproduciendo el resto del álbum o de la lista de reproducción en lugar de detenerse." + +msgid "Show track playing context" +msgstr "Mostrar el contexto de reproducción de la pista" + +msgid "Display the context-stack icon in the player." +msgstr "Muestra el ícono de la pila de contexto en el reproductor." + +msgid "Gapless playback" +msgstr "Reproducción sin pausas" + +msgid "Play consecutive same-format tracks without a gap." +msgstr "Reproduce pistas consecutivas del mismo formato sin pausa." + +msgid "SESSION" +msgstr "SESIÓN" + +msgid "Restore session on startup" +msgstr "Restaurar sesión al iniciar" + +msgid "Restore the queue and current track on the next launch." +msgstr "Restaura la cola y la pista actual en el próximo inicio." + +msgid "Resume playback position" +msgstr "Continuar desde la posición guardada" + +msgid "Also seek back to where the saved track left off." +msgstr "También vuelve al punto donde se quedó la pista guardada." + +msgid "STREAMING" +msgstr "STREAMING" + +msgid "Stream uncached tracks" +msgstr "Transmitir pistas sin caché" + +msgid "Start uncached tracks via streaming instead of waiting for the full download." +msgstr "Inicia las pistas sin caché por streaming en lugar de esperar la descarga completa." + +msgid "Initial buffer size" +msgstr "Tamaño de búfer inicial" + +msgid "Seconds of audio buffered before streaming playback starts." +msgstr "Segundos de audio almacenados en búfer antes de que comience la reproducción por streaming." + +msgid "s" +msgstr "s" + +msgid "Streaming only" +msgstr "Solo streaming" + +msgid "Skip writing tracks to the local cache while streaming." +msgstr "Omite escribir pistas en el caché local durante el streaming." + +msgid "When quality retries fail" +msgstr "Cuando fallan los reintentos de calidad" + +msgid "What to do when every quality tier for a track is unavailable." +msgstr "Qué hacer cuando ningún nivel de calidad de una pista está disponible." + +msgid "Folder settings" +msgstr "Configuración de la carpeta" + +msgid "Folder settings saved" +msgstr "Ajustes de la carpeta guardados" + +msgid "Could not save the folder settings." +msgstr "No se pudieron guardar los ajustes de la carpeta." + +msgid "Folder location" +msgstr "Ubicación de la carpeta" + +msgid "Change" +msgstr "Cambiar" + +msgid "Checking..." +msgstr "Verificando..." + +msgid "Folder is accessible" +msgstr "La carpeta es accesible" + +msgid "Folder is not accessible" +msgstr "La carpeta no es accesible" + +msgid "Display name" +msgstr "Nombre para mostrar" + +msgid "Optional friendly name" +msgstr "Nombre descriptivo opcional" + +msgid "Shown instead of the full path in the folder list." +msgstr "Se muestra en lugar de la ruta completa en la lista de carpetas." + +msgid "Enabled" +msgstr "Habilitada" + +msgid "Disabled folders are skipped during scans." +msgstr "Las carpetas desactivadas se omiten durante los análisis." + +msgid "Network folder" +msgstr "Carpeta de red" + +msgid "Mark this folder as a network share (excluded when offline)." +msgstr "Marca esta carpeta como un recurso compartido de red (excluida sin conexión)." + +msgid "Network filesystem type" +msgstr "Tipo de sistema de archivos de red" + +msgid "Auto-detect" +msgstr "Auto-detectar" + +msgid "Last scanned" +msgstr "Último escaneo" + +msgid "Scan this folder" +msgstr "Analizar esta carpeta" + +msgid "FOLDER" +msgstr "CARPETA" + +msgid "LAST SCAN" +msgstr "ÚLTIMO ANÁLISIS" + +msgid "STATUS" +msgstr "ESTADO" + +msgid "Unavailable" +msgstr "No disponible" + +msgid "Active" +msgstr "Activa" + +msgid "LIBRARY FOLDERS" +msgstr "CARPETAS DE LA BIBLIOTECA" + +msgid "Browse..." +msgstr "Explorar..." + +msgid "Choose a music folder" +msgstr "Elige una carpeta de música" + +msgid "Browse for a music folder, or type the full path." +msgstr "Busca una carpeta de música o escribe la ruta completa." + +msgid "Filter folders..." +msgstr "Filtrar carpetas..." + +msgid "No folders yet. Add a folder to build your local library." +msgstr "Aún no hay carpetas. Agrega una carpeta para crear tu biblioteca local." + +msgid "No folders match your filter." +msgstr "Ninguna carpeta coincide con tu filtro." + +msgid "Scanning" +msgstr "Analizando" + +msgid "MAINTENANCE" +msgstr "MANTENIMIENTO" + +msgid "Cleanup missing files" +msgstr "Limpiar archivos faltantes" + +msgid "Remove tracks whose files no longer exist on disk." +msgstr "Elimina las pistas cuyos archivos ya no existen en el disco." + +msgid "Cleaning up..." +msgstr "Limpiando..." + +msgid "Cleanup" +msgstr "Limpieza" + +msgid "DANGER ZONE" +msgstr "ZONA DE PELIGRO" + +msgid "Clear library database" +msgstr "Borrar la base de datos de la biblioteca" + +msgid "Remove all indexed tracks. Your audio files are not deleted." +msgstr "Elimina todas las pistas indexadas. Tus archivos de audio no se eliminan." + +msgid "Clearing..." +msgstr "Limpiando..." + +msgid "Clear all" +msgstr "Limpiar todo" + +msgid "PLEX" +msgstr "PLEX" + +msgid "Connect a Plex Media Server on your local network." +msgstr "Conecta un Plex Media Server en tu red local." + +msgid "Server address" +msgstr "Dirección del servidor" + +msgid "Only local network servers are supported." +msgstr "Solo se admiten servidores de la red local." + +msgid "Enter a valid server address." +msgstr "Escribe una dirección de servidor válida." + +msgid "http://127.0.0.1:32400" +msgstr "http://127.0.0.1:32400" + +msgid "Authorize" +msgstr "Autorizar" + +msgid "Generate a code and sign in to Plex in your browser." +msgstr "Genera un código e inicia sesión en Plex desde tu navegador." + +msgid "Working..." +msgstr "Procesando..." + +msgid "Generate code" +msgstr "Generar código" + +msgid "Link code" +msgstr "Código de vinculación" + +msgid "Enter this code at the Plex sign-in page." +msgstr "Ingresa este código en la página de inicio de sesión de Plex." + +msgid "Copy code" +msgstr "Copiar código" + +msgid "Open Plex sign-in" +msgstr "Abrir inicio de sesión de Plex" + +msgid "Connected to Plex" +msgstr "Conectado a Plex" + +msgid "The code expired. Generate a new one." +msgstr "El código expiró. Genera uno nuevo." + +msgid "Ping the saved server and report what answers." +msgstr "Hace ping al servidor guardado y reporta lo que responde." + +msgid "Connected to {}" +msgstr "Conectado a {}" + +msgid "Could not reach the Plex server." +msgstr "No se pudo alcanzar el servidor de Plex." + +msgid "Plex is not configured." +msgstr "Plex no está configurado." + +msgid "Enter token manually" +msgstr "Ingresar token manualmente" + +msgid "Use an existing X-Plex-Token instead of the PIN flow." +msgstr "Usa un X-Plex-Token existente en lugar del flujo de PIN." + +msgid "Token" +msgstr "Token" + +msgid "X-Plex-Token" +msgstr "X-Plex-Token" + +msgid "Check connection" +msgstr "Comprobar conexión" + +msgid "Verify the server is reachable." +msgstr "Verifica que el servidor sea accesible." + +msgid "Libraries" +msgstr "Bibliotecas" + +msgid "Fetch your Plex music libraries." +msgstr "Obtén tus bibliotecas de música de Plex." + +msgid "Get libraries" +msgstr "Obtener bibliotecas" + +msgid "MUSIC LIBRARIES" +msgstr "BIBLIOTECAS DE MÚSICA" + +msgid "({} tracks)" +msgstr "({} pistas)" + +msgid "Write metadata to Plex (experimental)" +msgstr "Escribir metadatos en Plex (experimental)" + +msgid "Allow QBZ to write track metadata back to your Plex server." +msgstr "Permitir que QBZ escriba los metadatos de las pistas en tu servidor Plex." + +msgid "PLEX DANGER ZONE" +msgstr "ZONA DE PELIGRO DE PLEX" + +msgid "Disconnect" +msgstr "Desconectar" + +msgid "Sign out of Plex and clear the local cache." +msgstr "Cerrar sesión en Plex y borrar la caché local." + +msgid "Clear cache" +msgstr "Limpiar caché" + +msgid "Remove cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Eliminar las bibliotecas y pistas de Plex en caché. Tu sesión se mantiene." + +msgid "OFFLINE MODE" +msgstr "MODO SIN CONEXIÓN" + +msgid "Status" +msgstr "Estado" + +msgid "Captive portal detected — sign in to the network to get online." +msgstr "Portal cautivo detectado: inicia sesión en la red para conectarte." + +msgid "Offline mode enabled" +msgstr "Modo sin conexión activado" + +msgid "No connection" +msgstr "Sin conexión" + +msgid "Online" +msgstr "En linea" + +msgid "Check now" +msgstr "Verificar ahora" + +msgid "Enable Offline Mode" +msgstr "Activar Modo Offline" + +msgid "Test the connection now instead of waiting for the next automatic check." +msgstr "Prueba la conexión ahora en lugar de esperar la siguiente comprobación automática." + +msgid "Include sign-in credentials" +msgstr "Incluir credenciales de inicio de sesión" + +msgid "The bundle will contain your tokens. Anyone who opens the file can sign in as you." +msgstr "El paquete incluirá tus tokens. Cualquiera que abra el archivo puede entrar como tú." + +msgid "Manually switch to offline mode even with internet." +msgstr "Cambiar manualmente al modo sin conexión incluso con internet." + +msgid "OFFLINE CACHE" +msgstr "CACHÉ SIN CONEXIÓN" + +msgid "Manage offline cache" +msgstr "Administrar caché offline" + +msgid "Browse and manage your downloaded tracks and albums." +msgstr "Explora y administra tus pistas y álbumes descargados." + +msgid "Open manager" +msgstr "Abrir administrador" + +msgid "Cache folder" +msgstr "Carpeta de caché" + +msgid "Open the folder where offline tracks are stored on disk." +msgstr "Abrir la carpeta donde se guardan las pistas sin conexión en el disco." + +msgid "Open folder" +msgstr "Abrir carpeta" + +msgid "Clear offline cache" +msgstr "Borrar caché sin conexión" + +msgid "Remove every downloaded track. This cannot be undone." +msgstr "Eliminar todas las pistas descargadas. Esta acción no se puede deshacer." + +msgid "LYRICS" +msgstr "LETRAS" + +msgid "Clear lyrics cache" +msgstr "Borrar caché de letras" + +msgid "{} entries using {}" +msgstr "{} entradas usando {}" + +msgid "Manage" +msgstr "Administrar" + +msgid "Artist Blacklist" +msgstr "Lista negra de artistas" + +msgid "{} artist(s) blocked" +msgstr "{} artista(s) bloqueado(s)" + +msgid "{} artist(s) blocked (disabled)" +msgstr "{} artista(s) bloqueado(s) (desactivado)" + +msgid "Settings" +msgstr "Ajustes" + +msgid "Audio" +msgstr "Audio" + +msgid "Playback" +msgstr "Reproducción" + +msgid "Appearance" +msgstr "Apariencia" + +msgid "Offline" +msgstr "Offline" + +msgid "Blacklist" +msgstr "Lista negra" + +msgid "Integrations" +msgstr "Integraciones" + +msgid "Developer" +msgstr "Desarrollador" + +msgid "Loading settings..." +msgstr "Cargando ajustes..." + +msgid "Added {}" +msgstr "Agregado {}" + +msgid "Blacklisted artists are hidden from search results, radio, suggestions, and similar artists." +msgstr "Los artistas en la lista negra están ocultos en resultados de búsqueda, radio, sugerencias y artistas similares." + +msgid "Clear All" +msgstr "Limpiar Todo" + +msgid "{} artists" +msgstr "{} artistas" + +msgid "Blacklist filtering is disabled. Blacklisted artists are shown everywhere until you re-enable it." +msgstr "El filtrado de la lista negra está desactivado. Los artistas en la lista negra se muestran en todas partes hasta que lo vuelvas a activar." + +msgid "Loading blacklist…" +msgstr "Cargando lista negra…" + +msgid "No blacklisted artists" +msgstr "No hay artistas en la lista negra" + +msgid "To hide an artist, go to their page and use the menu." +msgstr "Para ocultar un artista, ve a su página y usa el menú." + +msgid "No results for \"{}\"" +msgstr "Sin resultados para \"{}\"" + +msgid "Clear Blacklist?" +msgstr "¿Limpiar Lista Negra?" + +msgid "This will remove all {} blacklisted artists. This cannot be undone." +msgstr "Esto quitará los {} artistas de la lista negra. Esta acción no se puede deshacer." + +msgid "SCROBBLERS" +msgstr "SCROBBLERS" + +msgid "Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services." +msgstr "Envía tus reproducciones a Last.fm y ListenBrainz. Funciona con pistas de Qobuz, locales y de Plex. Este interruptor también decide si Recomendaciones puede leer tu historial de escucha de esos servicios." + +msgid "LAST.FM" +msgstr "LAST.FM" + +msgid "Scrobble to Last.fm" +msgstr "Hacer scrobbling en Last.fm" + +msgid "Signed in as {}." +msgstr "Sesión iniciada como {}." + +msgid "Connect your Last.fm account to enable scrobbling." +msgstr "Conecta tu cuenta de Last.fm para habilitar el scrobbling." + +msgid "Connect" +msgstr "Conectar" + +msgid "Authorize QBZ in your browser, then click Finish." +msgstr "Autoriza QBZ en tu navegador y luego haz clic en Finalizar." + +msgid "Connect Last.fm" +msgstr "Conectar Last.fm" + +msgid "Open authorize page" +msgstr "Abrir página de autorización" + +msgid "Disconnect Last.fm" +msgstr "Desconectar Last.fm" + +msgid "Sign out of Last.fm." +msgstr "Cerrar sesión en Last.fm." + +msgid "LISTENBRAINZ" +msgstr "LISTENBRAINZ" + +msgid "Scrobble to ListenBrainz" +msgstr "Hacer scrobbling en ListenBrainz" + +msgid "Paste your ListenBrainz user token to enable scrobbling." +msgstr "Pega tu token de usuario de ListenBrainz para habilitar el scrobbling." + +msgid "User token" +msgstr "Token de usuario" + +msgid "From listenbrainz.org/settings." +msgstr "Desde listenbrainz.org/settings." + +msgid "ListenBrainz token" +msgstr "Token de ListenBrainz" + +msgid "Save token" +msgstr "Guardar token" + +msgid "Validates the token against ListenBrainz." +msgstr "Valida el token con ListenBrainz." + +msgid "Validating..." +msgstr "Validando..." + +msgid "Connect ListenBrainz" +msgstr "Conectar ListenBrainz" + +msgid "Disconnect ListenBrainz" +msgstr "Desconectar ListenBrainz" + +msgid "Sign out of ListenBrainz." +msgstr "Cerrar sesión en ListenBrainz." + +msgid "DISCORD" +msgstr "DISCORD" + +msgid "Discord Rich Presence" +msgstr "Discord Rich Presence" + +msgid "Show what you're listening to as your Discord status. Opt-in — Discord must be running." +msgstr "Muestra lo que estás escuchando como tu estado de Discord. Opcional: Discord debe estar abierto." + +msgid "Flatpak socket access" +msgstr "Acceso al socket de Flatpak" + +msgid "" +"Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" +msgstr "" +"¿Instalación Flatpak y no aparece la presencia? Otorga acceso al socket IPC de Discord y luego reinicia QBZ:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" + +msgid "Streaming quality" +msgstr "Calidad de streaming" + +msgid "The quality tier QBZ requests for playback." +msgstr "El nivel de calidad que QBZ solicita para la reproducción." + +msgid "Limit quality to device" +msgstr "Limitar la calidad al dispositivo" + +msgid "Cap streaming quality at the output device's maximum sample rate." +msgstr "Limita la calidad de streaming a la frecuencia de muestreo máxima del dispositivo de salida." + +msgid "Maximum sample rate" +msgstr "Frecuencia de muestreo máxima" + +msgid "The highest sample rate streamed tracks may use." +msgstr "La frecuencia de muestreo más alta que pueden usar las pistas en streaming." + +msgid "OUTPUT" +msgstr "SALIDA" + +msgid "Audio backend" +msgstr "Backend de audio" + +msgid "The audio stack QBZ routes playback through." +msgstr "El sistema de audio a través del cual QBZ enruta la reproducción." + +msgid "Output device" +msgstr "Dispositivo de salida" + +msgid "The DAC or sound device that receives audio." +msgstr "El DAC o dispositivo de sonido que recibe el audio." + +msgid "ALSA plugin" +msgstr "Plugin de ALSA" + +msgid "How ALSA opens the device — hw is bit-perfect, plughw converts." +msgstr "Cómo ALSA abre el dispositivo: hw es bit-perfect, plughw convierte." + +msgid "Hardware volume control" +msgstr "Control de volumen por hardware" + +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Controla el volumen mediante un mezclador expuesto por el dispositivo ALSA seleccionado. Si no está disponible, la reproducción directa se mantiene fija al 100 %." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Este dispositivo ALSA no tiene un control de volumen por hardware compatible. La reproducción directa se mantiene fija al 100 %." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ no pudo liberar por completo el dispositivo de audio anterior. Consulta el registro de audio para ver los detalles." + +msgid "BIT-PERFECT" +msgstr "BIT-PERFECT" + +msgid "Exclusive mode" +msgstr "Modo exclusivo" + +msgid "Lock the device so no other app can resample it." +msgstr "Bloquea el dispositivo para que ninguna otra app pueda remuestrearlo." + +msgid "Reserve DAC while running" +msgstr "Reservar el DAC mientras se ejecuta" + +msgid "Hold the device reserved so other apps can't grab it." +msgstr "Mantén el dispositivo reservado para que otras apps no puedan tomarlo." + +msgid "DAC passthrough" +msgstr "Passthrough del DAC" + +msgid "Send the bitstream untouched to the DAC." +msgstr "Envía el flujo de bits intacto al DAC." + +msgid "Force bit-perfect" +msgstr "Forzar bit-perfect" + +msgid "Pin the PipeWire quantum and rate for the active track." +msgstr "Fija el quantum y la frecuencia de PipeWire para la pista activa." + +msgid "Allow quality fallback" +msgstr "Permitir fallback de calidad" + +msgid "Drop to a lower tier when the requested one is unavailable." +msgstr "Baja a un nivel inferior cuando el solicitado no está disponible." + +msgid "HiFi Wizard" +msgstr "Asistente HiFi" + +msgid "Auto-detect your DACs and set up bit-perfect playback, step by step." +msgstr "Detecta automáticamente tus DAC y configura la reproducción bit-perfect, paso a paso." + +msgid "Open Wizard" +msgstr "Abrir asistente" + +msgid "STARTUP" +msgstr "INICIO" + +msgid "Sync audio settings on startup" +msgstr "Sincronizar configuración de audio al iniciar" + +msgid "Reload saved audio settings into the player when QBZ launches." +msgstr "Vuelve a cargar los ajustes de audio guardados en el reproductor al iniciar QBZ." + +msgid "Lock output device" +msgstr "Bloquear dispositivo de salida" + +msgid "Keep external routing intact — skip switching the default sink." +msgstr "Mantiene intacto el enrutamiento externo: no cambia el sink predeterminado." + +msgid "THEME" +msgstr "TEMA" + +msgid "Theme" +msgstr "Tema" + +msgid "Album header gradient" +msgstr "Degradado en el encabezado del álbum" + +msgid "Use artwork-derived blur as a backdrop in album and artist detail views." +msgstr "Usa un desenfoque derivado de la portada como fondo en las vistas de detalle de álbum y artista." + +msgid "Intelligent Search" +msgstr "Búsqueda inteligente" + +msgid "Smart search cache, ranking, and the search preview dropdown. On by default." +msgstr "Caché de búsqueda inteligente, clasificación y menú desplegable de vista previa de búsqueda. Activado de forma predeterminada." + +msgid "Generate a color theme from your system wallpaper or a custom image" +msgstr "Genera un tema de colores desde tu wallpaper del sistema o una imagen personalizada" + +msgid "Select Image..." +msgstr "Seleccionar Imagen..." + +msgid "Detected: " +msgstr "Detectado: " + +msgid "Experimental: theme may not match your system exactly." +msgstr "Experimental: el tema puede no coincidir exactamente con tu sistema." + +msgid "Regenerate" +msgstr "Regenerar" + +msgid "TYPOGRAPHY & LANGUAGE" +msgstr "TIPOGRAFÍA E IDIOMA" + +msgid "Language" +msgstr "Idioma" + +msgid "UI Scale" +msgstr "Escala de UI" + +msgid "Immersive search" +msgstr "Búsqueda inmersiva" + +msgid "What selecting a result in the Immersive search does. Disabled turns the in-immersive search off." +msgstr "Qué ocurre al seleccionar un resultado en la búsqueda inmersiva. Si se desactiva, se apaga la búsqueda dentro del modo inmersivo." + +msgid "Immersive default view" +msgstr "Vista predeterminada del modo inmersivo" + +msgid "Which immersive view opens by default. 'Remember last' restores your last view." +msgstr "Qué vista inmersiva se abre de forma predeterminada. «Recordar la última» restaura tu última vista." + +msgid "LIBRARY & VISUALS" +msgstr "BIBLIOTECA Y VISUALES" + +msgid "Show navigation in sidebar" +msgstr "Mostrar navegación en la barra lateral" + +msgid "Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar." +msgstr "Mueve las secciones Descubrir, Biblioteca, Biblioteca local y Mi QBZ del encabezado a la barra lateral." + +msgid "My QBZ" +msgstr "My QBZ" + +msgid "Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default." +msgstr "Cambia el nombre del centro Mi QBZ. Deja el nombre en blanco (o pulsa restablecer) para restaurar el predeterminado." + +msgid "Show playlist cover collages in sidebar" +msgstr "Mostrar collage de portadas en playlists del sidebar" + +msgid "Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images." +msgstr "Muestra una miniatura 2×2 con portadas de las pistas junto a cada playlist. Desactívalo en equipos con recursos limitados para omitir las imágenes extra." + +msgid "Show album artwork in Local Library track list" +msgstr "Mostrar carátula del album en la lista de pistas de Local Library" + +msgid "Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost." +msgstr "Muestra la miniatura de la portada del album junto a cada pista en las vistas de Pistas y Carpetas. Apagado por defecto — bibliotecas grandes pagan un costo por fila al decodificar cada imagen." + +msgid "NOTIFICATIONS" +msgstr "NOTIFICACIONES" + +msgid "In-app toasts notifications" +msgstr "Notificaciones en App" + +msgid "System Notifications" +msgstr "Notificaciones del Sistema" + +msgid "WINDOW TITLE" +msgstr "TÍTULO DE LA VENTANA" + +msgid "Show track in window title" +msgstr "Mostrar pista en título de ventana" + +msgid "Title template" +msgstr "Plantilla del título" + +msgid "Placeholders: {{artist}}, {{track}}, {{album}}" +msgstr "Marcadores: {{artist}}, {{track}}, {{album}}" + +msgid "TITLE BAR" +msgstr "BARRA DE TÍTULO" + +msgid "Use system title bar" +msgstr "Usar barra de título del sistema" + +msgid "Replace QBZ custom title bar with your system's native window decorations. Changing this will restart the app." +msgstr "Reemplazar la barra de título personalizada de QBZ con las decoraciones nativas de tu sistema. Cambiar esto reiniciará la app." + +msgid "Hide title bar" +msgstr "Ocultar barra de título" + +msgid "Hide the title bar completely (for tiling window manager users)" +msgstr "Ocultar completamente la barra de título (para usuarios de gestores de ventanas en mosaico)" + +msgid "WINDOW CONTROLS" +msgstr "CONTROLES DE VENTANA" + +msgid "Window controls position" +msgstr "Posición de controles de ventana" + +msgid "Place the window control buttons on the left or right side of the title bar" +msgstr "Ubicar los botones de control de ventana a la izquierda o derecha de la barra de título" + +msgid "Show window controls" +msgstr "Mostrar controles de ventana" + +msgid "Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these." +msgstr "Mostrar botones de minimizar, maximizar y cerrar en la barra de titulo. Desactiva si tu gestor de ventanas los maneja." + +msgid "PLAYER VIEWS" +msgstr "VISTAS DEL REPRODUCTOR" + +msgid "Show volume +/- buttons" +msgstr "Mostrar botones de volumen +/-" + +msgid "Add discrete plus and minus buttons next to the volume slider in the player bar." +msgstr "Añade botones individuales de más y menos junto al control deslizante de volumen en la barra del reproductor." + +msgid "Mini player default view" +msgstr "Vista predeterminada del mini reproductor" + +msgid "Startup page" +msgstr "Página de inicio" + +msgid "Choose which page to show when the app starts" +msgstr "Elige qué página mostrar al iniciar la app" + +msgid "Show Purchases" +msgstr "Mostrar Compras" + +msgid "Show the Purchases section in the sidebar for browsing and downloading your purchased music" +msgstr "Mostrar la sección de Compras en la barra lateral para explorar y descargar tu música comprada" + +msgid "MENU BAR" +msgstr "BARRA DE MENÚS" + +msgid "SYSTEM TRAY" +msgstr "BANDEJA DEL SISTEMA" + +msgid "Enable tray icon" +msgstr "Habilitar icono de bandeja" + +msgid "Show icon in system tray (requires restart)" +msgstr "Mostrar icono en la bandeja del sistema (requiere reinicio)" + +msgid "Close to tray" +msgstr "Cerrar a bandeja" + +msgid "Keep playing in the menu bar instead of quitting when you close the window" +msgstr "Sigue reproduciendo en la barra de menús en lugar de cerrar la aplicación al cerrar la ventana" + +msgid "Keep playing in the tray instead of quitting when you close the window" +msgstr "Sigue reproduciendo en la bandeja en lugar de cerrar la aplicación al cerrar la ventana" + +msgid "Hide Dock icon when closed to menu bar" +msgstr "Ocultar el icono del Dock al cerrar a la barra de menús" + +msgid "Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)" +msgstr "Ejecutar como aplicación solo en la barra de menús mientras la ventana está cerrada. Si está desactivado, conserva el ícono del Dock (como Spotify)" + +msgid "Tray icon variant" +msgstr "Variante del icono" + +msgid "Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo" +msgstr "Elige un icono monocromático para tu barra (Plasma, la barra superior siempre oscura de GNOME) o el vinilo a color" + +msgid "IMMERSIVE" +msgstr "INMERSIVO" + +msgid "Background effect" +msgstr "Efecto de fondo" + +msgid "Controls how the blurred artwork background is rendered in immersive mode." +msgstr "Controla como se renderiza el fondo difuminado en el modo inmersivo." + +msgid "Immersive Panels" +msgstr "Paneles inmersivos" + +msgid "Enable or disable individual immersive view panels" +msgstr "Activa o desactiva paneles individuales de la vista inmersiva" + +msgid "Disabled panels are hidden from the visualizer menu so you don't land on them by mistake." +msgstr "Los paneles desactivados se ocultan del menú del visualizador para que no caigas en ellos por error." + +msgid "Animation Frame Rate" +msgstr "Tasa de cuadros" + +msgid "Per-animation FPS control for immersive mode" +msgstr "Control de FPS por animacion en modo inmersivo" + +msgid "Configure frame rate per animation. Lower values save CPU. Requires re-entering immersive mode to apply." +msgstr "Configura la tasa de cuadros por animacion. Valores bajos ahorran CPU. Requiere volver a entrar al modo inmersivo." + +msgid "PLAYLISTS" +msgstr "PLAYLISTS" + +msgid "No playlists yet." +msgstr "Aún no hay listas de reproducción." + +msgid "Manage playlists" +msgstr "Administrar listas" + +msgid "Remove from queue" +msgstr "Quitar de la cola" + +msgid "Stop after this" +msgstr "Detener después de esto" + +msgid "Remove all after" +msgstr "Quitar todo lo siguiente" + +msgid "History" +msgstr "Historial" + +msgid "NOW PLAYING" +msgstr "REPRODUCIENDO AHORA" + +msgid "UP NEXT ({} of {}, {} left)" +msgstr "A CONTINUACIÓN ({} de {}, quedan {})" + +msgid "Page {} of {}" +msgstr "Página {} de {}" + +msgid "Your queue is empty" +msgstr "Tu cola está vacía" + +msgid "Play an album or track to get started" +msgstr "Reproduce un álbum o una pista para empezar" + +msgid "No tracks match your search" +msgstr "Ninguna pista coincide con tu búsqueda" + +msgid "RECENTLY PLAYED" +msgstr "REPRODUCIDO RECIENTEMENTE" + +msgid "Nothing played yet" +msgstr "Aún no se ha reproducido nada" + +msgid "Search queue" +msgstr "Buscar en la cola" + +msgid "Auto-follow" +msgstr "Auto-scroll" + +msgid "Font" +msgstr "Fuente" + +msgid "System" +msgstr "Sistema" + +msgid "LINE Seed JP" +msgstr "LINE Seed JP" + +msgid "Montserrat" +msgstr "Montserrat" + +msgid "Noto Sans" +msgstr "Noto Sans" + +msgid "Source Sans 3" +msgstr "Source Sans 3" + +msgid "Size" +msgstr "Tamaño" + +msgid "S" +msgstr "S" + +msgid "M" +msgstr "M" + +msgid "L" +msgstr "L" + +msgid "XL" +msgstr "XL" + +msgid "Active color" +msgstr "Color karaoke" + +msgid "Uppercase" +msgstr "Mayúsculas" + +msgid "Dimming" +msgstr "Atenuar" + +msgid "Soft" +msgstr "Suave" + +msgid "Strong" +msgstr "Fuerte" + +msgid "Copy lyrics" +msgstr "Copiar letra" + +msgid "Reset" +msgstr "Reiniciar" + +msgid "Previous" +msgstr "Anterior" + +msgid "Pause" +msgstr "Pausar" + +msgid "Repeat" +msgstr "Repetir" + +msgid "Add to…" +msgstr "Agregar a…" + +msgid "Add album to collection" +msgstr "Agregar álbum a la colección" + +msgid "Remove album from collection" +msgstr "Quitar álbum de la colección" + +msgid "Remove from library" +msgstr "Quitar de la biblioteca" + +msgid "{} (not playable)" +msgstr "{} (no reproducible)" + +msgid "Cast" +msgstr "Transmitir" + +msgid "Connected to" +msgstr "Conectado a" + +msgid "Streaming {} · {}" +msgstr "Transmitiendo {} · {}" + +msgid "No devices found" +msgstr "No se encontraron dispositivos" + +msgid "Looking for Chromecast & DLNA on your network" +msgstr "Buscando Chromecast y DLNA en tu red" + +msgid "Display settings" +msgstr "Opciones de lyrics" + +msgid "Fetching lyrics..." +msgstr "Buscando letras..." + +msgid "No lyrics available" +msgstr "No hay letras disponibles" + +msgid "Missing lyrics? You can contribute them to LRCLIB:" +msgstr "¿Faltan las letras? Puedes contribuir con ellas en LRCLIB:" + +msgid "Lyrics unavailable offline" +msgstr "Letras no disponibles sin conexión" + +msgid "Lyrics source" +msgstr "Fuente de las letras" + +msgid "Qobuz Connect" +msgstr "Qobuz Connect" + +msgid "Connected" +msgstr "Conectado" + +msgid "Disconnected" +msgstr "Desconectado" + +msgid "Playing on: {}" +msgstr "Reproduciendo en: {}" + +msgid "Looking for devices…" +msgstr "Buscando dispositivos…" + +msgid "Make sure your renderer is on the same network" +msgstr "Asegúrate de que tu renderizador esté en la misma red" + +msgid "Play here" +msgstr "Reproducir aquí" + +msgid "Audio settings" +msgstr "Ajustes de audio" + +msgid "Normalization" +msgstr "Normalización" + +msgid "Level loudness across tracks" +msgstr "Nivelar el volumen entre pistas" + +msgid "Gapless" +msgstr "Sin pausas" + +msgid "Seamless track transitions" +msgstr "Transiciones fluidas entre pistas" + +msgid "Now Playing view" +msgstr "Vista de Reproducción actual" + +msgid "New" +msgstr "Nueva" + +msgid "Classic" +msgstr "Clásica" + +msgid "Miniplayer" +msgstr "Minirreproductor" + +msgid "Immersive" +msgstr "Inmersivo" + +msgid "Unmute" +msgstr "Activar Sonido" + +msgid "Mute" +msgstr "Silenciar" + +msgid "Volume" +msgstr "Volumen" + +msgid "Qobuz Connect: On" +msgstr "Qobuz Connect: Activado" + +msgid "Qobuz Connect: Off" +msgstr "Qobuz Connect: Desactivado" + +msgid "Volume down" +msgstr "Bajar volumen" + +msgid "Volume up" +msgstr "Subir volumen" + +msgid "Nothing playing" +msgstr "Nada en reproducción" + +msgid "Go to where this is playing from" +msgstr "Ir al origen de la reproducción" + +msgid "Casting" +msgstr "Transmitiendo" + +msgid "Output mode" +msgstr "Modo de salida" + +msgid "Move to root" +msgstr "Mover a raíz" + +msgid "No folders yet." +msgstr "Aún no hay carpetas." + +msgid "Discover" +msgstr "Descubrir" + +msgid "Mixtapes" +msgstr "Mixtapes" + +msgid "Collections" +msgstr "Colecciones" + +msgid "Search playlists" +msgstr "Buscar playlists" + +msgid "Sort by" +msgstr "Ordenar por" + +msgid "Name (A-Z)" +msgstr "Nombre (A-Z)" + +msgid "# of tracks" +msgstr "N.º de pistas" + +msgid "Refresh" +msgstr "Actualizar" + +msgid "Logged out" +msgstr "Sesión cerrada" + +msgid "Offline (manual)" +msgstr "Sin conexión (manual)" + +msgid "Offline (hard)" +msgstr "Sin conexión (forzado)" + +msgid "You're signed out. Sign-in needs a connection." +msgstr "Cerraste sesión. Para iniciar sesión necesitas conexión." + +msgid "Connection available — sign back in to Qobuz." +msgstr "Conexión disponible: vuelve a iniciar sesión en Qobuz." + +msgid "You're signed out — sign back in to Qobuz." +msgstr "Cerraste sesión: vuelve a iniciar sesión en Qobuz." + +msgid "No internet connection — your local library and downloads keep working." +msgstr "Sin conexión a Internet: tu biblioteca local y tus descargas siguen funcionando." + +msgid "Sign in" +msgstr "Iniciar sesión" + +msgid "Offline mode" +msgstr "Modo sin conexión" + +msgid "Enter" +msgstr "Enter" + +msgid "Guest" +msgstr "Invitado" + +msgid "Not signed in" +msgstr "Sin sesión iniciada" + +msgid "Keyboard Shortcuts" +msgstr "Atajos de Teclado" + +msgid "Documentation" +msgstr "Documentación" + +msgid "Report an Issue" +msgstr "Reportar un problema" + +msgid "Log Out" +msgstr "Cerrar sesión" + +msgid "Create Mixtape" +msgstr "Crear Mixtape" + +msgid "Create Collection" +msgstr "Crear Collection" + +msgid "NAME" +msgstr "NOMBRE" + +msgid "KIND" +msgstr "TIPO" + +msgid "+ New Mixtape" +msgstr "+ Nuevo Mixtape" + +msgid "+ New Collection" +msgstr "+ Nueva Collection" + +msgid "Rename" +msgstr "Renombrar" + +msgid "Edit description" +msgstr "Editar descripción" + +msgid "Delete \"{}\"? This cannot be undone." +msgstr "¿Eliminar \"{}\"? Esta acción no se puede deshacer." + +msgid "All ({})" +msgstr "Todos ({})" + +msgid "Random queue" +msgstr "Cola aleatoria" + +msgid "Create a random queue with songs from this collection. How many songs do you want?" +msgstr "Crea una cola aleatoria con canciones de esta colección. ¿Cuántas canciones quieres?" + +msgid "Number of songs" +msgstr "Cantidad de canciones" + +msgid "{} songs available" +msgstr "{} canciones disponibles" + +msgid "No mixtapes yet" +msgstr "Aún no hay mixtapes" + +msgid "Position" +msgstr "Posición" + +msgid "Items" +msgstr "Elementos" + +msgid "Recently updated" +msgstr "Actualización reciente" + +msgid "No results found" +msgstr "Sin resultados" + +msgid "No collections yet" +msgstr "Aún no hay collections" + +msgid "All kinds" +msgstr "Todos los tipos" + +msgid "Artist Collections" +msgstr "Colecciones de artistas" + +msgid "Remove from collection" +msgstr "Quitar de la colección" + +msgid "Not found" +msgstr "No encontrado" + +msgid "Upload cover" +msgstr "Subir portada" + +msgid "Clear custom cover" +msgstr "Quitar portada personalizada" + +msgid "Album shuffle" +msgstr "Álbumes aleatorios" + +msgid "In order" +msgstr "En orden" + +msgid "Convert to Collection" +msgstr "Convertir a Collection" + +msgid "Convert to Mixtape" +msgstr "Convertir a Mixtape" + +msgid "No items yet. Add albums, tracks, or playlists from their detail pages." +msgstr "Aún no hay elementos. Agrega álbumes, pistas o listas de reproducción desde sus páginas de detalle." + +msgid "1 filter" +msgstr "1 filtro" + +msgid "{} filters" +msgstr "{} filtros" + +msgid "Type" +msgstr "Tipo" + +msgid "All types" +msgstr "Todos" + +msgid "Track" +msgstr "Pista" + +msgid "Qobuz" +msgstr "Qobuz" + +msgid "Plex" +msgstr "Plex" + +msgid "Item" +msgstr "Elemento" + +msgid "Already added" +msgstr "Ya agregado" + +msgid "ADD TO MIXTAPE/COLLECTION" +msgstr "AGREGAR A MIXTAPE/COLECCIÓN" + +msgid "No matches." +msgstr "Sin coincidencias." + +msgid "No mixtapes or collections yet." +msgstr "Aún no hay mixtapes ni colecciones." + +msgid "EP" +msgstr "EP" + +msgid "Single" +msgstr "Sencillo" + +msgid "Live" +msgstr "En vivo" + +msgid "Compilation" +msgstr "Recopilación" + +msgid "Use auto-detected" +msgstr "Usar detección automática" + +msgid "alternate" +msgstr "alternativa" + +msgid "compilation" +msgstr "recopilación" + +msgid "BUILD ARTIST COLLECTION" +msgstr "CREAR COLECCIÓN DE ARTISTA" + +msgid "ORDER BY" +msgstr "ORDENAR POR" + +msgid "Release date" +msgstr "Fecha de lanzamiento" + +msgid "Manual" +msgstr "Manual" + +msgid "Loading..." +msgstr "Cargando..." + +msgid "ALBUM" +msgstr "ÁLBUM" + +msgid "{} of {} album selected" +msgid_plural "{} of {} albums selected" +msgstr[0] "{} de {} álbum seleccionado" +msgstr[1] "{} de {} álbumes seleccionados" + +msgid "Now Playing" +msgstr "Reproduciendo" + +msgid "No lyrics" +msgstr "Sin letra" + +msgid "No track selected" +msgstr "Sin pista seleccionada" + +msgid "Loading suggestions..." +msgstr "Cargando sugerencias..." + +msgid "Failed to load suggestions" +msgstr "No se pudieron cargar las sugerencias" + +msgid "Recommended tracks" +msgstr "Pistas recomendadas" + +msgid "Exit Immersive" +msgstr "Salir del modo inmersivo" + +msgid "FOCUS" +msgstr "ENFOQUE" + +msgid "Plasma" +msgstr "Plasma" + +msgid "Aurora" +msgstr "Aurora" + +msgid "SPLIT" +msgstr "DIVIDIR" + +msgid "Track Info" +msgstr "Info del Track" + +msgid "Suggestions" +msgstr "Sugerencias" + +msgid "UP NEXT" +msgstr "A CONTINUACIÓN" + +msgid "Tracks you play will appear here" +msgstr "Las pistas que reproduzcas aparecerán aquí" + +msgid "No track playing" +msgstr "Sin pista en reproducción" + +msgid "Queue is empty" +msgstr "La cola está vacía" + +msgid "Search albums & tracks…" +msgstr "Buscar álbumes y pistas…" + +msgid "Acclaimed Releases" +msgstr "Lanzamientos aclamados" + +msgid "No award-winning releases yet." +msgstr "Aún no hay lanzamientos premiados." + +msgid "Award" +msgstr "Premio" + +msgid "Unfollow award" +msgstr "Dejar de seguir" + +msgid "Follow award" +msgstr "Seguir premio" + +msgid "See All" +msgstr "Ver Todo" + +msgid "Other awards" +msgstr "Más premios" + +msgid "{} item needs attention before bit-perfect playback will work." +msgid_plural "{} items need attention before bit-perfect playback will work." +msgstr[0] "{} elemento requiere atención antes de que funcione la reproducción bit-perfect." +msgstr[1] "{} elementos requieren atención antes de que funcione la reproducción bit-perfect." + +msgid "Removed {} artist from blacklist" +msgid_plural "Removed {} artists from blacklist" +msgstr[0] "Se eliminó {} artista de la lista negra" +msgstr[1] "Se eliminaron {} artistas de la lista negra" + +msgid "Removed {} folder" +msgid_plural "Removed {} folders" +msgstr[0] "Se eliminó {} carpeta" +msgstr[1] "Se eliminaron {} carpetas" + +msgid "Scan complete ({} file skipped)" +msgid_plural "Scan complete ({} files skipped)" +msgstr[0] "Análisis completado ({} archivo omitido)" +msgstr[1] "Análisis completado ({} archivos omitidos)" + +msgid "{} album" +msgid_plural "{} albums" +msgstr[0] "{} álbum" +msgstr[1] "{} álbumes" + +msgid "{} item" +msgid_plural "{} items" +msgstr[0] "{} elemento" +msgstr[1] "{} elementos" + +msgid "{} duplicate skipped" +msgid_plural "{} duplicates skipped" +msgstr[0] "{} duplicado omitido" +msgstr[1] "{} duplicados omitidos" + +msgid "Removed {} item" +msgid_plural "Removed {} items" +msgstr[0] "Se eliminó {} elemento" +msgstr[1] "Se eliminaron {} elementos" + +msgid "Caching {} track offline…" +msgid_plural "Caching {} tracks offline…" +msgstr[0] "Almacenando {} pista sin conexión…" +msgstr[1] "Almacenando {} pistas sin conexión…" + +msgid "(Re)start the PipeWire audio services" +msgstr "(Re)iniciar los servicios de audio de PipeWire" + +msgid "({} local)" +msgstr "({} local)" + +msgid "176.4 kHz" +msgstr "176.4 kHz" + +msgid "192 kHz" +msgstr "192 kHz" + +msgid "352.8 kHz" +msgstr "352.8 kHz" + +msgid "384 kHz" +msgstr "384 kHz" + +msgid "44.1 kHz" +msgstr "44.1 kHz" + +msgid "48 kHz" +msgstr "48 kHz" + +msgid "88.2 kHz" +msgstr "88.2 kHz" + +msgid "96 kHz" +msgstr "96 kHz" + +msgid "A fresh mix every week." +msgstr "Una mezcla nueva cada semana." + +msgid "Add a folder before scanning" +msgstr "Agrega una carpeta antes de escanear" + +msgid "Added to queue" +msgstr "Agregado a la cola" + +msgid "Added {} to {}" +msgstr "Se agregó {} a {}" + +msgid "Added {} to {} ({})" +msgstr "Se agregó {} a {} ({})" + +msgid "Adding tracks to playlist..." +msgstr "Agregando tracks a la playlist..." + +msgid "Adding tracks: {} / {}" +msgstr "Agregando pistas: {} / {}" + +msgid "Album folder not found on disk" +msgstr "No se encontró la carpeta del álbum en el disco" + +msgid "Album metadata saved" +msgstr "Metadatos del álbum guardados" + +msgid "Album title is required" +msgstr "El título del álbum es obligatorio" + +msgid "Albums of the Week" +msgstr "Álbumes de la Semana" + +msgid "Albums on Local Library" +msgstr "Álbumes en la biblioteca local" + +msgid "Already in {}" +msgstr "Ya está en {}" + +msgid "Always skip track" +msgstr "Siempre saltar pista" + +msgid "Always try lowest quality" +msgstr "Siempre intentar calidad mas baja" + +msgid "April" +msgstr "Abril" + +msgid "Are you absolutely sure?" +msgstr "¿Estás completamente seguro?" + +msgid "Artist Spotlight" +msgstr "Artista destacado" + +msgid "Artists on Local Library" +msgstr "Artistas en la biblioteca local" + +msgid "Ask me" +msgstr "Preguntarme" + +msgid "August" +msgstr "Agosto" + +msgid "Authorize QBZ in your browser, then click \"Finish\"" +msgstr "Autoriza QBZ en tu navegador y luego haz clic en \"Finalizar\"" + +msgid "Available tracks only — offline" +msgstr "Solo pistas disponibles — sin conexión" + +msgid "Bit-perfect (Hardware / Digital)" +msgstr "Bit-perfect (Hardware / Digital)" + +msgid "Blacklist disabled" +msgstr "Lista negra desactivada" + +msgid "Blacklist enabled" +msgstr "Lista negra activada" + +msgid "Booklet open task failed: {}" +msgstr "Error al abrir el folleto: {}" + +msgid "COLLECTION" +msgstr "COLLECTION" + +msgid "Cache cleared" +msgstr "Caché borrada" + +msgid "Cached for offline" +msgstr "Guardado para uso sin conexión" + +msgid "Cannot convert this kind" +msgstr "No se puede convertir este tipo" + +msgid "Checked {} tracks - all OK" +msgstr "Se revisaron {} pistas - todo correcto" + +msgid "Checking playlist link..." +msgstr "Verificando el enlace de la lista de reproducción..." + +msgid "Checking your audio stack…" +msgstr "Verificando tu sistema de audio…" + +msgid "Couldn't load the test tracks (offline?)" +msgstr "No se pudieron cargar las pistas de prueba (¿sin conexión?)" + +msgid "Choose a cover image" +msgstr "Elige una imagen de portada" + +msgid "Choose a folder to play" +msgstr "Selecciona una carpeta para reproducir" + +msgid "Choose a {} icon" +msgstr "Elige un icono de {}" + +msgid "Cleaning up missing files..." +msgstr "Limpiando archivos faltantes..." + +msgid "Cleanup failed" +msgstr "Error en la limpieza" + +msgid "Clear Plex cache?" +msgstr "¿Borrar la caché de Plex?" + +msgid "Clear library database?" +msgstr "¿Borrar la base de datos de la biblioteca?" + +msgid "Code copied" +msgstr "Código copiado" + +msgid "Compilations" +msgstr "Compilaciones" + +msgid "Connected as {}" +msgstr "Conectado como {}" + +msgid "Connected to {} (v{})" +msgstr "Conectado a {} (v{})" + +msgid "Continue Listening" +msgstr "Seguir escuchando" + +msgid "Converted" +msgstr "Convertido" + +msgid "Couldn't add folder" +msgstr "No se pudo agregar la carpeta" + +msgid "Couldn't change folder location (path may already exist)" +msgstr "No se pudo cambiar la ubicación de la carpeta (la ruta ya podría existir)" + +msgid "Couldn't clean up missing files" +msgstr "No se pudieron limpiar los archivos faltantes" + +msgid "Couldn't clear the Plex cache" +msgstr "No se pudo borrar la caché de Plex" + +msgid "Couldn't clear the library database" +msgstr "No se pudo borrar la base de datos de la biblioteca" + +msgid "Couldn't clear the offline cache" +msgstr "No se pudo borrar la caché sin conexión" + +msgid "Couldn't create the Qobuz playlist" +msgstr "No se pudo crear la lista de reproducción de Qobuz" + +msgid "Couldn't load library folders" +msgstr "No se pudieron cargar las carpetas de la biblioteca" + +msgid "Couldn't load that album" +msgstr "No se pudo cargar ese álbum" + +msgid "Couldn't load that playlist" +msgstr "No se pudo cargar esa lista de reproducción" + +msgid "Couldn't load that track" +msgstr "No se pudo cargar esa pista" + +msgid "Couldn't load this album" +msgstr "No se pudo cargar este álbum" + +msgid "Couldn't load this artist" +msgstr "No se pudo cargar este artista" + +msgid "Couldn't load this collection" +msgstr "No se pudo cargar esta colección" + +msgid "Couldn't load this playlist" +msgstr "No se pudo cargar esta lista de reproducción" + +msgid "Couldn't open that folder" +msgstr "No se pudo abrir esa carpeta" + +msgid "Couldn't save folder settings" +msgstr "No se pudo guardar la configuración de la carpeta" + +msgid "Couldn't save metadata: {}" +msgstr "No se pudieron guardar los metadatos: {}" + +msgid "Cover removed" +msgstr "Portada eliminada" + +msgid "Cover updated" +msgstr "Portada actualizada" + +msgid "Creating playlist..." +msgstr "Creando playlist..." + +msgid "Critics' Picks" +msgstr "Selección de la crítica" + +msgid "DAC: {} · {} · {} ch" +msgstr "DAC: {} · {} · {} ch" + +msgid "December" +msgstr "Diciembre" + +msgid "Defaults" +msgstr "Valores predeterminados" + +msgid "Disconnect from Plex?" +msgstr "¿Desconectar de Plex?" + +msgid "EPs & Singles" +msgstr "EPs y Sencillos" + +msgid "Enable PipeWire in your NixOS configuration" +msgstr "Habilita PipeWire en tu configuración de NixOS" + +msgid "Enter a title or artist to search" +msgstr "Escribe un título o artista para buscar" + +msgid "Enter code {} at the Plex sign-in page" +msgstr "Introduce el código {} en la página de inicio de sesión de Plex" + +msgid "Error: {}" +msgstr "Error: {}" + +msgid "Error: {} (did you authorize in the browser?)" +msgstr "Error: {} (¿autorizaste en el navegador?)" + +msgid "Essentials by Genre" +msgstr "Esenciales por género" + +msgid "Every track needs a title" +msgstr "Cada pista necesita un título" + +msgid "Failed to cast track" +msgstr "No se pudo transmitir la pista" + +msgid "Failed to change play mode" +msgstr "No se pudo cambiar el modo de reproducción" + +msgid "Failed to clear blacklist" +msgstr "No se pudo borrar la lista negra" + +msgid "Failed to clear lyrics cache" +msgstr "No se pudo borrar la caché de letras" + +msgid "Failed to create temp dir: {}" +msgstr "No se pudo crear el directorio temporal: {}" + +msgid "Failed to decode booklet page: {}" +msgstr "No se pudo decodificar la página del folleto: {}" + +msgid "Failed to delete" +msgstr "No se pudo eliminar" + +msgid "Failed to fetch booklet: {}" +msgstr "No se pudo obtener el folleto: {}" + +msgid "Failed to fetch metadata" +msgstr "No se pudieron obtener los metadatos" + +msgid "Failed to read booklet: {}" +msgstr "No se pudo leer el folleto: {}" + +msgid "Failed to remove artist" +msgstr "No se pudo quitar el artista" + +msgid "Failed to remove cover" +msgstr "No se pudo eliminar la portada" + +msgid "Failed to remove items" +msgstr "No se pudieron quitar los elementos" + +msgid "Failed to rename" +msgstr "No se pudo renombrar" + +msgid "Failed to save description" +msgstr "No se pudo guardar la descripción" + +msgid "Failed to toggle blacklist" +msgstr "No se pudo cambiar la lista negra" + +msgid "Failed to upload cover" +msgstr "No se pudo subir la portada" + +msgid "Failed to write booklet: {}" +msgstr "No se pudo escribir el folleto: {}" + +msgid "Library Albums" +msgstr "Álbumes de tu biblioteca" + +msgid "February" +msgstr "Febrero" + +msgid "File not available — is the drive mounted?" +msgstr "Archivo no disponible — ¿está montada la unidad?" + +msgid "Files not available — is the drive mounted?" +msgstr "Archivos no disponibles — ¿está montada la unidad?" + +msgid "Folder opened" +msgstr "Carpeta abierta" + +msgid "Found {} tracks from {}." +msgstr "Se encontraron {} pistas de {}." + +msgid "From the same artist" +msgstr "Del mismo artista" + +msgid "From your most-played playlists." +msgstr "De tus listas más reproducidas." + +msgid "Ideal Discography" +msgstr "Discografía Ideal" + +msgid "Image" +msgstr "Imagen" + +msgid "Import failed: {}" +msgstr "Error al importar: {}" + +msgid "Imported {} of {} tracks into QBZ." +msgstr "Se importaron {} de {} pistas a QBZ." + +msgid "Install the ALSA bridge so playback can reach PipeWire" +msgstr "Instala el puente ALSA para que la reproducción llegue a PipeWire" + +msgid "Install the PipeWire audio stack" +msgstr "Instala la pila de audio PipeWire" + +msgid "Install the PipeWire tools (pw-dump)" +msgstr "Instala las herramientas de PipeWire (pw-dump)" + +msgid "Install the Pulse compatibility tools (optional fallback)" +msgstr "Instala las herramientas de compatibilidad de Pulse (alternativa opcional)" + +msgid "January" +msgstr "Enero" + +msgid "July" +msgstr "Julio" + +msgid "June" +msgstr "Junio" + +msgid "Last.fm disconnected" +msgstr "Last.fm desconectado" + +msgid "Last.fm sign-in failed to start" +msgstr "No se pudo iniciar el inicio de sesión en Last.fm" + +msgid "Library database cleared" +msgstr "Base de datos de la biblioteca borrada" + +msgid "ListenBrainz disconnected" +msgstr "ListenBrainz desconectado" + +msgid "Listening suggestions" +msgstr "Sugerencias de escucha" + +msgid "Loaded {} cached tracks" +msgstr "Se cargaron {} pistas en caché" + +msgid "Log in to cache tracks offline" +msgstr "Inicia sesión para guardar pistas sin conexión" + +msgid "Lossless: {}" +msgstr "LOSSLESS: {}" + +msgid "Lyrics cache cleared" +msgstr "Caché de letras borrada" + +msgid "Lyrics copied" +msgstr "Letra copiada" + +msgid "MIXTAPE" +msgstr "MIXTAPE" + +msgid "March" +msgstr "Marzo" + +msgid "Matching tracks: {} / {} ({} found)" +msgstr "Pistas coincidentes: {} / {} ({} encontradas)" + +msgid "May" +msgstr "Mayo" + +msgid "Middle-click to pause" +msgstr "Clic central para pausar" + +msgid "Middle-click to play" +msgstr "Clic central para reproducir" + +msgid "Mixed queue — not cast to Qobuz Connect" +msgstr "Cola mixta — no se transmite a Qobuz Connect" + +msgid "More From Your Library" +msgstr "Más de tu Biblioteca" + +msgid "More Labels" +msgstr "Más sellos" + +msgid "Most Streamed" +msgstr "Más reproducidas" + +msgid "" +"Music Player\n" +"Nothing playing" +msgstr "" +"Reproductor de música\n" +"Nada en reproducción" + +msgid "Network folder detected" +msgstr "Se detectó una carpeta de red" + +msgid "Never" +msgstr "Nunca" + +msgid "New Releases" +msgstr "Novedades" + +msgid "Next Track" +msgstr "Siguiente Pista" + +msgid "No limit" +msgstr "Sin límite" + +msgid "No matching tracks found." +msgstr "No se encontraron pistas coincidentes." + +msgid "No sinks detected — reinstall the ALSA UCM profiles, then reboot" +msgstr "No se detectaron salidas — reinstala los perfiles UCM de ALSA y luego reinicia" + +msgid "No top tracks available for this artist" +msgstr "No hay pistas destacadas disponibles para este artista" + +msgid "No tracks available offline" +msgstr "No hay pistas disponibles sin conexión" + +msgid "Not available offline" +msgstr "No disponible sin conexión" + +msgid "Nothing playable in this playlist right now" +msgstr "No hay nada reproducible en esta lista en este momento" + +msgid "November" +msgstr "Noviembre" + +msgid "October" +msgstr "Octubre" + +msgid "Offline cache cleared" +msgstr "Caché sin conexión vaciada" + +msgid "Offline cache is full — free space or raise the limit" +msgstr "La caché sin conexión está llena — libera espacio o aumenta el límite" + +msgid "Offline caching failed" +msgstr "Error al guardar en caché sin conexión" + +msgid "Offline listening period expired — reconnect to verify your subscription" +msgstr "El periodo de escucha sin conexión expiró — reconéctate para verificar tu suscripción" + +msgid "On Local Library" +msgstr "En la biblioteca local" + +msgid "Opened folder ({} files skipped)" +msgstr "Carpeta abierta ({} archivos omitidos)" + +msgid "Other Outputs" +msgstr "Otras salidas" + +msgid "PLAYLIST" +msgstr "PLAYLIST" + +msgid "Paste your ListenBrainz user token first" +msgstr "Primero pega tu token de usuario de ListenBrainz" + +msgid "Play/Pause" +msgstr "Reproducir/Pausar" + +msgid "Playing next" +msgstr "A continuación" + +msgid "Playing {} of {}" +msgstr "Reproduciendo {} de {}" + +msgid "Playlist ({} local)" +msgstr "Lista de reproducción ({} local)" + +msgid "Playlist created in Qobuz™." +msgstr "Lista de reproducción creada en Qobuz™." + +msgid "Playlist uploaded to Qobuz" +msgstr "Lista de reproducción subida a Qobuz" + +msgid "Playlist: {}" +msgstr "Lista de reproducción: {}" + +msgid "Plex link failed to start" +msgstr "No se pudo iniciar la vinculación con Plex" + +msgid "Plugin Hardware" +msgstr "Hardware con plugin" + +msgid "Press Accolades" +msgstr "Reconocimientos de Prensa" + +msgid "Previous Track" +msgstr "Pista Anterior" + +msgid "Purchase Only" +msgstr "Solo compra" + +msgid "QBZ requesting {} · {}-bit" +msgstr "QBZ solicitando {} · {}-bit" + +msgid "Qobuz Connect is unavailable while offline" +msgstr "Qobuz Connect no está disponible sin conexión" + +msgid "Qobuz Connect renderer disconnected" +msgstr "Receptor de Qobuz Connect desconectado" + +msgid "Qobuz Connect renderer unreachable" +msgstr "Receptor de Qobuz Connect inaccesible" + +msgid "Qobuzissimes" +msgstr "Qobuzissimes" + +msgid "Quit QBZ" +msgstr "Salir de QBZ" + +msgid "Rate limited, try again shortly" +msgstr "Límite de solicitudes alcanzado, vuelve a intentarlo en breve" + +msgid "Recently Played" +msgstr "Reproducido Recientemente" + +msgid "Rediscover Your Library" +msgstr "Redescubre tu biblioteca" + +msgid "Release Watch" +msgstr "Radar de Novedades" + +msgid "Removed \"{}\" ({})" +msgstr "Se quitó «{}» ({})" + +msgid "Removed album from offline" +msgstr "Álbum eliminado del modo sin conexión" + +msgid "Removed from offline" +msgstr "Eliminado de sin conexión" + +msgid "Removed {} of {} tracks" +msgstr "Se eliminaron {} de {} pistas" + +msgid "Scan cancelled" +msgstr "Escaneo cancelado" + +msgid "Scan complete" +msgstr "Escaneo completo" + +msgid "Scan failed" +msgstr "Falló el escaneo" + +msgid "Scanning track paths..." +msgstr "Escaneando rutas de pistas..." + +msgid "Scroll to adjust volume" +msgstr "Desplázate para ajustar el volumen" + +msgid "Search failed: {}" +msgstr "Falló la búsqueda: {}" + +msgid "Searching Qobuz catalog..." +msgstr "Buscando en el catalogo de Qobuz..." + +msgid "Select music folder" +msgstr "Seleccionar carpeta de música" + +msgid "September" +msgstr "Septiembre" + +msgid "Show/Hide Window" +msgstr "Mostrar/Ocultar ventana" + +msgid "Similar to {}" +msgstr "Similar a {}" + +msgid "Skipped: {}" +msgstr "Omitido: {}" + +msgid "Some tracks can't be cast to Qobuz Connect" +msgstr "Algunas pistas no se pueden enviar a Qobuz Connect" + +msgid "Song Radio" +msgstr "Radio de Canción" + +msgid "Split into {} playlists (Qobuz 2000-track limit)" +msgstr "Dividido en {} listas de reproducción (límite de 2000 pistas de Qobuz)" + +msgid "Start the sign-in first" +msgstr "Primero inicia el inicio de sesión" + +msgid "Starting…" +msgstr "Iniciando…" + +msgid "System default" +msgstr "Predeterminado del sistema" + +msgid "TRACK" +msgstr "PISTA" + +msgid "These items resolved to 0 playable tracks" +msgstr "Estos elementos no contienen pistas reproducibles" + +msgid "This action cannot be undone." +msgstr "Esta acción no se puede deshacer." + +msgid "This album has no playable tracks" +msgstr "Este álbum no tiene pistas reproducibles" + +msgid "This collection resolved to 0 playable tracks" +msgstr "Esta colección no contiene pistas reproducibles" + +msgid "This item resolved to 0 playable tracks" +msgstr "Este elemento no contiene pistas reproducibles" + +msgid "This modifies your audio files on disk and cannot be undone." +msgstr "Esto modifica tus archivos de audio en el disco y no se puede deshacer." + +msgid "This playlist has no playable tracks" +msgstr "Esta lista de reproducción no tiene pistas reproducibles" + +msgid "This removes ALL indexed tracks from the database. Your audio files are NOT deleted. You will need to re-scan your folders afterward." +msgstr "Esto elimina TODAS las pistas indexadas de la base de datos. Tus archivos de audio NO se eliminan. Después tendrás que volver a escanear tus carpetas." + +msgid "This removes cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Esto elimina las bibliotecas y pistas de Plex almacenadas en caché. Tu inicio de sesión se conserva." + +msgid "This signs out of Plex and clears the locally cached libraries and tracks." +msgstr "Esto cierra la sesión de Plex y borra las bibliotecas y pistas almacenadas en caché localmente." + +msgid "This track is no longer available" +msgstr "Esta pista ya no está disponible" + +msgid "This track is not yet available" +msgstr "Esta pista todavía no está disponible" + +msgid "Track count differs from the result; titles applied by position" +msgstr "El número de pistas difiere del resultado; los títulos se aplicaron por posición" + +msgid "Track not available offline" +msgstr "Pista no disponible sin conexión" + +msgid "Track not castable to Qobuz Connect" +msgstr "La pista no se puede enviar a Qobuz Connect" + +msgid "Track unavailable on Qobuz Connect" +msgstr "Pista no disponible en Qobuz Connect" + +msgid "Tracks matched: {} / {}" +msgstr "Pistas coincidentes: {} / {}" + +msgid "Unavailable Plex track" +msgstr "Pista de Plex no disponible" + +msgid "Unavailable track" +msgstr "Pista no disponible" + +msgid "Unknown" +msgstr "Desconocido" + +msgid "Unknown Artist" +msgstr "Artista desconocido" + +msgid "Unknown source" +msgstr "Fuente desconocida" + +msgid "Upcoming" +msgstr "Próximamente" + +msgid "Upload incomplete — local playlist kept" +msgstr "Subida incompleta — se conservó la lista local" + +msgid "Various Artists" +msgstr "Varios artistas" + +msgid "Waiting for the DAC to start playing…" +msgstr "Esperando a que el DAC comience a reproducir…" + +msgid "Write tags to audio files?" +msgstr "¿Escribir etiquetas en los archivos de audio?" + +msgid "Writing tags to files isn't supported for CUE albums; use sidecar mode" +msgstr "La escritura de etiquetas en archivos no es compatible con álbumes CUE; usa el modo sidecar" + +msgid "Year must be a number" +msgstr "El año debe ser un número" + +msgid "You're offline — try again when connected" +msgstr "Estás sin conexión — inténtalo de nuevo cuando te conectes" + +msgid "Your audio stack is ready for bit-perfect playback." +msgstr "Tu sistema de audio está listo para la reproducción bit-perfect." + +msgid "Your queue is empty — add some tracks first, or press Play test." +msgstr "Tu cola está vacía — agrega algunas pistas primero o presiona Reproducir prueba." + +msgid "by {}" +msgstr "de {}" + +msgid "hw (Direct Hardware)" +msgstr "hw (Hardware directo)" + +msgid "pcm (Most compatible)" +msgstr "pcm (Más compatible)" + +msgid "plughw (Auto-convert)" +msgstr "plughw (Conversión automática)" + +msgid "{} + {} more" +msgstr "{} + {} más" + +msgid "{} albums · {} tracks" +msgstr "{} álbumes · {} pistas" + +msgid "{} h {} min" +msgstr "{} h {} min" + +msgid "{} libraries found" +msgstr "{} bibliotecas encontradas" + +msgid "{} min" +msgstr "{} min" + +msgid "{} removed from blacklist" +msgstr "{} eliminado de la lista negra" + +msgid "{} tracks loaded" +msgstr "{} pistas cargadas" + +msgid "{} tracks · {}" +msgstr "{} pistas · {}" + +msgid "{} {}" +msgstr "{} {}" + +msgid "{} {}, {}" +msgstr "{} {}, {}" + +msgid "{} — Complete Discography" +msgstr "{} — Discografía completa" + +msgid "· Unlimited" +msgstr "· Ilimitado" + +msgid "· of {}" +msgstr "· de {}" + +# --- Purchases (Slice 13) --- +msgid "My Purchases" +msgstr "Mis Compras" + +msgid "Purchases are downloaded as DRM-free files. Availability depends on your Qobuz region." +msgstr "Las compras se descargan como archivos sin DRM. La disponibilidad depende de tu región de Qobuz." + +msgid "Search purchases…" +msgstr "Buscar compras…" + +msgid "No purchases yet" +msgstr "Aún no hay compras" + +msgid "No purchased tracks yet" +msgstr "Aún no hay pistas compradas" + +msgid "Filters" +msgstr "Filtros" + +msgid "Availability" +msgstr "Disponibilidad" + +msgid "Hide unavailable" +msgstr "Ocultar no disponibles" + +msgid "CD Quality" +msgstr "Calidad CD" + +msgid "MP3" +msgstr "MP3" + +msgid "Downloads" +msgstr "Descargas" + +msgid "Hide downloaded" +msgstr "Ocultar descargados en este equipo" + +msgid "Group: A–Z" +msgstr "Grupo: A-Z" + +msgid "Sort: Date" +msgstr "Ordenar: Fecha" + +msgid "Sort: Artist" +msgstr "Ordenar: Artista" + +msgid "Sort: Album" +msgstr "Ordenar: Álbum" + +msgid "Sort: Quality" +msgstr "Ordenar: Calidad" + +msgid "Date" +msgstr "Fecha" + +msgid "Download all" +msgstr "Descargar todo" + +msgid "Download complete" +msgstr "Descarga completa" + +msgid "Download cancelled ({}/{})" +msgstr "Descarga cancelada ({}/{})" + +msgid "Downloading {}/{}..." +msgstr "Descargando {}/{}..." + +msgid "Select format" +msgstr "Seleccionar formato" + +msgid "Purchased on" +msgstr "Comprado el" + +msgid "Add these downloaded tracks to your local library." +msgstr "Agregar estas pistas descargadas a tu biblioteca local." + +msgid "{} tracks" +msgstr "{} pistas" + +msgid "Choose folder" +msgstr "Elegir carpeta" + +msgid "Couldn't add to library" +msgstr "No se pudo agregar a la biblioteca" + +msgid "Added to library" +msgstr "Agregado a la biblioteca" + +msgid "Purchased from Qobuz" +msgstr "Compra de Qobuz" + +msgid "Couldn't load purchases. Check your connection." +msgstr "No se pudieron cargar las compras. Revisa tu conexión." + +msgid "This track has no album to download" +msgstr "Esta pista no tiene álbum para descargar" + +msgid "Failed to start download. Please try again." +msgstr "No se pudo iniciar la descarga. Inténtalo de nuevo." + +msgid "No downloadable formats available" +msgstr "No hay formatos disponibles para descargar" + +msgid "Download goodies" +msgstr "Descargar extras" + +# Playlist "Suggested Songs" section (T8) +msgid "Suggested songs" +msgstr "Canciones sugeridas" + +msgid "Suggest songs for this playlist" +msgstr "Sugerir canciones para esta lista" + +msgid "Finding similar artists..." +msgstr "Buscando artistas similares..." + +msgid "Refresh suggestions" +msgstr "Actualizar sugerencias" + +msgid "Not interested" +msgstr "No me interesa" + +# Slint polish batch (radio / playlist follow-copy / MusicBrainz / reco) +msgid "QBZ Radio" +msgstr "Radio QBZ" + +msgid "Qobuz Radio" +msgstr "Radio Qobuz" + +msgid "Create QBZ radio" +msgstr "Crear radio QBZ" + +msgid "Create Qobuz radio" +msgstr "Crear radio Qobuz" + +msgid "Follow on Qobuz" +msgstr "Seguir en Qobuz" + +msgid "Following on Qobuz" +msgstr "Siguiendo en Qobuz" + +msgid "Copy to your library" +msgstr "Copiar a tu biblioteca" + +msgid "METADATA" +msgstr "METADATOS" + +msgid "MusicBrainz" +msgstr "MusicBrainz" + +msgid "Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections." +msgstr "Activa las relaciones entre artistas y los metadatos mejorados de MusicBrainz. Sin telemetría — solo empareja lanzamientos para enriquecer las páginas de artistas y las sugerencias de canciones de las listas de reproducción. Desactívalo para deshabilitar esas secciones." + +msgid "No tracks to add" +msgstr "No hay pistas para agregar" + +msgid "Failed to add tracks" +msgstr "No se pudieron agregar las pistas" + +msgid "Copied to your library" +msgstr "Copiado a tu biblioteca" + +msgid "Failed to copy playlist" +msgstr "No se pudo copiar la lista de reproducción" + +msgid "Playlist has no tracks to copy" +msgstr "La lista de reproducción no tiene pistas para copiar" + +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "Se agregó {} pista" +msgstr[1] "Se agregaron {} pistas" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "Se agregó {} pista a {}" +msgstr[1] "Se agregaron {} pistas a {}" + +msgid "Added {} track to queue" +msgid_plural "Added {} tracks to queue" +msgstr[0] "Se agregó {} pista a la cola" +msgstr[1] "Se agregaron {} pistas a la cola" + +msgid "Unfollow on Qobuz" +msgstr "Dejar de seguir en Qobuz" + +msgid "Stops in" +msgstr "Se detiene en" + +msgid "Cancel Timer" +msgstr "Cancelar temporizador" + +msgid "Stop playback after:" +msgstr "Detener la reproducción después de:" + +msgid "30 min" +msgstr "30 min" + +msgid "1 hr" +msgstr "1 h" + +msgid "2 hr" +msgstr "2 h" + +msgid "3 hr" +msgstr "3 h" + +msgid "5 hr" +msgstr "5 h" + +msgid "Custom…" +msgstr "Personalizado…" + +msgid "min" +msgstr "min" + +msgid "Set Timer" +msgstr "Activar temporizador" + +msgid "Cancel stop after this" +msgstr "Cancelar detener después de esto" + +msgid "LOGS" +msgstr "REGISTROS" + +msgid "Application logs" +msgstr "Registros de la aplicación" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Consulta el registro de la app, cópialo (con los secretos ocultos) o súbelo para compartirlo en un issue de GitHub." + +msgid "Open log viewer" +msgstr "Abrir el visor de registros" + +msgid "Setting" +msgstr "Ajuste" + +msgid "Saved" +msgstr "Guardado" + +msgid "Runtime" +msgstr "En ejecución" + +msgid "Diagnostics" +msgstr "Diagnóstico" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Comparación entre la configuración guardada y la activa." + +msgid "Exported" +msgstr "Exportado" + +msgid "Export to clipboard" +msgstr "Exportar al portapapeles" + +msgid "Cast Discovery" +msgstr "Búsqueda de dispositivos Cast" + +msgid "Scanning…" +msgstr "Buscando…" + +msgid "Scan for devices" +msgstr "Buscar dispositivos" + +msgid "Graphics" +msgstr "Gráficos" + +msgid "Environment" +msgstr "Entorno" + +msgid "Logs" +msgstr "Registros" + +msgid "View logs" +msgstr "Ver registros" + +msgid "Upload failed." +msgstr "Falló la subida." + +msgid "All levels" +msgstr "Todos los niveles" + +msgid "Error" +msgstr "Error" + +msgid "Warn" +msgstr "Aviso" + +msgid "Info" +msgstr "Info" + +msgid "Debug" +msgstr "Depuración" + +msgid "Trace" +msgstr "Rastreo" + +msgid "Search logs…" +msgstr "Buscar en los registros…" + +msgid "Auto-tail" +msgstr "Auto-seguir" + +msgid "Time" +msgstr "Hora" + +msgid "Level" +msgstr "Nivel" + +msgid "Target" +msgstr "Módulo" + +msgid "Message" +msgstr "Mensaje" + +msgid "No log entries." +msgstr "No hay registros." + +msgid "Copied" +msgstr "Copiado" + +msgid "Copy all" +msgstr "Copiar todo" + +msgid "Copy diagnostics bundle" +msgstr "Copiar el paquete de diagnóstico" + +msgid "Uploading…" +msgstr "Subiendo…" + +msgid "Upload (public)" +msgstr "Subir (público)" + +msgid "Open log file" +msgstr "Abrir el archivo de registro" + +msgid "Report an issue" +msgstr "Reportar un problema" + +msgid "Go to logs" +msgstr "Ir a los logs" + +msgid "Create issue report" +msgstr "Crear reporte" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Para hacer más sencilla la resolución de bugs y problemas, es recomendable compartir los logs de la aplicación. Nos hemos encargado de ser muy cuidadosos y enmascarar los logs que se envían para no enviar ningún dato sensible; el proceso es manual, nada automático, para que tú puedas verificar lo que se comparte." + +msgid "Cache window" +msgstr "Ventana de cache" + +msgid "24 hours" +msgstr "24 horas" + +msgid "36 hours" +msgstr "36 horas" + +msgid "48 hours" +msgstr "48 horas" + +msgid "72 hours" +msgstr "72 horas" + +msgid "Refresh now" +msgstr "Actualizar ahora" + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback without browser sample rate limitations." +msgstr "Un cliente nativo de streaming de música para Linux, diseñado para audiófilos que necesitan una reproducción de alta fidelidad con fidelidad de bits, sin las limitaciones de frecuencia de muestreo del navegador." + +msgid "Acknowledgments" +msgstr "Reconocimiento" + +msgid "Animated background renderer" +msgstr "Renderizador de fondos animados" + +msgid "Audio playback engine" +msgstr "Motor de reproducción de audio" + +msgid "Author" +msgstr "Autor" + +msgid "Build" +msgstr "Build" + +msgid "Build Info" +msgstr "Información de Versión" + +msgid "Codename" +msgstr "Nombre en clave" + +msgid "Contributors" +msgstr "Contribuidores" + +msgid "Hatred towards all those companies that discriminate against the Linux community and don't provide us with a decent client for their product." +msgstr "Odio hacia todas aquellas empresas que discriminan a la comunidad Linux y no nos ofrecen un cliente decente para su producto." + +msgid "Icon library" +msgstr "Biblioteca de iconos" + +msgid "License" +msgstr "Licencia" + +msgid "Lyrics API" +msgstr "API de letras" + +msgid "Made with" +msgstr "Hecho con" + +msgid "Music link aggregation API" +msgstr "API de agregación de enlaces musicales" + +msgid "Music metadata database" +msgstr "Base de datos de metadatos musicales" + +msgid "Music streaming service and API" +msgstr "Servicio de streaming musical y API" + +msgid "Platform" +msgstr "Plataforma" + +msgid "Release" +msgstr "Release" + +msgid "Release notes are not available." +msgstr "Las notas de la versión no están disponibles." + +msgid "Synced lyrics provider" +msgstr "Proveedor de letras sincronizadas" + +msgid "UI framework" +msgstr "UI framework" + +msgid "Version" +msgstr "Versión" + +msgid "Website" +msgstr "Página web" + +msgid "What's new" +msgstr "Qué hay de nuevo" + +msgid "What's new in v{}" +msgstr "Novedades de v{}" + +msgid "in" +msgstr "en" + +msgid "QBZ uses your Last.fm and ListenBrainz account data when they're connected (if they aren't, we recommend connecting them to enrich this section). Here you set how often that data is fetched — it defaults to 48 hours, or you can request an update right now." +msgstr "QBZ toma la información de tu cuenta de Last.fm y ListenBrainz si es que están activos (si no lo están, te recomendamos activarlo para enriquecer esta sección). Aquí configuras qué tan seguido quieres hacer la obtención de estos datos; por default está establecido a 48 horas, o puedes pedir una actualización en este momento." + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback." +msgstr "Un cliente nativo de streaming de música para Linux, diseñado para audiófilos que necesitan una reproducción de alta fidelidad con fidelidad de bits." + +msgid "Built with Rust and Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, and Lucide." +msgstr "Construido con Rust y Slint, e impulsado por Rodio y Symphonia. Con agradecimientos a Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh y Lucide." + +# Album blacklist (album-level blocking) +msgid "Block this album" +msgstr "Bloquear este álbum" + +msgid "Unblock album" +msgstr "Desbloquear álbum" + +msgid "Album \"{}\" blocked" +msgstr "Álbum \"{}\" bloqueado" + +msgid "Album \"{}\" unblocked" +msgstr "Álbum \"{}\" desbloqueado" + +msgid "Failed to block album" +msgstr "No se pudo bloquear el álbum" + +msgid "Failed to unblock album" +msgstr "No se pudo desbloquear el álbum" + +msgid "{} albums" +msgstr "{} álbumes" + +msgid "No blocked albums" +msgstr "No hay álbumes bloqueados" + +msgid "To block an album, open its menu and choose Block this album." +msgstr "Para bloquear un álbum, abre su menú y elige Bloquear este álbum." + +msgid "Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed." +msgstr "Los álbumes bloqueados se ocultan de la búsqueda, el descubrimiento y los listados, aunque su artista esté permitido." + +msgid "This will remove all {} blocked albums. This cannot be undone." +msgstr "Esto quitará los {} álbumes bloqueados. No se puede deshacer." + +msgid "{} artists, {} albums blocked" +msgstr "{} artistas, {} álbumes bloqueados" + +msgid "{} artists, {} albums blocked (disabled)" +msgstr "{} artistas, {} álbumes bloqueados (desactivado)" + +msgid "Failed to clear album blacklist" +msgstr "No se pudo limpiar la lista de álbumes bloqueados" + +msgid "Conductor" +msgstr "Director" + +msgid "Orchestra" +msgstr "Orquesta" + +msgid "Featured Artist" +msgstr "Artista invitado" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "Se eliminó {} álbum de la lista negra" +msgstr[1] "Se eliminaron {} álbumes de la lista negra" + +# Recommendations (Discover external-reco) + JACK not-bit-perfect note +msgid "RECOMMENDATIONS" +msgstr "RECOMENDACIONES" + +msgid "Show Recommendations in Discover" +msgstr "Mostrar Recomendaciones en Descubrir" + +msgid "A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases." +msgstr "Una pestaña de Descubrir personalizada a partir de lo que escuchas. Para mejores resultados conecta Last.fm y ListenBrainz abajo — MusicBrainz se usa automáticamente para emparejar lanzamientos." + +msgid "Recommendations" +msgstr "Recomendaciones" + +msgid "More like the artists you love" +msgstr "Más de los artistas que te encantan" + +msgid "Based on what you've been into lately" +msgstr "Según lo que has escuchado últimamente" + +msgid "Weekly Exploration" +msgstr "Exploración semanal" + +msgid "Weekly Jams" +msgstr "Joyas de la semana" + +msgid "Popular artists on Qobuz" +msgstr "Artistas populares en Qobuz" + +msgid "No recommendations yet" +msgstr "Aún no hay recomendaciones" + +msgid "Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations." +msgstr "Conecta Last.fm o ListenBrainz en Ajustes, o reproduce más música, para obtener recomendaciones personalizadas." + +msgid "JACK is not bit-perfect" +msgstr "JACK no es bit-perfect" + +msgid "The JACK backend routes audio through the JACK graph, which resamples to the graph's sample rate. For bit-perfect playback use ALSA (direct) or PipeWire with passthrough." +msgstr "El backend de JACK enruta el audio por el grafo de JACK, que remuestrea a la frecuencia del grafo. Para reproducción bit-perfect usa ALSA (directo) o PipeWire con passthrough." + +msgid "Extra large" +msgstr "Extra grande" + +msgid "INTERFACE" +msgstr "INTERFAZ" + +msgid "Interface size" +msgstr "Tamaño de la interfaz" + +msgid "Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)" +msgstr "Escala toda la interfaz, como el zoom del navegador. Pequeño muestra más contenido en pantalla, Grande y Extra grande mejoran la legibilidad (requiere reiniciar)" + +msgid "Interface size changed — restart QBZ to apply" +msgstr "Tamaño de la interfaz cambiado — reinicia QBZ para aplicar" + +msgid "Extra small" +msgstr "Extra pequeño" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Inicia sesión con tu navegador" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Continúa en tu navegador web — QBZ está esperando a que termines de iniciar sesión." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "Puede que el navegador se abra en segundo plano sin tomar el foco — checa tus otras ventanas." + +msgid "Signing you in…" +msgstr "Iniciando tu sesión…" + +msgid "Sign-in failed" +msgstr "No se pudo iniciar sesión" + +msgid "About QBZ" +msgstr "Acerca de QBZ" + +msgid "Already used by" +msgstr "Ya lo usa" + +msgid "Auto (recommended)" +msgstr "Auto (recomendado)" + +msgid "Discrete GPU (higher performance)" +msgstr "GPU dedicada (más rendimiento)" + +msgid "Integrated GPU (lower power)" +msgstr "GPU integrada (menos consumo)" + +msgid "Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)" +msgstr "Auto elige el mejor renderizador para tu hardware gráfico. Cámbialo solo si la app se siente lenta o se ve mal (requiere reiniciar)" + +msgid "Customize Shortcuts" +msgstr "Personalizar atajos" + +msgid "GPU" +msgstr "GPU" + +msgid "GPU (compatibility)" +msgstr "GPU (compatibilidad)" + +msgid "Go" +msgstr "Ir" + +msgid "Line Bed" +msgstr "Line Bed" + +msgid "Tunnel Flow" +msgstr "Tunnel Flow" + +msgid "Open Music Link" +msgstr "Abrir enlace de música" + +msgid "Open Playlist Importer" +msgstr "Abrir importador de playlists" + +msgid "Open Qobuz Link" +msgstr "Abrir enlace de Qobuz" + +msgid "Paste a Qobuz, Spotify, Apple Music, Tidal, Deezer or song.link URL" +msgstr "Pega una URL de Qobuz, Spotify, Apple Music, Tidal, Deezer o song.link" + +msgid "Place the Purchases entry in the custom title bar instead of the sidebar" +msgstr "Coloca la entrada de Compras en la barra de título personalizada en lugar de la barra lateral" + +msgid "Press keys…" +msgstr "Presiona teclas…" + +msgid "Purchases" +msgstr "Compras" + +msgid "Purchases in title bar" +msgstr "Compras en la barra de título" + +msgid "RENDERER" +msgstr "RENDERIZADOR" + +msgid "Rendering backend" +msgstr "Backend de renderizado" + +msgid "Reset All to Defaults" +msgstr "Restablecer todo" + +msgid "Resolving…" +msgstr "Resolviendo…" + +msgid "Software" +msgstr "Software" + +msgid "Spectral Ribbon" +msgstr "Spectral Ribbon" + +msgid "This looks like a playlist." +msgstr "Esto parece una playlist." + +msgid "Wave Bed" +msgstr "Wave Bed" + +msgid "What's New" +msgstr "Novedades" + +msgid "modified" +msgstr "modificado" + +msgid "Keep your system's native window decorations. Turn off to use the QBZ header as the title bar, with its own window controls and drag support. Takes effect after restarting QBZ." +msgstr "Conserva las decoraciones de ventana nativas del sistema. Desactívalo para usar el encabezado de QBZ como barra de título, con sus propios controles de ventana y arrastre. Se aplica al reiniciar QBZ." + +msgid "Frameless window without window controls or header drag (for tiling window manager users)" +msgstr "Ventana sin marco, sin controles de ventana ni arrastre en el encabezado (para usuarios de gestores de ventanas en mosaico)" + +msgid "Title bar mode changed — restart QBZ to apply" +msgstr "Modo de barra de título cambiado — reinicia QBZ para aplicar" + +# Compact header navigation (opt-in, 2026-07-03) +msgid "Compact header navigation" +msgstr "Navegación compacta en el encabezado" + +msgid "Use the icon-only section navigation in the header even while the sidebar is open." +msgstr "Usa la navegación de secciones de solo iconos en el encabezado aunque la barra lateral esté abierta." + +# DSD support Phase 1 (2026-07-03) +msgid "DSD: {}" +msgstr "DSD: {}" + +msgid "Cannot play DSD file" +msgstr "No se puede reproducir el archivo DSD" + +# DSD Phases 2-3: delivery mode (2026-07-03) +msgid "DSD playback" +msgstr "Reproducción DSD" + +msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC." +msgstr "Cómo llegan las pistas DSD al DAC. ADVERTENCIA: elige DoP o Nativo solo si tu DAC lo soporta — en cualquier otro DAC se reproducen como ruido fuerte. El volumen queda fijo en modo DoP/Nativo. Nativo además requiere soporte del kernel para el DAC." + +msgid "Convert to PCM (works everywhere)" +msgstr "Convertir a PCM (funciona en todos lados)" + +msgid "DoP — DSD over PCM (bit-perfect)" +msgstr "DoP — DSD sobre PCM (bit-perfect)" + +msgid "Native DSD (kernel support required)" +msgstr "DSD nativo (requiere soporte del kernel)" + +msgid "Date added" +msgstr "Fecha de agregado" + +msgid "Renderer setting reverted to Auto — the previous start didn't finish" +msgstr "El ajuste de renderizador volvió a Automático — el arranque anterior no terminó" + +msgid "Renderer switched to GPU (compatibility) — the GPU renderer failed to start" +msgstr "El renderizador cambió a GPU (compatibilidad) — el renderizador GPU no pudo iniciar" + +msgid "Audio output error" +msgstr "Error de salida de audio" + +msgid "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default" +msgstr "El acceso directo a dispositivos ALSA está bloqueado dentro de Flatpak/Snap — cambia el backend de audio a PipeWire o al predeterminado del sistema" + +msgid "Auto theme generation failed" +msgstr "Error al generar el tema automático" + +msgid "Play all tracks" +msgstr "Reproducir todas las pistas" + +msgid "Shuffle all tracks" +msgstr "Aleatorio: todas las pistas" + +msgid "Select multiple tracks" +msgstr "Seleccionar varias pistas" + +msgid "Play a random album" +msgstr "Reproducir un álbum al azar" + +msgid "Open a random artist" +msgstr "Abrir un artista al azar" + +msgid "Play a random playlist" +msgstr "Reproducir una playlist al azar" + +msgid "Open a random label" +msgstr "Abrir un sello al azar" + +msgid "Start from current theme" +msgstr "Partir del tema actual" + +msgid "Copy the colors of the currently applied theme into the editor as a starting point." +msgstr "Copia los colores del tema aplicado actualmente al editor como punto de partida." + +msgid "Use current colors" +msgstr "Usar colores actuales" + +msgid "Dark theme" +msgstr "Tema oscuro" + +msgid "Set the overall light or dark polarity. Affects derived shades, borders and overlays." +msgstr "Define la polaridad general clara u oscura. Afecta los tonos derivados, los bordes y las superposiciones." + +msgid "SURFACES" +msgstr "SUPERFICIES" + +msgid "Background" +msgstr "Fondo" + +msgid "Surface" +msgstr "Superficie" + +msgid "Elevated" +msgstr "Elevado" + +msgid "TEXT" +msgstr "TEXTO" + +msgid "Text" +msgstr "Texto" + +msgid "Secondary text" +msgstr "Texto secundario" + +msgid "ACCENT" +msgstr "ACENTO" + +msgid "Accent" +msgstr "Acento" + +msgid "Danger" +msgstr "Peligro" + +msgid "Warning" +msgstr "Advertencia" + +msgid "Success" +msgstr "Éxito" + +msgid "OTHER" +msgstr "OTROS" + +msgid "Border" +msgstr "Borde" + +# Unavailable-track auto-skip (Tauri #467 parity) +msgid "No available tracks to play" +msgstr "No hay pistas disponibles para reproducir" +# Always-visible log uploader entry (settings sub-nav footer) +msgid "Share logs" +msgstr "Compartir registros" +# Guest local-only mode disclaimer line (login screen, #553) +msgid "QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library)." +msgstr "QBZ puede usarse como reproductor sin conexión sin una cuenta de Qobuz™ (sin acceso a la biblioteca de Qobuz™)." +# Renderer degradation ladder toast (generic: GL or software rung) +msgid "Renderer switched automatically — the previous renderer failed to start" +msgstr "El renderizador cambió automáticamente — el anterior no pudo iniciar" + +# QConnect startup behavior (Settings > Playback, ported from Tauri) +msgid "Auto-connect Qobuz Connect on startup" +msgstr "Conectar Qobuz Connect automáticamente al iniciar" +msgid "Choose whether Qobuz Connect activates automatically when QBZ launches." +msgstr "Elige si Qobuz Connect se activa automáticamente cuando QBZ inicia." +msgid "Remember state" +msgstr "Recordar estado" +msgid "On by default" +msgstr "Activado de forma predeterminada" +msgid "Off by default" +msgstr "Desactivado de forma predeterminada" + +msgid "QBZ recovered from repeated startup crashes — some restored state was reset" +msgstr "QBZ se recuperó de fallos repetidos al iniciar — se restableció parte del estado guardado" + +# QConnect device name (Settings > Playback, ported from Tauri) +msgid "Qobuz Connect device name" +msgstr "Nombre del dispositivo en Qobuz Connect" + +msgid "The name other Qobuz Connect apps see for this device. Applies on the next connection." +msgstr "El nombre que otras apps de Qobuz Connect ven para este dispositivo. Se aplica en la próxima conexión." + +# Quality downgrade tooltip (#590 follow-up) +msgid "Source: {}" +msgstr "Fuente: {}" + +msgid "Output: {}" +msgstr "Salida: {}" + +# Local Library header Plex sync button (#573) +msgid "Sync Plex library" +msgstr "Sincronizar biblioteca de Plex" + +# Library Albums sort options (#566) +msgid "Recently added" +msgstr "Recién agregados" + +msgid "First added" +msgstr "Primeros agregados" + +msgid "Random" +msgstr "Aleatorio" + + +# Offline cache clear — communicate it does not remove downloads (#606) +msgid "Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above." +msgstr "Libera datos en caché. Tus álbumes descargados se conservan — elimínalos desde el administrador offline de arriba." + +msgid "Couldn't clear the cache" +msgstr "No se pudo limpiar el caché" + + +# Task 18 — Settings > Developer > Export settings modal (daemon 04 §4.2) +msgid "SETTINGS PORTABILITY" +msgstr "PORTABILIDAD DE AJUSTES" + +msgid "Export settings…" +msgstr "Exportar ajustes…" + +msgid "Save a portable bundle of your settings to move to another machine or the qbzd daemon." +msgstr "Guarda un paquete portátil de tus ajustes para llevarlos a otra máquina o al daemon qbzd." + +msgid "Export…" +msgstr "Exportar…" + +msgid "Export settings" +msgstr "Exportar ajustes" + +msgid "Save your settings as a bundle to import on the qbzd daemon or another machine." +msgstr "Guarda tus ajustes como un paquete para importarlos en el daemon qbzd o en otra máquina." + +msgid "Include Qobuz login and integration tokens" +msgstr "Incluir el inicio de sesión de Qobuz y los tokens de integraciones" + +msgid "Anyone with the file can use your accounts. It is saved readable only by you; delete it after importing." +msgstr "Cualquiera que tenga el archivo puede usar tus cuentas. Se guarda de modo que solo tú puedas leerlo; elimínalo después de importarlo." + +msgid "Export" +msgstr "Exportar" + +msgid "Bundle saved. On the daemon box:" +msgstr "Paquete guardado. En la máquina del daemon:" + +msgid "Could not export settings" +msgstr "No se pudieron exportar los ajustes" + + +# Share menu Song.link/Album.link toasts (#514) +msgid "Fetching Song.link..." +msgstr "Obteniendo Song.link..." + +msgid "Fetching Album.link..." +msgstr "Obteniendo Album.link..." + +msgid "Link copied" +msgstr "Enlace copiado" + +msgid "Failed to copy link" +msgstr "No se pudo copiar el enlace" + +# Dynamic app background (Appearance settings) +msgid "Ambient" +msgstr "Ambiente" + +msgid "Blurred art" +msgstr "Portada difuminada" + +msgid "Dynamic background" +msgstr "Fondo dinámico" + +msgid "Animated album-art background behind the whole app. High resource use — GPU accelerated." +msgstr "Fondo animado con la portada del álbum detrás de toda la app. Alto uso de recursos — acelerado por GPU." + +# Preferred GPU (Renderer settings) +msgid "Preferred GPU" +msgstr "GPU preferida" + +msgid "Which GPU renders the app. Only the GPUs actually present are listed. Choosing one other than the default also switches the renderer to Vulkan — the only backend Qt can select a GPU on (requires restart)" +msgstr "Qué GPU dibuja la app. Sólo se listan las GPUs que de verdad existen. Elegir una distinta a la predeterminada también cambia el renderizador a Vulkan — el único backend en el que Qt puede seleccionar GPU (requiere reiniciar)" + +msgid "Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)" +msgstr "Qué GPU renderiza la app. En una laptop híbrida, elegir la GPU dedicada mueve la carga del fondo dinámico fuera de la integrada (más fresca, pero con mayor consumo). Se recomienda Auto (requiere reinicio)" + +msgid "discrete" +msgstr "dedicada" + +msgid "integrated" +msgstr "integrada" + +msgid "Preferred GPU changed — restart QBZ to apply" +msgstr "GPU preferida cambiada — reinicia QBZ para aplicar" + +msgid "Selected GPU is unavailable in this display session — using Auto" +msgstr "La GPU seleccionada no está disponible en esta sesión de pantalla — se usará Auto" + +msgid "Renderer changed — restart QBZ to apply" +msgstr "Cambiaste el renderizador — reinicia QBZ para aplicarlo" + +msgid "Search your library" +msgstr "Buscar en tu biblioteca" + +msgid "From catalog" +msgstr "Del catálogo" + +msgid "In library" +msgstr "En la biblioteca" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Agregar a favoritos" + +msgid "Remove from favorites" +msgstr "Quitar de favoritos" + +msgid "Start radio" +msgstr "Iniciar radio" + +msgid "Booklet" +msgstr "Libreto" + +msgid "Add to Mixtape" +msgstr "Agregar a Mixtape" + +msgid "Album info" +msgstr "Información del álbum" + +msgid "More options" +msgstr "Más opciones" + +msgid "Follow artist" +msgstr "Seguir artista" + +msgid "Unfollow" +msgstr "Dejar de seguir" + +msgid "Artist network" +msgstr "Red del artista" + +msgid "Upload to Qobuz" +msgstr "Subir a Qobuz" + +msgid "Select tracks" +msgstr "Seleccionar pistas" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz está rechazando solicitudes temporalmente; esperando un momento. Intenta de nuevo en breve." + + +msgid "Flatpak" +msgstr "Flatpak" + +msgid "Snap" +msgstr "Snap" + +msgid "FLATPAK SANDBOX" +msgstr "SANDBOX DE FLATPAK" + +msgid "SNAP SANDBOX" +msgstr "SANDBOX DE SNAP" + +msgid "QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates." +msgstr "QBZ se está ejecutando dentro de un sandbox de Flatpak. Algunas funciones necesitan permisos que se otorgan una sola vez: ejecuta los comandos en una terminal y reinicia QBZ. Los permisos se conservan entre actualizaciones." + +msgid "QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ." +msgstr "QBZ se está ejecutando dentro de un sandbox de Snap. Algunas interfaces de audio necesitan conectarse una sola vez: ejecuta estos comandos en una terminal y reinicia QBZ." + +msgid "Exclusive DAC access (bit-perfect)" +msgstr "Acceso exclusivo al DAC (bit-perfect)" + +msgid "Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected." +msgstr "Recomendado para el modo exclusivo: permite a QBZ pedirle a PipeWire que entregue el DAC limpiamente (reserva de dispositivo por D-Bus). Sin esto, PipeWire retiene el DAC y otras aplicaciones siguen mezclando a través de él incluso con ALSA + hw + Exclusivo seleccionado." + +msgid "Music on NAS or external drives" +msgstr "Música en NAS o discos externos" + +msgid "Grants access to a folder outside the sandbox. Repeat the command for each folder you need." +msgstr "Da acceso a una carpeta fuera del sandbox. Repite el comando por cada carpeta que necesites." + +msgid "Required audio connections" +msgstr "Conexiones de audio requeridas" + +msgid "Optional — external drives & NAS" +msgstr "Opcional — discos externos y NAS" + +msgid "Copy" +msgstr "Copiar" + +msgid "Cut" +msgstr "Cortar" + +msgid "Paste" +msgstr "Pegar" + +msgid "Copied!" +msgstr "¡Copiado!" + +msgid "Limited by your streaming quality setting" +msgstr "Limitado por tu ajuste de calidad de streaming" + +msgid "Qobuz has no higher quality for this track" +msgstr "Qobuz no ofrece una calidad superior para esta pista" + +msgid "offline copy — cap not applied" +msgstr "copia offline — límite no aplicado" + +msgid "cached copy — cap not applied" +msgstr "copia en caché — límite no aplicado" + +msgid "Limited by your output device's limit" +msgstr "Limitado por el límite de tu dispositivo de salida" + +msgid "Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting." +msgstr "Limita la calidad de streaming solicitada al límite de tu dispositivo de salida. Aplica solo a la reproducción local, nunca a la transmisión." + +msgid "Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly." +msgstr "No se pudieron leer los límites exactos de tu dispositivo en este sistema, así que se asume un conjunto común. El límite aplica de todos modos, pero puede no coincidir exactamente con tu hardware." + +msgid "Detected device limit" +msgstr "Límite detectado del dispositivo" + +msgid "Quality for this device" +msgstr "Calidad para este dispositivo" + +msgid "Follow app setting ({})" +msgstr "Seguir el ajuste de la app ({})" + +msgid "Hi-Res — 24-bit, up to 96 kHz" +msgstr "Hi-Res — 24-bit, hasta 96 kHz" + +msgid "CD — 16-bit / 44.1 kHz" +msgstr "CD — 16-bit / 44.1 kHz" + +msgid "MP3 320" +msgstr "MP3 320" + +msgid "Qobuz only offers these tiers — there is no 48 kHz option. The step below Hi-Res is CD 44.1 kHz." +msgstr "Qobuz solo ofrece estos niveles — no existe una opción de 48 kHz. El paso debajo de Hi-Res es CD 44.1 kHz." + +msgid "Limited by the quality cap for {}" +msgstr "Limitado por el límite de calidad para {}" + +msgid "capped for this device" +msgstr "limitado para este dispositivo" + +msgid "Albums by folder" +msgstr "Álbumes por carpeta" + +msgid "Albums by metadata" +msgstr "Álbumes por metadatos" + +msgid "ALBUMS VIEW" +msgstr "VISTA DE ÁLBUMES" + +msgid "Album grouping" +msgstr "Agrupación de álbumes" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Carpetas: una tarjeta por carpeta de álbum — ideal para compilaciones y box sets. Metadatos: separar por etiquetas de álbum + artista." + +msgid "+{} more artists" +msgstr "+{} artistas más" + +msgid "Show less" +msgstr "Ver menos" +# Most Played Albums rail title +msgid "Most Played Albums" +msgstr "Álbumes más escuchados" + +# Most Played Albums View-all — per-card play count +msgid "{} plays" +msgstr "{} reproducciones" + +# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast +msgid "Show lyrics translation" +msgstr "Mostrar traducción de la letra" + +msgid "Hide lyrics translation" +msgstr "Ocultar traducción de la letra" + +msgid "Translation language" +msgstr "Idioma de traducción" + +msgid "Auto (account language)" +msgstr "Automático (idioma de la cuenta)" + +msgid "Translation is not available right now. Please try again later." +msgstr "La traducción no está disponible ahora mismo. Inténtalo de nuevo más tarde." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Los artistas descartados solo desaparecen de tus Recomendaciones — siguen apareciendo en la búsqueda y en el resto de la app." + +msgid "No dismissed artists" +msgstr "No hay artistas descartados" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Para dejar de ver un artista en Recomendaciones, abre el menú de su tarjeta y elige No me interesa." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} ya no aparecerá en Recomendaciones" + +msgid "Artist dismissed from Recommendations" +msgstr "Artista descartado de Recomendaciones" + +msgid "{} restored to Recommendations" +msgstr "{} restaurado en Recomendaciones" + +msgid "LIBRARY › ALL" +msgstr "BIBLIOTECA › TODOS" + +msgid "Show local files & Plex" +msgstr "Mostrar archivos locales y Plex" + +msgid "Include your favorited local files and Plex items in the Library All feed." +msgstr "Incluye tus archivos locales y elementos de Plex favoritos en el feed Todos de la biblioteca." + +msgid "Play indicator animation" +msgstr "Animación del indicador de reproducción" + +msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU." +msgstr "Anima la fila en reproducción con barras de ecualizador. Apagado (default) muestra un ícono de pausa estático con una marca de acento en el borde — más ligero en CPU." + +msgid "Invert swipe navigation direction" +msgstr "Invertir la dirección del gesto de navegación" + +msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward." +msgstr "Invierte el deslizamiento con dos dedos del trackpad: izquierda va atrás, derecha va adelante." + +msgid "Local items in Library All" +msgstr "Elementos locales en Biblioteca Todo" + +msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track." +msgstr "Solo favoritos: lo que marcaste con corazón. Toda la biblioteca local: cada álbum, artista y track de carpetas y Plex." + +msgid "Favorited items only" +msgstr "Solo favoritos" + +msgid "Entire local library" +msgstr "Toda la biblioteca local" + +msgid "Select multiple albums" +msgstr "Seleccionar varios álbumes" + +msgid "Play later" +msgstr "Reproducir después" + +msgid "Added to play later" +msgstr "Agregado para después" + +msgid "Next in queue" +msgstr "Siguiente en la cola" + +msgid "Next up" +msgstr "A continuación" + +msgid "Removed {} tracks from favorites" +msgstr "Se quitaron {} tracks de favoritos" + +msgid "Removing {} tracks from favorites…" +msgstr "Quitando {} tracks de favoritos…" + +msgid "Playlist imported" +msgstr "Lista de reproducción importada" + +msgid "Playlist import failed" +msgstr "No se pudo importar la lista de reproducción" + +msgid "Not logged in to Qobuz" +msgstr "No has iniciado sesión en Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Eliminar carpeta" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "¿Eliminar la carpeta “{}”? Sus playlists vuelven a la raíz." + +msgid "Choose a folder image" +msgstr "Elige una imagen para la carpeta" + +msgid "Images" +msgstr "Imágenes" + +msgid "Failed to create folder" +msgstr "No se pudo crear la carpeta" + +msgid "Failed to save folder" +msgstr "No se pudo guardar la carpeta" + +msgid "Failed to delete folder" +msgstr "No se pudo eliminar la carpeta" + +msgid "Failed to rename playlist" +msgstr "No se pudo renombrar la playlist" + +msgid "Failed to delete playlist" +msgstr "No se pudo eliminar la playlist" + +msgid "Couldn't create the playlist" +msgstr "No se pudo crear la playlist" + +# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard, +# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. +msgid "Open playlist" +msgstr "Abrir playlist" + +# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut +# action labels + category names for the cheatsheet and the customize editor. +# Slint's Rust-side labels were never extracted (the port inherits the gap); +# this block fixes it for both frontends. en is EXCLUDED on purpose +# (sparse-override convention, qbz-i18n/src/lib.rs:120-125). + +msgid "Play / Pause" +msgstr "Reproducir / Pausa" + +msgid "Go Back" +msgstr "Atrás" + +msgid "Go Forward" +msgstr "Adelante" + +msgid "Toggle Sidebar" +msgstr "Mostrar/ocultar barra lateral" + +msgid "Immersive Mode" +msgstr "Modo inmersivo" + +msgid "Close / Dismiss" +msgstr "Cerrar / Descartar" + +msgid "Show Shortcuts" +msgstr "Mostrar atajos" + +msgid "Toggle Mini Player" +msgstr "Mostrar/ocultar mini reproductor" + +msgid "Seek Forward (5s)" +msgstr "Avanzar 5 s" + +msgid "Seek Back (5s)" +msgstr "Retroceder 5 s" + +msgid "Seek Forward (10s)" +msgstr "Avanzar 10 s" + +msgid "Seek Back (10s)" +msgstr "Retroceder 10 s" + +msgid "Micro View" +msgstr "Vista micro" + +msgid "Compact View" +msgstr "Vista compacta" + +msgid "Artwork View" +msgstr "Vista de carátula" + +msgid "Listen list" +msgstr "Lista de escucha" + +msgid "Lyrics View" +msgstr "Vista de letra" + +msgid "Navigation" +msgstr "Navegación" + +msgid "Interface" +msgstr "Interfaz" + +msgid "Mini Player" +msgstr "Mini reproductor" + + +# Kiosk shell (2026-08-02 kiosk-port contract) + +msgid "Cover" +msgstr "Portada" + +msgid "Radio" +msgstr "Radio" + +msgid "Up Next" +msgstr "A continuación" + +# Kiosk <-> Desktop profile toggle (view-mode menu) + +msgid "Kiosk mode" +msgstr "Modo kiosco" + +msgid "Desktop mode" +msgstr "Modo escritorio" + +# AlbumView cover menu / lightbox (Qt port, 2026-08-10) + +msgid "View cover" +msgstr "Ver carátula" + +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Ver imagen" + +msgid "Could not load the image" +msgstr "No se pudo cargar la imagen" + +# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's +# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen +# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter", +# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On", +# "Bands & Projects", "Load more", "Loading…", "Artist Scene", +# "No artists found for this scene.", "No album appearances found." and +# "Select an artist to see their albums." (period-only delta) already exist. +# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7, +# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them +# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every +# call site substitutes explicitly, e.g. .replace("{artist}", name). + +msgid "Based on {artist} · {genres}" +msgstr "Basado en {artist} · {genres}" + +msgid "Searching MusicBrainz for {genre} artists..." +msgstr "Buscando artistas de {genre} en MusicBrainz..." + +msgid "Verifying availability on Qobuz..." +msgstr "Verificando disponibilidad en Qobuz..." + +msgid "Preparing results..." +msgstr "Preparando resultados..." + +msgid "No matching artists from this scene are currently available on Qobuz." +msgstr "Ningún artista de esta escena está disponible en Qobuz por el momento." + +msgid "Loading albums..." +msgstr "Cargando álbumes..." + +msgid "Failed to load albums" +msgstr "No se pudieron cargar los álbumes" + +msgid "Failed to load appearances" +msgstr "No se pudieron cargar las participaciones" + +msgid "Looking up {name}..." +msgstr "Buscando a {name}..." + +msgid "Failed to look up musician" +msgstr "No se pudo buscar al músico" + +msgid "Browse view" +msgstr "Vista de exploración" + +msgid "Grid view" +msgstr "Vista de cuadrícula" + +msgid "Alphabetical (A-Z)" +msgstr "Alfabético (A-Z)" + +msgid "This musician appears in album credits but has limited catalog information available in Qobuz." +msgstr "Este músico aparece en los créditos del álbum, pero hay poca información de catálogo disponible en Qobuz." + +msgid "Unable to verify this musician's catalog. The information shown is based on album credits." +msgstr "No se pudo verificar el catálogo de este músico. La información mostrada proviene de los créditos del álbum." + +msgid "This musician appears in album credits." +msgstr "Este músico aparece en los créditos del álbum." + +msgid "Discography" +msgstr "Discografía" + +msgid "Live Albums" +msgstr "Álbumes en vivo" + +msgid "Select an artist to see their albums" +msgstr "Selecciona un artista para ver sus álbumes" + +msgid "{} / {} artists" +msgstr "{} / {} artistas" + +# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17 +# §8-6). "Unavailable" and "This track is no longer available" already +# exist in this file and are REUSED by the render and the reactive skip toast +# — nothing below twins them. +# +# The skipped-tracks toast is a plural PAIR, per the convention already set by +# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build +# drops N rows at once, and a singular-only string reads wrong in every locale +# with plural rules. Russian carries all three forms. +# +# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled +# state (contract §5.3/F5): the track left the catalogue but the user owns a +# downloaded copy, so it still plays. It cannot fold into "This track is no +# longer available" — that sentence would be a lie on a row playing right now. +# +# Em dash is U+2014 with spaces on both sides, as everywhere else in this file. +msgid "Find available version" +msgstr "Buscar versión disponible" + +msgid "Replace unavailable track" +msgstr "Reemplazar pista no disponible" + +msgid "No available version found" +msgstr "No se encontró ninguna versión disponible" + +msgid "Track replaced" +msgstr "Pista reemplazada" + +msgid "Could not replace the track" +msgstr "No se pudo reemplazar la pista" + +msgid "The replacement was added at the end of the playlist" +msgstr "El reemplazo se agregó al final de la lista de reproducción" + +msgid "{} track skipped — no longer available" +msgid_plural "{} tracks skipped — no longer available" +msgstr[0] "Se omitió {} pista — ya no está disponible" +msgstr[1] "Se omitieron {} pistas — ya no están disponibles" + +msgid "No longer on Qobuz — playing your downloaded copy" +msgstr "Ya no está en Qobuz — reproduciendo tu copia descargada" + +msgid "Exact match" +msgstr "Coincidencia exacta" + +msgid "Low confidence" +msgstr "Coincidencia dudosa" + +msgid "Replace" +msgstr "Reemplazar" + +msgid "Replacing..." +msgstr "Reemplazando..." + +msgid "The replacement takes the unavailable track's place in the playlist." +msgstr "El reemplazo ocupa el lugar de la pista no disponible en la lista." + +# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19) +msgid "Items per carousel" +msgstr "Elementos por carrusel" + +# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19) +msgid "Recently Played Playlists" +msgstr "Playlists reproducidas recientemente" + +msgid "Playlists you play will appear here." +msgstr "Las playlists que reproduzcas aparecerán aquí." + +# Applied-filters tooltip (2026-08-19) +msgid "+{} more" +msgstr "+{} más" + +msgid "+{} more filters" +msgstr "+{} filtros más" + +msgid "Applied filters" +msgstr "Filtros aplicados" + +# Applied-filters tooltip: group labels (2026-08-19) +msgid "Category" +msgstr "Categoría" + +# Applied-filters tooltip: Library source switches (2026-08-19) +msgid "Favorites" +msgstr "Favoritos" + +# Discover configurator: Refresh content (2026-08-19) +msgid "Refresh content" +msgstr "Actualizar contenido" + +# Appearance: click-on-section opens the first tab (2026-08-19) +msgid "Click on menu item navigates to first tab" +msgstr "Hacer clic en una sección abre su primera pestaña" + +msgid "Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu" +msgstr "Al hacer clic en una sección de la barra lateral o del título, se abre en su primera pestaña — Discover en Home, Library en All — en vez de sólo mostrar su menú" + +# Discover configurator: per-rail default item count (2026-08-19) +msgid "Default ({})" +msgstr "Predeterminado ({})" + +# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources + +msgid "Streaming URL" +msgstr "URL de streaming" + +msgid "Playlist file" +msgstr "Archivo de lista" + +msgid "JSON file" +msgstr "Archivo JSON" + +msgid "ListenBrainz" +msgstr "ListenBrainz" + +msgid "Last.fm" +msgstr "Last.fm" + +msgid "Choose file…" +msgstr "Elegir archivo…" + +msgid "No file selected" +msgstr "Ningún archivo seleccionado" + +msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog." +msgstr "Solo se lee la lista de pistas: los archivos de audio referenciados nunca se abren, se copian ni se agregan a tu biblioteca local. Cada entrada se busca en el catálogo de Qobuz." + +msgid "JSON is read best-effort — check the track count below before importing." +msgstr "El JSON se lee lo mejor posible: revisa el número de pistas antes de importar." + +msgid "Could not read the file." +msgstr "No se pudo leer el archivo." + +msgid "No tracks found in the file." +msgstr "No se encontraron pistas en el archivo." + +msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON." +msgstr "Formato de archivo no reconocido: usa XSPF, PLS, M3U/M3U8 o JSON." + +msgid "This looks like a live-stream manifest, not a playlist." +msgstr "Esto parece un manifiesto de transmisión en vivo, no una lista." + +msgid "That file is too large to import." +msgstr "Ese archivo es demasiado grande para importarlo." + +msgid "Could not recognize a track list in this JSON." +msgstr "No se pudo reconocer una lista de pistas en este JSON." + +msgid "Username or profile URL" +msgstr "Usuario o URL de perfil" + +msgid "Username or playlist URL" +msgstr "Usuario o URL de la lista" + +msgid "Play your library" +msgstr "Reproducir tu biblioteca" + +msgid "Play your mix" +msgstr "Reproducir tu mezcla" + +msgid "Play your recommendations" +msgstr "Reproducir tus recomendaciones" + +msgid "Station" +msgstr "Estación" + +msgid "No playlists found for {}." +msgstr "No se encontraron listas para {}." + +msgid "Enter a public username." +msgstr "Escribe un usuario público." + +msgid "Duplicates: {}" +msgstr "Duplicados: {}" + +msgid "Media" +msgstr "Medio" + +msgid "Open audio CD" +msgstr "Abrir CD de audio" + +msgid "Audio CD" +msgstr "CD de audio" + +msgid "No optical drive found." +msgstr "No se encontró ninguna unidad óptica." + +msgid "No disc in the drive." +msgstr "No hay disco en la unidad." + +msgid "That disc has no audio tracks." +msgstr "Ese disco no tiene pistas de audio." + +msgid "Could not read the disc." +msgstr "No se pudo leer el disco." + +msgid "Track {}" +msgstr "Pista {}" + +msgid "Open SACD image…" +msgstr "Abrir imagen SACD…" + +msgid "Choose a disc image" +msgstr "Elige una imagen de disco" + +msgid "That image has no stereo audio area." +msgstr "Esa imagen no tiene área de audio estéreo." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Este disco está comprimido con DST y QBZ todavía no puede reproducirlo." + +msgid "That file is not a disc image." +msgstr "Ese archivo no es una imagen de disco." + +msgid "That file is not a SACD image." +msgstr "Ese archivo no es una imagen SACD." + +msgid "That SACD image is damaged or unsupported." +msgstr "Esa imagen SACD está dañada o no es compatible." + +msgid "SACD" +msgstr "SACD" + +msgid "Library Explorer" +msgstr "Explorador de biblioteca" + +msgid "Genres" +msgstr "Géneros" + +msgid "All genres" +msgstr "Todos los géneros" + +msgid "All albums" +msgstr "Todos los álbumes" + +msgid "No albums match these filters" +msgstr "Ningún álbum coincide con estos filtros" + +msgid "Try a different genre, artist, album, source or quality." +msgstr "Prueba con otro género, artista, álbum, fuente o calidad." + +msgid "Favorites only" +msgstr "Solo favoritos" + +msgid "Where should the ripped album go?" +msgstr "¿Dónde guardo el álbum ripeado?" + +msgid "Ripping" +msgstr "Ripeando" + +msgid "Nothing to rip." +msgstr "No hay nada que ripear." + +msgid "Ripped {} track to your library" +msgid_plural "Ripped {} tracks to your library" +msgstr[0] "Se ripeó {} pista a tu biblioteca" +msgstr[1] "Se ripearon {} pistas a tu biblioteca" + +# Discs: rip wizard, metadata lookup and the disc tooltips (2026-08-21) + +msgid "Play a folder, CD or disc image without adding it to your library" +msgstr "Reproduce una carpeta, CD o imagen de disco sin agregarla a tu biblioteca" + +msgid "Reading the disc…" +msgstr "Leyendo el disco…" + +msgid "Re-scan your folders and media servers for new music" +msgstr "Vuelve a revisar tus carpetas y servidores para buscar música nueva" + +msgid "Manage the folders your library is built from" +msgstr "Administra las carpetas con las que se arma tu biblioteca" + +msgid "Play the whole disc from the first track" +msgstr "Reproduce el disco completo desde la primera pista" + +msgid "Play it in a shuffled order" +msgstr "Reprodúcelo en orden aleatorio" + +msgid "Look this disc up and correct its details" +msgstr "Busca este disco y corrige sus datos" + +msgid "Copy this CD to your computer as FLAC files" +msgstr "Copia este CD a tu computadora como archivos FLAC" + +msgid "Close this disc and go back to your library" +msgstr "Cierra este disco y regresa a tu biblioteca" + +msgid "Correct disc details" +msgstr "Corregir datos del disco" + +msgid "SACD image" +msgstr "Imagen SACD" + +msgid "Close without changing anything" +msgstr "Cierra sin cambiar nada" + +msgid "Community database. Knows discs by their table of contents." +msgstr "Base de datos comunitaria. Reconoce discos por su tabla de contenido." + +msgid "Collector database. Stronger on pressings, editions and catalogue numbers." +msgstr "Base de datos de coleccionistas. Es mejor en prensados, ediciones y números de catálogo." + +msgid "Search this provider" +msgstr "Buscar en este proveedor" + +msgid "The provider is rate-limiting right now. Try again in a moment." +msgstr "El proveedor está limitando las consultas ahorita. Vuelve a intentar en un momento." + +msgid "No releases matched. Try fewer words, or the other provider." +msgstr "No hubo coincidencias. Prueba con menos palabras, o con el otro proveedor." + +msgid "Search for the release this disc actually is." +msgstr "Busca la edición que realmente es este disco." + +msgid "Remove my correction" +msgstr "Quitar mi corrección" + +msgid "Forget the saved details for this disc and look it up fresh next time" +msgstr "Olvida los datos guardados de este disco y vuelve a buscarlos la próxima vez" + +msgid "Applying..." +msgstr "Aplicando..." + +msgid "Use these details" +msgstr "Usar estos datos" + +msgid "Rename this disc and remember it for next time" +msgstr "Renombra este disco y recuérdalo para la próxima" + +msgid "Rip this CD" +msgstr "Extraer este CD" + +msgid "What will happen" +msgstr "Qué va a pasar" + +msgid "Track names" +msgstr "Nombres de las pistas" + +msgid "Your library" +msgstr "Tu biblioteca" + +msgid "Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags." +msgstr "Cada pista del disco se lee y se escribe como archivo FLAC: una carpeta por álbum, un archivo por pista, con etiquetas." + +msgid "Format: FLAC (lossless) — the only format QBZ writes" +msgstr "Formato: FLAC (sin pérdida) — el único formato que QBZ escribe" + +msgid "Where the album goes" +msgstr "Dónde va el álbum" + +msgid "Choose folder…" +msgstr "Elegir carpeta…" + +msgid "Change…" +msgstr "Cambiar…" + +msgid "Pick the folder the album folder will be created in" +msgstr "Elige la carpeta donde se va a crear la carpeta del álbum" + +msgid "Nothing chosen yet" +msgstr "Todavía no eliges nada" + +msgid "This folder is already part of your Local Library." +msgstr "Esta carpeta ya es parte de tu Biblioteca Local." + +msgid "This folder is outside your Local Library, so the album will not appear there on its own." +msgstr "Esta carpeta está fuera de tu Biblioteca Local, así que el álbum no va a aparecer ahí por su cuenta." + +msgid "Choose a destination first and this step will know what to offer." +msgstr "Primero elige un destino y este paso va a saber qué ofrecerte." + +msgid "Re-scan that folder when the rip finishes" +msgstr "Volver a revisar esa carpeta cuando termine la extracción" + +msgid "Only that folder, not your whole library." +msgstr "Nada más esa carpeta, no toda tu biblioteca." + +msgid "Do nothing" +msgstr "No hacer nada" + +msgid "The album appears the next time you scan." +msgstr "El álbum aparece la próxima vez que revises." + +msgid "Add this folder to Local Library and scan it" +msgstr "Agregar esta carpeta a la Biblioteca Local y revisarla" + +msgid "Future rips into the same folder will be picked up too." +msgstr "Las siguientes extracciones a la misma carpeta también se van a detectar." + +msgid "The files are written; the library will not know about them." +msgstr "Los archivos se escriben; la biblioteca no se va a enterar de ellos." + +msgid "A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool." +msgstr "Es un extractor sencillo, hecho para meter tus CDs a la biblioteca rapidito. Si necesitas verificación AccurateRip u opciones avanzadas, usa una herramienta dedicada." + +msgid "Close without ripping anything" +msgstr "Cierra sin extraer nada" + +msgid "Go back a step" +msgstr "Regresa un paso" + +msgid "Start ripping" +msgstr "Empezar la extracción" + +msgid "Go to the next step" +msgstr "Ir al siguiente paso" + +msgid "Read the disc and write the FLAC files" +msgstr "Lee el disco y escribe los archivos FLAC" + +msgid "Choose a destination folder first" +msgstr "Primero elige una carpeta de destino" + +msgid "No disc is open." +msgstr "No hay ningún disco abierto." + +msgid "Couldn't load that release." +msgstr "No se pudo cargar esa edición." + +msgid "Disc details updated" +msgstr "Datos del disco actualizados" + +msgid "Correction removed. Re-open the disc to look it up again." +msgstr "Se quitó la corrección. Vuelve a abrir el disco para buscarlo de nuevo." + +msgid "Only a physical CD can be ripped." +msgstr "Solo se puede extraer un CD físico." + +msgid "Choose where the album should go first." +msgstr "Primero elige a dónde va el álbum." + +msgid "Couldn't add that folder to the library" +msgstr "No se pudo agregar esa carpeta a la biblioteca" + +# Rip progress panel, cancel and partial rips (2026-08-21) + +msgid "Writing FLAC files to" +msgstr "Escribiendo archivos FLAC en" + +msgid "Hide this panel — the rip keeps going" +msgstr "Oculta este panel — la extracción sigue" + +msgid "Ripping — don't eject the disc until this finishes." +msgstr "Extrayendo — no saques el disco hasta que termine." + +msgid "Finished" +msgstr "Terminado" + +msgid "Files already written stay on disk. Delete them yourself if you don't want them." +msgstr "Los archivos ya escritos se quedan en el disco. Si no los quieres, bórralos tú." + +msgid "Stop ripping" +msgstr "Detener la extracción" + +msgid "Stopping…" +msgstr "Deteniendo…" + +msgid "Stop after the current track. Nothing is deleted." +msgstr "Detén después de la pista actual. No se borra nada." + +msgid "Rip stopped. The files already written are still there." +msgstr "Se detuvo la extracción. Los archivos ya escritos siguen ahí." + +msgid "See what the rip is doing, or stop it" +msgstr "Mira qué está haciendo la extracción, o deténla" + +msgid "Pick at least one track to rip." +msgstr "Elige al menos una pista para extraer." + +# Ephemeral pane: per-disc blocks and the track filter (2026-08-21) + +msgid "Disc {}" +msgstr "Disco {}" + +msgid "Filter tracks" +msgstr "Filtrar pistas" + +msgid "No track matches that." +msgstr "Ninguna pista coincide." + +msgid "NAVIGATION" +msgstr "NAVEGACIÓN" + +msgid "SEARCH" +msgstr "BÚSQUEDA" + +msgid "PLAYER & VISUALS" +msgstr "REPRODUCTOR Y VISUALES" + +msgid "WINDOW" +msgstr "VENTANA" + +msgid "SYSTEM INTEGRATION" +msgstr "INTEGRACIÓN CON EL SISTEMA" + +msgid "The typeface used across the app. System follows your operating system; the other choices are bundled with QBZ (requires restart)" +msgstr "La tipografía que usa toda la app. Sistema usa la fuente del sistema operativo; las demás opciones vienen incluidas con QBZ (requiere reiniciar)" + +msgid "Library folders" +msgstr "Carpetas de la biblioteca" + +msgid "{} folders" +msgstr "{} carpetas" + +msgid "No Qobuz account? You can still use QBZ as a player for your own music library" +msgstr "¿No tienes cuenta de Qobuz? Aun así puedes usar QBZ como reproductor de tu propia música" + +msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working." +msgstr "Estás usando QBZ sin cuenta de Qobuz. Tu biblioteca local y tus descargas siguen funcionando." + +msgid "Continuous playback" +msgstr "Reproducción continua" + +msgid "Open" +msgstr "Abrir" + +msgid "Folder view" +msgstr "Vista por carpetas" + +msgid "Open media" +msgstr "Abrir medios" + +msgid "Open folder…" +msgstr "Abrir carpeta…" + +# Orden de pestañas de la biblioteca local e inicio sin sesión (2026-08-24) +msgid "LIBRARY TABS" +msgstr "PESTAÑAS DE LA BIBLIOTECA" + +msgid "Tab order and default" +msgstr "Orden de pestañas y predeterminada" + +msgid "Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session." +msgstr "Elige el orden de las pestañas de la biblioteca local. La primera se abre de forma predeterminada, incluso cuando QBZ inicia sin una sesión de Qobuz." + +msgid "Local Library tabs" +msgstr "Pestañas de la biblioteca local" + +msgid "Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session." +msgstr "Reordena las pestañas de la biblioteca local. La primera se abre de forma predeterminada y se muestra al iniciar sin una sesión de Qobuz." + +msgid "Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu" +msgstr "Al hacer clic en una sección de la barra lateral o de título, se abre su primera pestaña —incluida la predeterminada elegida para la biblioteca local— en vez de mostrar solo su menú" + +# Posición de los filtros de género de la biblioteca local (2026-08-24) +msgid "GENRES VIEW" +msgstr "VISTA DE GÉNEROS" + +msgid "Genre filters position" +msgstr "Posición de los filtros de género" + +msgid "Place the chained genre filters above, beside or below the album results." +msgstr "Coloca los filtros de género encadenados encima, a un lado o debajo de los resultados de álbumes." + +msgid "Top" +msgstr "Arriba" + +msgid "Bottom" +msgstr "Abajo" + +msgid "Album metadata saved." +msgstr "Metadatos del álbum guardados." + +msgid "Album title is required." +msgstr "El título del álbum es obligatorio." + +msgid "Apply to form" +msgstr "Aplicar al formulario" + +msgid "CUE-based albums can only be edited with a sidecar." +msgstr "Los álbumes basados en CUE solo se pueden editar con un sidecar." + +msgid "CUE-based albums use sidecar metadata." +msgstr "Los álbumes basados en CUE usan metadatos sidecar." + +msgid "Canonical" +msgstr "Canónica" + +msgid "Close without saving" +msgstr "Cerrar sin guardar" + +msgid "Couldn't inspect album metadata." +msgstr "No se pudieron inspeccionar los metadatos del álbum." + +msgid "Couldn't load that metadata result." +msgstr "No se pudo cargar ese resultado de metadatos." + +msgid "Couldn't open the metadata source." +msgstr "No se pudo abrir la fuente de metadatos." + +msgid "Detected" +msgstr "Detectadas" + +msgid "Disc number" +msgstr "Número de disco" + +msgid "Editing the selected physical version" +msgstr "Editando la versión física seleccionada" + +msgid "Enter an album title or artist to search." +msgstr "Ingresa un título de álbum o artista para buscar." + +msgid "Every track needs a title." +msgstr "Cada pista necesita un título." + +msgid "File names are shown for reference and cannot be changed here." +msgstr "Los nombres de archivo se muestran como referencia y no se pueden cambiar aquí." + +msgid "Find metadata" +msgstr "Buscar metadatos" + +msgid "ID3 writing" +msgstr "Escritura ID3" + +msgid "Metadata editing is available for local file versions only." +msgstr "La edición de metadatos solo está disponible para versiones de archivos locales." + +msgid "Metadata search failed." +msgstr "Falló la búsqueda de metadatos." + +msgid "No local album version is selected." +msgstr "No hay una versión local del álbum seleccionada." + +msgid "None" +msgstr "Ninguna" + +msgid "One or more audio files are unavailable." +msgstr "Uno o más archivos de audio no están disponibles." + +msgid "Open source" +msgstr "Abrir fuente" + +msgid "QBZ will preflight every file, update its canonical tag layer, then read it back to verify the change. Keep a backup if these files cannot be replaced." +msgstr "QBZ comprobará cada archivo, actualizará su capa de etiquetas canónica y volverá a leerla para verificar el cambio. Conserva una copia de seguridad si estos archivos no se pueden reemplazar." + +msgid "Save changes to" +msgstr "Guardar cambios en" + +msgid "Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled." +msgstr "Algunos archivos contienen capas de etiquetas en conflicto. La capa canónica prevalece salvo que se active la sincronización." + +msgid "Synchronize compatible tag layers" +msgstr "Sincronizar capas de etiquetas compatibles" + +msgid "Tag layers" +msgstr "Capas de etiquetas" + +msgid "The canonical tag is not writable for every file." +msgstr "La etiqueta canónica no se puede escribir en todos los archivos." + +msgid "The metadata editor is no longer open." +msgstr "El editor de metadatos ya no está abierto." + +msgid "The metadata form could not be read." +msgstr "No se pudo leer el formulario de metadatos." + +msgid "The track list changed; reopen the editor." +msgstr "La lista de pistas cambió; vuelve a abrir el editor." + +msgid "The track list contains a duplicate row." +msgstr "La lista de pistas contiene una fila duplicada." + +msgid "Track number" +msgstr "Número de pista" + +msgid "Unknown metadata persistence mode." +msgstr "Modo de persistencia de metadatos desconocido." + +msgid "Write and verify" +msgstr "Escribir y verificar" + +msgid "Write metadata into audio files?" +msgstr "¿Escribir los metadatos en los archivos de audio?" + +msgid "Year must be a valid number." +msgstr "El año debe ser un número válido." + +msgid "{} must be a positive number." +msgstr "{} debe ser un número positivo." + +msgid "The ephemeral session changed; reopen the metadata editor." +msgstr "La sesión efímera cambió; vuelve a abrir el editor de metadatos." + +msgid "Album artists" +msgstr "Artistas del álbum" + +msgid "Album artists (ordered; separate with semicolons)" +msgstr "Artistas del álbum (en orden; sepáralos con punto y coma)" + +msgid "Artist credit" +msgstr "Crédito de artista" + +msgid "Artists (ordered; semicolon separated)" +msgstr "Artistas (en orden; separados con punto y coma)" + +msgid "Artwork is larger than 25 MiB." +msgstr "La portada supera los 25 MiB." + +msgid "Artwork search failed." +msgstr "Falló la búsqueda de portadas." + +msgid "Artwork source did not provide a secure URL." +msgstr "La fuente de la portada no proporcionó una URL segura." + +msgid "Choose album artwork" +msgstr "Elegir portada del álbum" + +msgid "Choose another file" +msgstr "Elegir otro archivo" + +msgid "Choose file" +msgstr "Elegir archivo" + +msgid "Compilation / Various Artists" +msgstr "Compilación / Varios artistas" + +msgid "Composers" +msgstr "Compositores" + +msgid "Couldn't download that artwork." +msgstr "No se pudo descargar esa portada." + +msgid "Couldn't stage artwork" +msgstr "No se pudo preparar la portada" + +msgid "Current artwork" +msgstr "Portada actual" + +msgid "Discogs release ID" +msgstr "ID de edición de Discogs" + +msgid "Enter an album title to find artwork." +msgstr "Escribe el título de un álbum para buscar portadas." + +msgid "File" +msgstr "Archivo" + +msgid "Find artwork" +msgstr "Buscar portada" + +msgid "Local file" +msgstr "Archivo local" + +msgid "MusicBrainz album artist IDs" +msgstr "IDs de artistas del álbum en MusicBrainz" + +msgid "MusicBrainz artist IDs" +msgstr "IDs de artistas en MusicBrainz" + +msgid "MusicBrainz recording ID" +msgstr "ID de grabación en MusicBrainz" + +msgid "MusicBrainz release ID" +msgstr "ID de edición en MusicBrainz" + +msgid "MusicBrainz release-group ID" +msgstr "ID de grupo de ediciones en MusicBrainz" + +msgid "MusicBrainz track ID" +msgstr "ID de pista en MusicBrainz" + +msgid "Performers" +msgstr "Intérpretes" + +msgid "Revert selection" +msgstr "Revertir selección" + +msgid "Selected track details" +msgstr "Detalles de la pista seleccionada" + +msgid "That artwork result is no longer available." +msgstr "Ese resultado de portada ya no está disponible." + +msgid "The selected artwork changed; choose it again." +msgstr "La portada seleccionada cambió; elígela de nuevo." + +msgid "The selected artwork has unsupported dimensions." +msgstr "La portada seleccionada tiene dimensiones no compatibles." + +msgid "The selected file is not a supported image." +msgstr "El archivo seleccionado no es una imagen compatible." + +msgid "Goniometer" +msgstr "Goniómetro" + +msgid "Oscilloscope" +msgstr "Osciloscopio" + +msgid "Track waveform" +msgstr "Forma de onda de la pista" + +msgid "Show the full-track waveform in the player seek bar." +msgstr "Muestra la forma de onda completa de la pista en la barra de búsqueda del reproductor." + +msgid "Compact header" +msgstr "Encabezado compacto" + +msgid "Show less data in album headers." +msgstr "Muestra menos datos en los encabezados de los álbumes." + +msgid "Show full album header" +msgstr "Mostrar el encabezado completo del álbum" + +msgid "Show compact album header" +msgstr "Mostrar el encabezado compacto del álbum" + +msgid "Immediate scrobbling" +msgstr "Scrobbling inmediato" + +msgid "Send scrobbles immediately while manual offline mode is enabled." +msgstr "Envía los scrobbles de inmediato cuando el modo offline manual está activado." + +msgid "Accumulated scrobbling" +msgstr "Scrobbling acumulado" + +msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old." +msgstr "Guarda los scrobbles cuando no haya red y los envía al volver la conexión. Last.fm acepta scrobbles de hasta 2 semanas." + +msgid "Scrobble while logged out" +msgstr "Scrobbling sin sesión" + +msgid "Continue sending plays to connected scrobblers without a Qobuz session." +msgstr "Continúa enviando reproducciones a los servicios conectados aunque no haya una sesión de Qobuz." + +msgid "Focus on playing" +msgstr "Seguir la reproducción" + +msgid "Go to top" +msgstr "Ir arriba" + +msgid "Quick view" +msgstr "Vista rápida" + +msgid "Could not load album" +msgstr "No se pudo cargar el álbum" + +msgid "This album has no tracks." +msgstr "Este álbum no tiene pistas." + +msgid "Reactive Rings" +msgstr "Anillos reactivos" + +msgid "Windows — yes, but read this first" +msgstr "Windows — sí, pero lee esto primero" + +msgid "Don't show this again" +msgstr "No volver a mostrar" + +msgid "I understand" +msgstr "Entiendo" +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker + +msgid "Volume Up" +msgstr "Subir volumen" + +msgid "Volume Down" +msgstr "Bajar volumen" + +msgid "Mute / Unmute" +msgstr "Silenciar / activar sonido" + +msgid "Toggle Shuffle" +msgstr "Activar/desactivar reproducción aleatoria" + +msgid "Cycle Repeat" +msgstr "Cambiar modo de repetición" + +msgid "Like Track" +msgstr "Marcar pista como favorita" + +msgid "Seek Forward" +msgstr "Avanzar" + +msgid "Seek Back" +msgstr "Retroceder" + +msgid "Keymap" +msgstr "Mapa de teclas" + +msgid "Vim" +msgstr "Vim" + +# Local Library QoL: Explorer, scan progress, and per-track metadata editor +msgid "All years" +msgstr "Todos los años" + +msgid "Explorer columns" +msgstr "Columnas del explorador" + +msgid "Genre and year" +msgstr "Género y año" + +msgid "Years" +msgstr "Años" + +msgid "Edit track metadata" +msgstr "Editar metadatos de la pista" + +msgid "Building library index" +msgstr "Creando el índice de la biblioteca" + +msgid "Local folders" +msgstr "Carpetas locales" + +msgid "Next track" +msgstr "Pista siguiente" + +msgid "Overall" +msgstr "Total" + +msgid "Previous track" +msgstr "Pista anterior" + +msgid "Scanning library" +msgstr "Escaneando la biblioteca" + +msgid "The track is no longer in the library." +msgstr "La pista ya no está en la biblioteca." + +msgid "Updating library index" +msgstr "Actualizando el índice de la biblioteca" + +msgid "Edit album" +msgstr "Editar álbum" + +msgid "Editing a local metadata sidecar" +msgstr "Editando un sidecar de metadatos local" + +msgid "Media-server metadata is stored in a local sidecar." +msgstr "Los metadatos del servidor multimedia se guardan en un sidecar local." + +msgid "Media-server metadata can only be edited with a sidecar." +msgstr "Los metadatos del servidor multimedia solo pueden editarse mediante un sidecar." + +msgid "Network file" +msgstr "Archivo de red" + +msgid "Offline download" +msgstr "Descarga sin conexión" + +msgid "File path" +msgstr "Ruta del archivo" + +msgid "Containing folder" +msgstr "Carpeta contenedora" + +msgid "Media item ID" +msgstr "ID del elemento multimedia" + +msgid "Album ID" +msgstr "ID del álbum" + +msgid "Server" +msgstr "Servidor" + +msgid "Channels" +msgstr "Canales" + +msgid "File size" +msgstr "Tamaño del archivo" + +msgid "Location" +msgstr "Ubicación" + +msgid "The media item is no longer available." +msgstr "El elemento multimedia ya no está disponible." + +msgid "The local album folder is not currently available." +msgstr "La carpeta local del álbum no está disponible en este momento." + +msgid "Everything is already available offline" +msgstr "Todo ya está disponible sin conexión" + +msgid "Some tracks are already available offline" +msgstr "Algunas pistas ya están disponibles sin conexión" + +msgid "{} of {} tracks in “{}” are already available offline." +msgstr "{} de {} pistas de “{}” ya están disponibles sin conexión." + +msgid "Download the entire playlist again" +msgstr "Descargar toda la playlist de nuevo" + +msgid "Download the entire album again" +msgstr "Descargar todo el álbum de nuevo" + +msgid "Re-downloads every track and can repair corrupt offline copies." +msgstr "Vuelve a descargar cada pista y puede reparar copias sin conexión corruptas." + +msgid "Only missing tracks" +msgstr "Solo las pistas faltantes" + +msgid "Keeps existing offline copies and downloads tracks that are not available yet." +msgstr "Conserva las copias sin conexión existentes y descarga las pistas que aún no están disponibles." + +msgid "PRIVACY" +msgstr "PRIVACIDAD" + +msgid "Listening history" +msgstr "Historial de escucha" + +msgid "Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler." +msgstr "Guarda un registro local de lo que reproduces: pista, fuente, cuánto escuchaste y cuándo. Se queda en esta computadora, por cuenta, y nunca sale de aquí a menos que actives un scrobbler." + +msgid "Clear listening history" +msgstr "Borrar historial de escucha" + +msgid "Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected." +msgstr "Elimina todas las reproducciones registradas en esta computadora para esta cuenta. Los scrobbles ya enviados no se ven afectados." + +msgid "Listening history cleared." +msgstr "Historial de escucha borrado." + +msgid "Couldn't clear listening history." +msgstr "No se pudo borrar el historial de escucha." + +msgid "Checking ALSA mixer controls…" +msgstr "Comprobando los controles del mezclador ALSA…" + +msgid "This device exposes multiple playback controls. Choose which one QBZ should use." +msgstr "Este dispositivo expone varios controles de reproducción. Elige cuál debe usar QBZ." + +msgid "The saved mixer control is no longer available. Choose another control." +msgstr "El control del mezclador guardado ya no está disponible. Elige otro control." + +msgid "QBZ does not have permission to read this device's ALSA mixer." +msgstr "QBZ no tiene permiso para leer el mezclador ALSA de este dispositivo." + +msgid "The ALSA mixer is busy. Close the app using it and try again." +msgstr "El mezclador ALSA está ocupado. Cierra la aplicación que lo está usando e inténtalo de nuevo." + +msgid "The selected ALSA device is no longer available." +msgstr "El dispositivo ALSA seleccionado ya no está disponible." + +msgid "This ALSA device does not expose a mixer." +msgstr "Este dispositivo ALSA no expone un mezclador." + +msgid "QBZ could not write the selected ALSA mixer control. Direct playback remains fixed at 100%." +msgstr "QBZ no pudo escribir en el control del mezclador ALSA seleccionado. La reproducción directa permanece fija al 100 %." + +msgid "Selected mixer control" +msgstr "Control de mezclador seleccionado" + +msgid "Choose mixer control" +msgstr "Elegir control de mezclador" + +msgid "Change mixer control" +msgstr "Cambiar control de mezclador" + +msgid "Qobuz Connect could not shut down safely. Quit and reopen QBZ, then try logging out again." +msgstr "Qobuz Connect no pudo cerrarse de forma segura. Cierra y vuelve a abrir QBZ, y luego intenta cerrar sesión de nuevo." + +msgid "Show filter panels" +msgstr "Mostrar los paneles de filtros" + +msgid "Hide filter panels" +msgstr "Ocultar los paneles de filtros" + +msgid "Already in" +msgstr "Ya está en" + +msgid "Playlist index updating..." +msgstr "Actualizando el índice de playlists..." + +msgid "{} of {}" +msgstr "{} de {}" + +msgid "Last playlist" +msgstr "Última playlist" + +msgid "OK" +msgstr "OK" + +msgid "Sponsors" +msgstr "Patrocinadores" + +msgid "And all those anonymous sponsors..." +msgstr "Y todos esos patrocinadores anónimos..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect no puede resolver tracks locales. Se ignoró {} track y no se incluyó en la lista. Si deseas reproducirlo, apaga Qobuz Connect." +msgstr[1] "Qobuz Connect no puede resolver tracks locales. Se ignoraron {} tracks y no se incluyeron en la lista. Si deseas reproducirlos, apaga Qobuz Connect." + +msgid "Qobuz Connect device" +msgstr "Dispositivo Qobuz Connect" + +msgid "Continue playback on %1" +msgstr "Continuar la reproducción en %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Usar la cola de Qobuz Connect y mantener activo ese dispositivo." + +msgid "Continue playback on this device" +msgstr "Continuar la reproducción en este dispositivo" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Usar la cola de Qobuz Connect y reemplazar la cola local." + +msgid "Continue this device's current playback" +msgstr "Continuar la reproducción actual de este dispositivo" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Reemplazar la cola y el estado de reproducción de Qobuz Connect con la cola y la posición actuales de este dispositivo." + +msgid "Cancel Qobuz Connect" +msgstr "Cancelar Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Conservar ambas colas sin cambios y continuar la reproducción local actual." + +msgid "Choose how to continue" +msgstr "Elegir cómo continuar" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Este dispositivo y otro reproductor de Qobuz Connect tienen una reproducción en curso. Elige qué cola y dispositivo deben continuar." + +msgid "Ask every time" +msgstr "Preguntar siempre" + +msgid "Continue on the active renderer" +msgstr "Continuar en el renderer activo" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Continuar aquí con la cola de Qobuz Connect" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Continuar la reproducción local y reemplazar la cola de Qobuz Connect" + +msgid "When playback conflicts" +msgstr "Cuando la reproducción entre en conflicto" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Preguntar cada vez o elegir automáticamente qué cola y dispositivo deben continuar." + +msgid "Purchased" +msgstr "Comprado" + +msgid "Sort: Release date" +msgstr "Orden: Fecha de lanzamiento" + +msgid "Until the purchased file is downloaded, Play streams the best quality Qobuz offers for this album. Once it is on disk, Play uses your copy." +msgstr "Mientras no descargues el archivo comprado, Reproducir usa la mejor calidad que Qobuz ofrece para este álbum. Cuando ya esté en disco, Reproducir usa tu copia." + +msgid "Open in Local Library" +msgstr "Abrir en Biblioteca local" + +msgid "Import / Export" +msgstr "Importar / Exportar" + +msgid "This bundle holds the app's own settings: audio, playback, appearance, integrations. It does not contain your Qobuz favorites, playlists, blacklist or purchases." +msgstr "Este paquete contiene los ajustes de la app: audio, reproducción, apariencia, integraciones. No incluye tus favoritos, playlists, blacklist ni compras de Qobuz." + +msgid "BLACKLIST" +msgstr "BLACKLIST" + +msgid "Export blacklist…" +msgstr "Exportar blacklist…" + +msgid "Save your blocked artists and albums as a file you can hand to another QBZ user." +msgstr "Guarda tus artistas y álbumes bloqueados en un archivo que puedes pasarle a otro usuario de QBZ." + +msgid "Import blacklist…" +msgstr "Importar blacklist…" + +msgid "Add blocked artists and albums from a file. Nothing already on your blacklist is removed." +msgstr "Agrega artistas y álbumes bloqueados desde un archivo. No se quita nada de lo que ya tienes en tu blacklist." + +msgid "Import…" +msgstr "Importar…" + +msgid "Import failed." +msgstr "Falló la importación." + +msgid "Not a QBZ blacklist file." +msgstr "No es un archivo de blacklist de QBZ." + +msgid "Choose a blacklist file" +msgstr "Elige un archivo de blacklist" + +msgid "Imported {} artists and {} albums ({} already blocked)." +msgstr "Se importaron {} artistas y {} álbumes ({} ya estaban bloqueados)." + +msgid "Member" +msgstr "Miembro" + +msgid "QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™." +msgstr "QBZ requiere una cuenta de Qobuz™. Sin suscripción activa, la reproducción se limita a previews de 30 segundos; favoritos, playlists y compras siguen funcionando. Tus credenciales se envían directamente a Qobuz™." + +msgid "ACCOUNT MIGRATION" +msgstr "MIGRACIÓN DE CUENTA" + +msgid "Moves favorites, playlists and followed playlists from one Qobuz account into another, adding only what is missing and deleting nothing on either side. It does not move subscriptions or purchases: those stay with the Qobuz account that holds them." +msgstr "Pasa favoritos, playlists y playlists seguidas de una cuenta de Qobuz a otra, agregando sólo lo que falta y sin borrar nada en ninguna de las dos. No migra suscripciones ni compras: esas se quedan en la cuenta de Qobuz que las tiene." + +msgid "Create migration snapshot" +msgstr "Crear snapshot de migración" + +msgid "Sign in with the account you are moving FROM, then save its favorites and playlists to a file in its QBZ profile." +msgstr "Inicia sesión con la cuenta DESDE la que te mueves y guarda sus favoritos y playlists en un archivo dentro de su perfil de QBZ." + +msgid "Create…" +msgstr "Crear…" + +msgid "Migrate into this account" +msgstr "Migrar a esta cuenta" + +msgid "Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing." +msgstr "Inicia sesión con la cuenta A la que te mueves y elige un snapshot abajo. Se puede repetir sin riesgo: una segunda corrida no agrega nada." + +msgid "No snapshots yet. Sign in with the old account and create one first." +msgstr "Aún no hay snapshots. Inicia sesión con la cuenta vieja y crea uno primero." + +msgid "{} favorites · {} playlists · {} followed" +msgstr "{} favoritos · {} playlists · {} seguidas" + +msgid "this account" +msgstr "esta cuenta" + +msgid "Migrate…" +msgstr "Migrar…" + +msgid "Account {} · {}" +msgstr "Cuenta {} · {}" + +msgid "Reading favorites and playlists…" +msgstr "Leyendo favoritos y playlists…" + +msgid "Reading this account…" +msgstr "Leyendo esta cuenta…" + +msgid "Adding favorites…" +msgstr "Agregando favoritos…" + +msgid "Adding playlists…" +msgstr "Agregando playlists…" + +msgid "Following playlists…" +msgstr "Siguiendo playlists…" + +msgid "{} / {} — {}" +msgstr "{} / {} — {}" + +msgid "Snapshot failed." +msgstr "Falló el snapshot." + +msgid "Migration failed." +msgstr "Falló la migración." + +msgid "Saved {} favorites, {} playlists and {} followed playlists to {}" +msgstr "Se guardaron {} favoritos, {} playlists y {} playlists seguidas en {}" + +msgid "That snapshot was taken from this same account." +msgstr "Ese snapshot es de esta misma cuenta." + +msgid "Nothing to migrate: everything is already in this account." +msgstr "Nada que migrar: todo ya está en esta cuenta." + +msgid "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there." +msgstr "Se agregaron {} favoritos, {} playlists ({} pistas) y {} playlists seguidas; {} ya estaban." + +msgid "{} items failed; see the log." +msgstr "{} elementos fallaron; checa el log." + +msgid "Also copy the local profile" +msgstr "Copiar también el perfil local" + +msgid "Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer." +msgstr "Carpetas de la biblioteca, carpetas y orden de playlists, elementos fijados, blacklist y preferencias del perfil de QBZ de la cuenta vieja en esta computadora." + +msgid "Media server connections" +msgstr "Conexiones a servidores de medios" + +msgid "Plex, Jellyfin and Subsonic settings, including their credentials." +msgstr "Ajustes de Plex, Jellyfin y Subsonic, incluidas sus credenciales." + +msgid "Scrobbler accounts" +msgstr "Cuentas de scrobbling" + +msgid "Last.fm and ListenBrainz settings, including their credentials." +msgstr "Ajustes de Last.fm y ListenBrainz, incluidas sus credenciales." + +msgid "The listen log and the events behind offline recommendations." +msgstr "El registro de escucha y los eventos detrás de las recomendaciones offline." + +msgid "Local profile: {} rows copied; {} playlist rows had no match." +msgstr "Perfil local: {} filas copiadas; {} filas de playlist no tuvieron correspondencia." + +msgid "The library already had folders: run a rescan to index the added ones." +msgstr "La biblioteca ya tenía carpetas: corre un rescan para indexar las agregadas." + +msgid "Restart QBZ to see the migrated local profile." +msgstr "Reinicia QBZ para ver el perfil local migrado." + +msgid "The local profile could not be copied; see the log." +msgstr "No se pudo copiar el perfil local; checa el log." +msgid "Delete…" +msgstr "Eliminar…" + +msgid "Delete migration snapshot?" +msgstr "¿Eliminar la instantánea de migración?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Esto elimina únicamente el paquete de migración. Se conservarán el perfil de QBZ y los datos de usuario de la cuenta anterior." + +msgid "Delete snapshot" +msgstr "Eliminar instantánea" + +msgid "Migration snapshot deleted." +msgstr "Se eliminó la instantánea de migración." + +msgid "Could not delete the migration snapshot." +msgstr "No se pudo eliminar la instantánea de migración." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} está actualizando su biblioteca ({} elementos escaneados). Espera a que termine y vuelve a sincronizar." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "No había ninguna conexión de Plex, Jellyfin o Subsonic guardada en el perfil de origen, así que no se pudo copiar ninguna." + +msgid "Media server connections copied: {} of {}." +msgstr "Conexiones a servidores de medios copiadas: {} de {}." + +msgid "Copying local profile…" +msgstr "Copiando el perfil local…" + +msgid "Refreshing migrated data…" +msgstr "Actualizando los datos migrados…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Esto solo sucede una vez. Espera a que termine y no cierres la ventana." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Puedes cerrar este panel; la migración continuará en segundo plano." + +msgid "From" +msgstr "Origen" + +msgid "To" +msgstr "Destino" + +msgid "Step {} of {}" +msgstr "Paso {} de {}" + +msgid "Elapsed: {}" +msgstr "Tiempo transcurrido: {}" + +msgid "Remaining: {}" +msgstr "Restante: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Revisa los perfiles de origen guardados, elige qué copiar y confirma la cuenta de destino con la sesión iniciada." + +msgid "Migrate account data" +msgstr "Migrar datos de la cuenta" + +msgid "Start migration" +msgstr "Iniciar migración" + +msgid "What is a migration profile?" +msgstr "¿Qué es un perfil de migración?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Es un paquete de un solo uso creado desde la cuenta de origen. Conserva sus favoritos y playlists de Qobuz para agregarlos a la cuenta que tiene la sesión iniciada. Los datos locales opcionales se copian directamente desde el perfil anterior de QBZ en esta computadora. Eliminar el paquete no borra el perfil anterior ni los datos de ninguna cuenta." + +msgid "SAVED SOURCE PROFILES" +msgstr "PERFILES DE ORIGEN GUARDADOS" + +msgid "No saved migration profiles are available." +msgstr "No hay perfiles de migración guardados disponibles." + +msgid "Selected" +msgstr "Seleccionado" + +msgid "Select" +msgstr "Seleccionar" + +msgid "MIGRATION DIRECTION" +msgstr "DIRECCIÓN DE LA MIGRACIÓN" + +msgid "Select a source profile above" +msgstr "Selecciona arriba un perfil de origen" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Cuenta de Qobuz con sesión iniciada · ID {}" + +msgid "WHAT TO COPY" +msgstr "QUÉ COPIAR" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "La migración es aditiva: no elimina favoritos ni playlists de ninguna cuenta." + +msgid "That library folder is disabled." +msgstr "Esa carpeta de la biblioteca está deshabilitada." + +msgid "That folder overlaps an existing library folder." +msgstr "Esa carpeta se superpone con una carpeta existente de la biblioteca." + +msgid "This purchase is not available for download." +msgstr "Esta compra no está disponible para descargar." + +msgid "Could not save playback preference." +msgstr "No se pudo guardar la preferencia de reproducción." + +msgid "Qobuz · stream/offline copy" +msgstr "Qobuz · streaming/copia offline" + +msgid "Local purchase" +msgstr "Compra local" + +msgid "Play with" +msgstr "Reproducir con" + +msgid "Download purchase" +msgstr "Descargar compra" + +msgid "1 track purchased" +msgstr "1 pista comprada" + +msgid "{} tracks purchased" +msgstr "{} pistas compradas" + +msgid "This copy cannot be continued right now." +msgstr "No se puede continuar esta copia en este momento." + +msgid "Library folder refreshed." +msgstr "Carpeta de la biblioteca actualizada." + +msgid "Download another copy" +msgstr "Descargar otra copia" + +msgid "Complete" +msgstr "Completa" + +msgid "Partial" +msgstr "Parcial" + +msgid "Missing files" +msgstr "Archivos faltantes" + +msgid "Changed files" +msgstr "Archivos modificados" + +msgid "Unreadable files" +msgstr "Archivos ilegibles" + +msgid "Local purchase copies" +msgstr "Copias locales de la compra" + +msgid "{} of {} tracks" +msgstr "{} de {} pistas" + +msgid "Continue copy" +msgstr "Continuar copia" + +msgid "Add to Local Library" +msgstr "Agregar a la biblioteca local" + +msgid "In Local Library" +msgstr "En la biblioteca local" + +msgid "{} of {} tracks available in this folder." +msgstr "{} de {} pistas disponibles en esta carpeta." + +msgid "{} failed" +msgstr "{} con error" + +msgid "{} cancelled" +msgstr "{} canceladas" + +msgid "Download finished" +msgstr "Descarga finalizada" + +msgid "Shuffle: On" +msgstr "Aleatorio: activado" + +msgid "Shuffle: Off" +msgstr "Aleatorio: desactivado" + +msgid "Repeat: Off" +msgstr "Repetir: desactivado" + +msgid "Repeat: All" +msgstr "Repetir: todo" + +msgid "Repeat: One" +msgstr "Repetir: una" + +msgid "Show album artwork in Queue track list" +msgstr "Mostrar carátulas de álbum en la lista de pistas de la cola" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Reemplaza los números de pista de la cola con miniaturas de las carátulas. Desactivado por defecto." + +msgid "Show album artwork in Library track list" +msgstr "Mostrar carátulas de álbum en la lista de pistas de la Biblioteca" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Muestra la miniatura de la carátula entre el número y el título de la pista. Desactivado por defecto." + +msgid "Play random" +msgstr "Reproducir al azar" + +msgid "Play selected" +msgstr "Reproducir selección" + +msgid "Cancel selection" +msgstr "Cancelar selección" + +msgid "QOBUZ LINKS" +msgstr "ENLACES DE QOBUZ" + +msgid "Open Qobuz links in QBZ" +msgstr "Abrir enlaces de Qobuz en QBZ" + +msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here." +msgstr "Hace que QBZ maneje los enlaces qobuzapp:// que salen de tu navegador. Apagado, la app oficial de Qobuz se queda con ellos; los enlaces qbz:// de QBZ siempre abren aquí." + +msgid "Look for better replacement" +msgstr "Buscar un reemplazo mejor" + +msgid "Remove all favorites from this release" +msgstr "Eliminar todos los favoritos de este lanzamiento" + +msgid "Remove all favorites from this release?" +msgstr "¿Eliminar todos los favoritos de este lanzamiento?" + +msgid "Remove the album and every track from this release from your Library?" +msgstr "¿Eliminar de tu Biblioteca el álbum y todas las pistas de este lanzamiento?" + +msgid "No replacement releases found" +msgstr "No se encontraron lanzamientos de reemplazo" + +msgid "Choosing a result opens the release. Your existing favorites are not changed." +msgstr "Al elegir un resultado se abre el lanzamiento. Tus favoritos actuales no cambian." + +msgid "No favorites from this release to remove" +msgstr "No hay favoritos de este lanzamiento para eliminar" + +msgid "Favorites removed from this release" +msgstr "Se eliminaron los favoritos de este lanzamiento" + +msgid "Could not remove favorites from this release" +msgstr "No se pudieron eliminar los favoritos de este lanzamiento" + +msgid "Some favorites could not be removed" +msgstr "No se pudieron eliminar algunos favoritos" + +msgid "Replace in Library" +msgstr "Reemplazar en la Biblioteca" + +msgid "Replacing favorite..." +msgstr "Reemplazando favorito..." + +msgid "The selected release replaces the unavailable album favorite." +msgstr "El lanzamiento seleccionado reemplaza el álbum favorito no disponible." + +msgid "The matching track from the selected release replaces the unavailable favorite." +msgstr "La pista equivalente del lanzamiento seleccionado reemplaza el favorito no disponible." + +msgid "Could not open the selected release" +msgstr "No se pudo abrir el lanzamiento seleccionado" + +msgid "Could not find a matching track in this release" +msgstr "No se encontró una pista equivalente en este lanzamiento" + +msgid "Could not add the replacement favorite" +msgstr "No se pudo agregar el favorito de reemplazo" + +msgid "Favorite replaced" +msgstr "Favorito reemplazado" + +msgid "The replacement was added, but the unavailable favorite could not be removed" +msgstr "Se agregó el reemplazo, pero no se pudo eliminar el favorito no disponible" + +msgid "Failed to send playback to the selected device" +msgstr "No se pudo enviar la reproducción al dispositivo seleccionado" + +msgid "Move up" +msgstr "Mover arriba" + +msgid "Move down" +msgstr "Mover abajo" + +msgid "Download" +msgstr "Descargar" + +msgid "Edit" +msgstr "Editar" + +msgid "Grid" +msgstr "Cuadrícula" + +msgid "List" +msgstr "Lista" + +msgid "Tree" +msgstr "Árbol" + +msgid "Forward" +msgstr "Adelante" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po new file mode 100644 index 000000000..27a377fe0 --- /dev/null +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -0,0 +1,6843 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: qbz-slint\n" +"PO-Revision-Date: 2026-09-05 19:52-0600\n" +"Last-Translator: QBZ contributors\n" +"Language-Team: QBZ fr translators\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "System Colors" +msgstr "Couleurs système" + +msgid "Wallpaper Sync" +msgstr "Wallpaper Sync" + +msgid "Custom Image" +msgstr "Image personnalisée" + +msgid "Auto" +msgstr "Automatique" + +msgid "Disabled" +msgstr "Désactivé" + +msgid "Replace current queue" +msgstr "Remplacer la file d'attente actuelle" + +msgid "Play next" +msgstr "Lire ensuite" + +msgid "Add to queue" +msgstr "Ajouter à la file" + +msgid "Remember last" +msgstr "Mémoriser la dernière" + +msgid "Album Reactive" +msgstr "Album réactif" + +msgid "Static" +msgstr "Statique" + +msgid "Coverflow" +msgstr "Coverflow" + +msgid "Spectrum" +msgstr "Spectre" + +msgid "Lyrics" +msgstr "Paroles" + +msgid "Queue" +msgstr "File d'attente" + +msgid "Auto (opposite to window controls)" +msgstr "Automatique (opposé aux contrôles de fenêtre)" + +msgid "Left" +msgstr "Gauche" + +msgid "Right" +msgstr "Droite" + +msgid "Rectangular" +msgstr "Rectangulaire" + +msgid "Full-height rounded" +msgstr "Arrondi pleine hauteur" + +msgid "Circular" +msgstr "Circulaire" + +msgid "Square" +msgstr "Carré" + +msgid "Small" +msgstr "Petit" + +msgid "Normal" +msgstr "Normal" + +msgid "Large" +msgstr "Grand" + +msgid "Default" +msgstr "Par défaut" + +msgid "Monochrome" +msgstr "Monochrome" + +msgid "Custom" +msgstr "Personnalisé" + +msgid "Remember last used" +msgstr "Mémoriser la dernière utilisée" + +msgid "Micro" +msgstr "Micro" + +msgid "Compact" +msgstr "Compact" + +msgid "Artwork" +msgstr "Artwork" + +msgid "Home" +msgstr "Accueil" + +msgid "Where you left off" +msgstr "Où vous en étiez" + +msgid "Mono light" +msgstr "Mono claire" + +msgid "Mono dark" +msgstr "Mono foncée" + +msgid "Color" +msgstr "Couleur" + +msgid "Vinyl" +msgstr "Vinyle" + +msgid "Visualizer" +msgstr "Visualiseur" + +msgid "Neon Flow" +msgstr "Flux Néon" + +msgid "Tunnel" +msgstr "Tunnel" + +msgid "Comet" +msgstr "Comète" + +msgid "Split: Lyrics" +msgstr "Divisé : Paroles" + +msgid "Split: Track Info" +msgstr "Divisé : Info titre" + +msgid "Split: Suggestions" +msgstr "Divisé : Suggestions" + +msgid "Split: Queue" +msgstr "Divisé : File d'attente" + +msgid "Full" +msgstr "Complet" + +msgid "Lite" +msgstr "Léger" + +msgid "Off" +msgstr "Aucune" + +msgid "versions" +msgstr "versions" + +msgid "Search tracks..." +msgstr "Rechercher des titres..." + +msgid "Title" +msgstr "Titre" + +msgid "Quality" +msgstr "Qualité" + +msgid "Disc" +msgstr "Disque" + +msgid "Loading…" +msgstr "Chargement…" + +msgid "Failed to load booklet" +msgstr "Échec du chargement du livret" + +msgid "Add cover" +msgstr "Ajouter une pochette" + +msgid "Change cover" +msgstr "Changer la pochette" + +msgid "Remove cover" +msgstr "Supprimer la pochette" + +msgid "Open in browser" +msgstr "Ouvrir dans le navigateur" + +msgid "Save as…" +msgstr "Enregistrer sous…" + +msgid "Read more" +msgstr "Lire la suite" + +msgid "Loading album…" +msgstr "Chargement de l'album…" + +msgid "Select all" +msgstr "Tout sélectionner" + +msgid "Add to playlist" +msgstr "Ajouter à la playlist" + +msgid "Add to Library" +msgstr "Ajouter à la bibliothèque" + +msgid "Make available offline" +msgstr "Rendre disponible hors ligne" + +msgid "Clear" +msgstr "Effacer" + +msgid "LABEL" +msgstr "LABEL" + +msgid "AWARDS" +msgstr "RÉCOMPENSES" + +msgid "EXTERNAL LINKS" +msgstr "LIENS EXTERNES" + +msgid "About this album" +msgstr "À propos de cet album" + +msgid "Released by" +msgstr "Parution chez" + +msgid "on" +msgstr "le" + +msgid "Credits" +msgstr "Crédits" + +msgid "Review" +msgstr "Critique" + +msgid "Failed to load album credits" +msgstr "Échec du chargement des crédits de l'album" + +msgid "Loading credits..." +msgstr "Chargement des crédits..." + +msgid "Loading track info..." +msgstr "Chargement des informations du titre..." + +msgid "Failed to load track info" +msgstr "Échec du chargement des informations du titre" + +msgid "Label" +msgstr "Label" + +msgid "Newest" +msgstr "Plus récents" + +msgid "Oldest" +msgstr "Plus anciens" + +msgid "A–Z" +msgstr "A–Z" + +msgid "Z–A" +msgstr "Z–A" + +msgid "Failed to load Library" +msgstr "Échec du chargement de la bibliothèque" + +msgid "Retry" +msgstr "Réessayer" + +msgid "No releases found." +msgstr "Aucune sortie trouvée." + +msgid "See discography" +msgstr "Voir la discographie" + +msgid "Load more" +msgstr "Charger plus" + +msgid "Add image" +msgstr "Ajouter une image" + +msgid "Change image" +msgstr "Changer l'image" + +msgid "Remove image" +msgstr "Supprimer l'image" + +msgid "Create Artist Collection" +msgstr "Créer une collection d'artistes" + +msgid "Artist Scene" +msgstr "Scène de l'artiste" + +msgid "Share" +msgstr "Partager" + +msgid "Show artist" +msgstr "Montrer l'artiste" + +msgid "Blacklist artist" +msgstr "Bloquer l'artiste" + +msgid "This artist is hidden from discovery" +msgstr "Cet artiste est masqué de la découverte" + +msgid "Loading artist…" +msgstr "Chargement de l'artiste…" + +msgid "Popular Tracks" +msgstr "Titres populaires" + +msgid "Play all next" +msgstr "Tout lire ensuite" + +msgid "Play all later" +msgstr "Tout lire plus tard" + +msgid "Add all to queue" +msgstr "Tout ajouter à la file d'attente" + +msgid "Shuffle all" +msgstr "Tout lire en aléatoire" + +msgid "Add all to playlist" +msgstr "Tout ajouter à la playlist" + +msgid "View less" +msgstr "Voir moins" + +msgid "Latest release" +msgstr "Dernière sortie" + +msgid "Appears On" +msgstr "Apparaît sur" + +msgid "Hide" +msgstr "Masquer" + +msgid "Show" +msgstr "Afficher" + +msgid "Network" +msgstr "Réseau" + +msgid "Magazine" +msgstr "Magazine" + +msgid "Loading origin…" +msgstr "Chargement de l'origine…" + +msgid "BORN" +msgstr "NAISSANCE" + +msgid "FOUNDED" +msgstr "FONDATION" + +msgid "BORN IN" +msgstr "NÉ À" + +msgid "FOUNDED IN" +msgstr "FONDÉ EN" + +msgid "DIED" +msgstr "DÉCÈS" + +msgid "DISBANDED" +msgstr "DISSOLUTION" + +msgid "LABELS" +msgstr "LABELS" + +msgid "No label info" +msgstr "Aucune information sur le label" + +msgid "SIMILAR ARTISTS" +msgstr "ARTISTES SIMILAIRES" + +msgid "RELATIONSHIPS" +msgstr "RELATIONS" + +msgid "Members & Former" +msgstr "Membres et anciens" + +msgid "Member Of" +msgstr "Membre de" + +msgid "Collaborators" +msgstr "Collaborateurs" + +msgid "YOU MAY ALSO LIKE" +msgstr "VOUS POURRIEZ AUSSI AIMER" + +msgid "No stories for this artist" +msgstr "Aucune actualité pour cet artiste" + +msgid "About this artist" +msgstr "À propos de cet artiste" + +msgid "Source" +msgstr "Source" + +msgid "Following" +msgstr "Abonnements" + +msgid "Follow" +msgstr "Suivre" + +msgid "View all" +msgstr "Voir tout" + +msgid "View all →" +msgstr "Tout voir →" + +msgid "Filter by genre" +msgstr "Filtrer par genre" + +msgid "Remember selection" +msgstr "Mémoriser la sélection" + +msgid "Advanced view" +msgstr "Vue avancée" + +msgid "Search genres…" +msgstr "Rechercher des genres…" + +msgid "Clear filter" +msgstr "Effacer le filtre" + +msgid "Qobuz Mixes" +msgstr "Qobuz Mixes" + +msgid "Elevate your day with a customized selection of music." +msgstr "Sublimez votre journée avec une sélection musicale sur mesure." + +msgid "Take a weekly journey with a fresh mix every Friday." +msgstr "Vivez un voyage hebdomadaire avec un nouveau mix chaque vendredi." + +msgid "A fresh shuffle from your personal library." +msgstr "Un mélange frais de votre bibliothèque personnelle." + +msgid "Discover new music from your most-played playlists." +msgstr "Découvrez de nouvelles musiques à partir de vos playlists les plus écoutées." + +msgid "Open album" +msgstr "Ouvrir l'album" + +msgid "Open containing album" +msgstr "Ouvrir l'album d'origine" + +msgid "Play" +msgstr "Lecture" + +msgid "Search…" +msgstr "Rechercher…" + +msgid "1 genre" +msgstr "1 genre" + +msgid "{} genres" +msgstr "{} genres" + +msgid "No results found." +msgstr "Aucun résultat trouvé." + +msgid "Editor's Picks" +msgstr "Sélection éditoriale" + +msgid "For You" +msgstr "Pour vous" + +msgid "Qobuz Playlists" +msgstr "Playlists Qobuz" + +msgid "No playlists match the selected categories." +msgstr "Aucune playlist ne correspond aux catégories sélectionnées." + +msgid "Recently Played Albums" +msgstr "Albums écoutés récemment" + +msgid "Albums you play will appear here." +msgstr "Les albums que vous écoutez apparaîtront ici." + +msgid "Recently Played Tracks" +msgstr "Titres écoutés récemment" + +msgid "Tracks you play will appear here." +msgstr "Les morceaux que vous écoutez apparaîtront ici." + +msgid "Popular albums" +msgstr "Top albums" + +msgid "{} selected" +msgstr "{} sélectionné(s)" + +msgid "Filter by category" +msgstr "Filtrer par catégorie" + +msgid "All categories" +msgstr "Toutes les catégories" + +msgid "Customize" +msgstr "Personnaliser" + +msgid "Enabling more sections increases load time." +msgstr "Activer plus de sections augmente le temps de chargement." + +msgid "{} of {} enabled" +msgstr "{} sur {} activé(s)" + +msgid "Reset to defaults" +msgstr "Restaurer les valeurs par défaut" + +msgid "Radio Stations" +msgstr "Radios à découvrir" + +msgid "RADIO" +msgstr "RADIO" + +msgid "Your Top Artists" +msgstr "Vos artistes favoris" + +msgid "Artists to Follow" +msgstr "Artistes à suivre" + +msgid "Pinned" +msgstr "Épinglés" + +msgid "Pin" +msgstr "Épingler" + +msgid "Unpin" +msgstr "Désépingler" + +msgid "Showing {} of {} track." +msgid_plural "Showing {} of {} tracks." +msgstr[0] "Affichage de {} sur {} piste." +msgstr[1] "Affichage de {} sur {} pistes." + +msgid "Spotlight" +msgstr "Sous les projecteurs" + +msgid "Shine a light on one of your favourite artists." +msgstr "Un artiste qui mérite toute votre attention." + +msgid "ARTIST" +msgstr "ARTISTE" + +msgid "Top tracks" +msgstr "Titres populaires" + +msgid "By {}" +msgstr "Par {}" + +msgid "TOP TRACKS" +msgstr "TOP TRACKS" + +msgid "Qobuz Radio Station" +msgstr "Station radio Qobuz" + +msgid "Available offline" +msgstr "Disponible hors ligne" + +msgid "Tracks" +msgstr "Pistes" + +msgid "Albums" +msgstr "Albums" + +msgid "Artists" +msgstr "Artistes" + +msgid "Playlists" +msgstr "Playlists" + +msgid "Labels" +msgstr "Labels" + +msgid "Favorite" +msgstr "Favori" + +msgid "Library" +msgstr "Bibliothèque" + +msgid "Search" +msgstr "Rechercher" + +msgid "Group: Off" +msgstr "Grouper : Désactivé" + +msgid "Group: Album" +msgstr "Grouper : Album" + +msgid "Group: Artist" +msgstr "Grouper : Artiste" + +msgid "Group: Name" +msgstr "Grouper : Nom" + +msgid "Group: A-Z" +msgstr "Grouper : A-Z" + +msgid "Title A-Z" +msgstr "Titre A-Z" + +msgid "Title Z-A" +msgstr "Titre Z-A" + +msgid "Artist A-Z" +msgstr "Artiste A-Z" + +msgid "Couldn't load your Library." +msgstr "Impossible de charger votre bibliothèque." + +msgid "Remove from Library" +msgstr "Retirer de la bibliothèque" + +msgid "No tracks in your Library yet." +msgstr "Aucun titre dans votre bibliothèque pour l'instant." + +msgid "No tracks match your search." +msgstr "Aucun titre ne correspond à votre recherche." + +msgid "No albums in your Library yet." +msgstr "Aucun album dans votre bibliothèque pour l'instant." + +msgid "No albums match your search." +msgstr "Aucun album ne correspond à votre recherche." + +msgid "No artists in your Library yet." +msgstr "Aucun artiste dans votre bibliothèque pour l'instant." + +msgid "No artists match your search." +msgstr "Aucun artiste ne correspond à votre recherche." + +msgid "No playlists match your search." +msgstr "Aucune playlist ne correspond à votre recherche." + +msgid "No followed playlists yet." +msgstr "Aucune playlist suivie pour l'instant." + +msgid "No playlists in your Library yet." +msgstr "Aucune playlist dans votre bibliothèque pour l'instant." + +msgid "No labels in your Library yet." +msgstr "Aucun label dans votre bibliothèque pour l'instant." + +msgid "No labels match your search." +msgstr "Aucun label ne correspond à votre recherche." + +msgid "Select an artist to see their albums." +msgstr "Sélectionnez un artiste pour voir ses albums." + +msgid "Couldn't load albums." +msgstr "Impossible de charger les albums." + +msgid "No albums found." +msgstr "Aucun album trouvé." + +msgid "Go to artist page" +msgstr "Aller à la page de l'artiste" + +msgid "albums" +msgstr "albums" + +msgid "Releases" +msgstr "Sorties" + +msgid "Search this label…" +msgstr "Rechercher dans ce label…" + +msgid "No results" +msgstr "Aucun résultat" + +msgid "More labels" +msgstr "Plus de labels" + +msgid "About" +msgstr "À propos" + +msgid "Folders" +msgstr "Dossiers" + +msgid "tracks" +msgstr "titres" + +msgid "Play all" +msgstr "Tout lire" + +msgid "Clear session" +msgstr "Effacer la session" + +msgid "No playable tracks in this folder." +msgstr "Aucun titre lisible dans ce dossier." + +msgid "Play this album" +msgstr "Lire cet album" + +msgid "Local Library" +msgstr "Bibliothèque locale" + +msgid "Artist Z-A" +msgstr "Artiste Z-A" + +msgid "Year (newest)" +msgstr "Année (plus récent)" + +msgid "Year (oldest)" +msgstr "Année (plus ancien)" + +msgid "No grouping" +msgstr "Aucun regroupement" + +msgid "Group by letter" +msgstr "Grouper par lettre" + +msgid "Group by artist" +msgstr "Grouper par artiste" + +msgid "By album" +msgstr "Par album" + +msgid "By artist" +msgstr "Par artiste" + +msgid "By name" +msgstr "Par nom" + +msgid "Search artists" +msgstr "Rechercher des artistes" + +msgid "Couldn't load your local albums." +msgstr "Impossible de charger vos albums locaux." + +msgid "No albums in your local library yet." +msgstr "Aucun album dans votre bibliothèque locale pour le moment." + +msgid "Couldn't load your local tracks." +msgstr "Impossible de charger vos pistes locales." + +msgid "No tracks in your local library yet." +msgstr "Aucun titre dans votre bibliothèque locale pour le moment." + +msgid "Add to Mixtape/Collection" +msgstr "Ajouter à Mixtape/Collection" + +msgid "No folders in your local library yet." +msgstr "Aucun dossier dans votre bibliothèque locale pour le moment." + +msgid "No folders match your search." +msgstr "Aucun dossier ne correspond à votre recherche." + +msgid "Search folders" +msgstr "Rechercher des dossiers" + +msgid "sel" +msgstr "sél." + +msgid "Check all" +msgstr "Tout cocher" + +msgid "Select a folder to see its contents." +msgstr "Sélectionnez un dossier pour afficher son contenu." + +msgid "Search albums..." +msgstr "Rechercher des albums..." + +msgid "This folder has no tracks." +msgstr "Ce dossier ne contient aucun titre." + +msgid "No artists in your local library yet." +msgstr "Aucun artiste dans votre bibliothèque locale pour le moment." + +msgid "artists" +msgstr "artistes" + +msgid "Select an artist to view their albums" +msgstr "Sélectionnez un artiste pour voir ses albums" + +msgid "No albums found" +msgstr "Aucun album trouvé" + +msgid "Local Library port in progress" +msgstr "Portage de la bibliothèque locale en cours" + +msgid "This tab is coming next." +msgstr "Cet onglet arrive prochainement." + +msgid "Filter" +msgstr "Filtrer" + +msgid "Hi-Res" +msgstr "Hi-Res" + +msgid "CD" +msgstr "CD" + +msgid "Lossy" +msgstr "Avec perte" + +msgid "Other" +msgstr "Autre" + +msgid "Local" +msgstr "Local" + +msgid "Offline cache" +msgstr "Cache hors ligne" + +msgid "Scene" +msgstr "Scène" + +msgid "No artists found for this scene." +msgstr "Aucun artiste trouvé pour cette scène." + +msgid "QBZ" +msgstr "QBZ" + +msgid "QOBUZ™ PLAYER" +msgstr "LECTEUR QOBUZ™" + +msgid "I have read the" +msgstr "J'ai lu les" + +msgid "Qobuz™ Terms of Service" +msgstr "Conditions d'utilisation de Qobuz™" + +msgid "Sign in via Browser" +msgstr "Se connecter via le navigateur" + +msgid "Use your system browser instead" +msgstr "Utiliser votre navigateur système" + +msgid "No internet connection — you can start in offline mode with your local library and downloads" +msgstr "Pas de connexion internet — vous pouvez démarrer en mode hors ligne avec votre bibliothèque locale et vos téléchargements" + +msgid "A network sign-in page may be blocking the connection" +msgstr "Une page de connexion au réseau bloque peut-être la connexion" + +msgid "Could not restore your session" +msgstr "Impossible de restaurer votre session" + +msgid "Start offline (no access to Qobuz™ services)" +msgstr "Démarrer hors ligne (pas d'accès aux services Qobuz™)" + +msgid "QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™." +msgstr "QBZ requiert un abonnement à Qobuz™ actif. Vos identifiants sont directement envoyés à Qobuz™." + +msgid "This application uses the Qobuz API but is not certified by Qobuz." +msgstr "Cette application utilise l'API Qobuz™ mais n'est pas certifiée par Qobuz™." + +msgid "Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz." +msgstr "Qobuz™ est une marque de Qobuz. QBZ est une application open source sous licence MIT et n'est pas affilié, approuvé ou certifié par Qobuz™." + +msgid "No tracks for this mix." +msgstr "Aucune piste pour ce mix." + +msgid "Album" +msgstr "Album" + +msgid "Bands & Projects" +msgstr "Bandes et projets" + +msgid "No album appearances found." +msgstr "Aucune apparition sur un album trouvée." + +msgid "You're offline" +msgstr "Vous êtes hors ligne" + +msgid "Offline mode is enabled. Disable it in Settings to use Qobuz." +msgstr "Le mode hors ligne est activé. Désactivez-le dans les Paramètres pour utiliser Qobuz." + +msgid "No internet connection. Your local library and downloads keep working." +msgstr "Pas de connexion internet. Votre bibliothèque locale et vos téléchargements continuent de fonctionner." + +msgid "Open Settings" +msgstr "Ouvrir les Paramètres" + +msgid "Offline Cache" +msgstr "Cache hors ligne" + +msgid "{} track" +msgid_plural "{} tracks" +msgstr[0] "{} piste" +msgstr[1] "{} pistes" + +msgid "Sort: A-Z" +msgstr "Trier : A-Z" + +msgid "Sort: Recent" +msgstr "Trier : Récent" + +msgid "Sort: Largest" +msgstr "Trier : Plus volumineux" + +msgid "Sort: Smallest" +msgstr "Trier : Plus petit" + +msgid "Failed only" +msgstr "Échecs uniquement" + +msgid " selected" +msgstr " sélectionné(s)" + +msgid "Re-download" +msgstr "Re-télécharger" + +msgid "Remove" +msgstr "Supprimer" + +msgid "All artists" +msgstr "Tous les artistes" + +msgid "No offline tracks yet" +msgstr "Aucune piste hors ligne pour le moment" + +msgid "Search playlists…" +msgstr "Rechercher des playlists…" + +msgid "1 playlist" +msgstr "1 playlist" + +msgid "{} playlists" +msgstr "{} playlists" + +msgid "Move to folder" +msgstr "Déplacer vers le dossier" + +msgid "No folder" +msgstr "Aucun dossier" + +msgid "Playlist manager" +msgstr "Gestionnaire de playlist" + +msgid "Visible" +msgstr "Visible" + +msgid "Hidden" +msgstr "Caché" + +msgid "All" +msgstr "Tout" + +msgid "Recent" +msgstr "Récent" + +msgid "Play Count" +msgstr "Nombre de lectures" + +msgid "Track Count" +msgstr "Nombre de pistes" + +msgid "Name A-Z" +msgstr "Nom A-Z" + +msgid "New folder" +msgstr "Nouveau dossier" + +msgid "{} folders, {} playlists" +msgstr "{} dossiers, {} playlists" + +msgid "Use the arrows to reorder playlists" +msgstr "Utilisez les flèches pour réorganiser les playlists" + +msgid "Folders ({})" +msgstr "Dossiers ({})" + +msgid "Playlists ({})" +msgstr "Playlists ({})" + +msgid "No playlists found." +msgstr "Aucune playlist trouvée." + +msgid "Artist" +msgstr "Artiste" + +msgid "Playlist" +msgstr "Playlist" + +msgid "Search tracks" +msgstr "Rechercher des titres" + +msgid "Sort" +msgstr "Trier" + +msgid "This playlist is empty." +msgstr "Cette playlist est vide." + +msgid "Upload to Qobuz?" +msgstr "Envoyer vers Qobuz ?" + +msgid "This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy." +msgstr "Cela crée la playlist sur Qobuz avec ses titres Qobuz, conserve les fichiers locaux attachés et supprime la copie purement locale." + +msgid "Cancel" +msgstr "Annuler" + +msgid "Upload" +msgstr "Envoyer" + +msgid "About this playlist" +msgstr "À propos de cette playlist" + +msgid "ITEM" +msgstr "ÉLÉMENT" + +msgid "TYPE" +msgstr "TYPE" + +msgid "SOURCE" +msgstr "SOURCE" + +msgid "QUALITY" +msgstr "QUALITÉ" + +msgid "TRACKS" +msgstr "TITRES" + +msgid "YEAR" +msgstr "ANNÉE" + +msgid "Folder name" +msgstr "Nom du dossier" + +msgid "Creating..." +msgstr "Création..." + +msgid "Create" +msgstr "Créer" + +msgid "Edit folder" +msgstr "Modifier le dossier" + +msgid "Enter folder name" +msgstr "Saisissez le nom du dossier" + +msgid "Icon" +msgstr "Icône" + +msgid "Hide from sidebar" +msgstr "Masquer de la barre latérale" + +msgid "Delete" +msgstr "Supprimer" + +msgid "Save" +msgstr "Enregistrer" + +msgid "JUMP TO" +msgstr "ALLER À" + +msgid "Search this page…" +msgstr "Rechercher dans cette page…" + +msgid "Something is already playing" +msgstr "Quelque chose est déjà en cours de lecture" + +msgid "Add this to the current queue, or clear the queue and start playing it?" +msgstr "Ajouter ceci à la file d'attente actuelle, ou vider la file et lancer la lecture ?" + +msgid "Clear queue and play" +msgstr "Vider la file et lire" + +msgid "Edit metadata" +msgstr "Modifier les métadonnées" + +msgid "Album title" +msgstr "Titre de l'album" + +msgid "Album artist" +msgstr "Artiste de l'album" + +msgid "Year" +msgstr "Année" + +msgid "Genre" +msgstr "Genre" + +msgid "Catalog number" +msgstr "Numéro de catalogue" + +msgid "Total discs" +msgstr "Nombre total de disques" + +msgid "Searching…" +msgstr "Recherche…" + +msgid "No results, try a different search" +msgstr "Aucun résultat, essayez une autre recherche" + +msgid "Apply" +msgstr "Appliquer" + +msgid "Album folder" +msgstr "Dossier de l'album" + +msgid "Sidecar file (recommended)" +msgstr "Fichier sidecar (recommandé)" + +msgid "Write to audio files" +msgstr "Écrire dans les fichiers audio" + +msgid "This writes tags directly into your audio files." +msgstr "Ceci écrit les tags directement dans vos fichiers audio." + +msgid "Direct write is unavailable for CUE-based albums." +msgstr "L'écriture directe n'est pas disponible pour les albums basés sur CUE." + +msgid "Writing" +msgstr "Écriture" + +msgid "Saving…" +msgstr "Enregistrement…" + +msgid "Edit playlist" +msgstr "Modifier la playlist" + +msgid "Playlist name" +msgstr "Nom de la playlist" + +msgid "Description" +msgstr "Description" + +msgid "Offline-only playlist" +msgstr "Playlist hors ligne uniquement" + +msgid "New playlist" +msgstr "Nouvelle playlist" + +msgid "Name" +msgstr "Nom" + +msgid "My Playlist" +msgstr "Ma playlist" + +msgid "Description (optional)" +msgstr "Description (facultatif)" + +msgid "Folder" +msgstr "Dossier" + +msgid "Make playlist public" +msgstr "Rendre la playlist publique" + +msgid "Created locally — you're offline" +msgstr "Créée localement — vous êtes hors ligne" + +msgid "Add to mixtape" +msgstr "Ajouter au mixtape" + +msgid "Share Qobuz link" +msgstr "Partager le lien Qobuz" + +msgid "Share Album.link" +msgstr "Partager Album.link" + +msgid "Refresh offline copy" +msgstr "Rafraîchir la copie hors-ligne" + +msgid "Some of these tracks were already added" +msgstr "Certains de ces titres ont déjà été ajoutés" + +msgid "This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates." +msgstr "Cette playlist contient déjà {} des {} pistes que vous ajoutez. Ajoutez seulement les nouvelles, ou tout, y compris les doublons." + +msgid "Adding..." +msgstr "Ajout en cours..." + +msgid "Add all tracks" +msgstr "Ajouter tous les titres" + +msgid "Add only new tracks" +msgstr "Ajouter uniquement les nouveaux" + +msgid "Add to Playlist" +msgstr "Ajouter à la playlist" + +msgid "Choose playlist" +msgstr "Choisir une playlist" + +msgid "Adding {} local track(s)" +msgstr "Ajout de {} piste(s) locale(s)" + +msgid "Adding {} track(s)" +msgstr "Ajout de {} piste(s)" + +msgid "+ Create new playlist" +msgstr "+ Créer une nouvelle playlist" + +msgid "Search playlists..." +msgstr "Rechercher des playlists..." + +msgid "Playlist Name" +msgstr "Nom de la playlist" + +msgid "Saving..." +msgstr "Enregistrement..." + +msgid "Create & Add" +msgstr "Créer et ajouter" + +msgid "Add" +msgstr "Ajouter" + +msgid "No playlists found" +msgstr "Aucune playlist trouvée" + +msgid "{} more playlist(s)..." +msgstr "{} playlist(s) de plus..." + +msgid "Import Playlist" +msgstr "Importer une playlist" + +msgid "This feature requires internet connection" +msgstr "Cette fonctionnalité nécessite une connexion Internet" + +msgid "Playlist URL" +msgstr "URL de la playlist" + +msgid "ALLOWED SOURCES" +msgstr "SOURCES AUTORISÉES" + +msgid "Conversion progress" +msgstr "Progression de la conversion" + +msgid "Summary" +msgstr "Resume" + +msgid "Close" +msgstr "Fermer" + +msgid "Importing..." +msgstr "Importation..." + +msgid "Import" +msgstr "Importer" + +msgid "Fetching..." +msgstr "Recuperation..." + +msgid "Fetch playlist" +msgstr "Recuperer la playlist" + +msgid "Shuffle" +msgstr "Lecture aléatoire" + +msgid "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" +msgstr "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" + +msgid "24-bit / 44.1 kHz — Billie Eilish · LUNCH" +msgstr "24-bit / 44.1 kHz — Billie Eilish · LUNCH" + +msgid "24-bit / 96 kHz — Iron Maiden · Stratego" +msgstr "24-bit / 96 kHz — Iron Maiden · Stratego" + +msgid "24-bit / 192 kHz — Toto · Africa" +msgstr "24-bit / 192 kHz — Toto · Africa" + +msgid "Welcome" +msgstr "Bienvenue" + +msgid "Check" +msgstr "Vérifier" + +msgid "DACs" +msgstr "DACs" + +msgid "Configure" +msgstr "Configurer" + +msgid "Test" +msgstr "Tester" + +msgid "Done" +msgstr "Terminé" + +msgid "HiFi Wizard — DAC Setup" +msgstr "Assistant HiFi — Configuration du DAC" + +msgid "Guided DAC Setup" +msgstr "Configuration Guidée du DAC" + +msgid "This wizard helps you configure your DAC for bit-perfect playback on Linux. QBZ never changes your system automatically — it auto-detects your DACs, shows you the exact commands and config to copy, and lets you test playback right here." +msgstr "Cet assistant vous aide à configurer votre DAC pour une lecture bit-perfect sous Linux. QBZ ne modifie jamais votre système automatiquement — il détecte vos DACs, vous montre les commandes et la configuration exactes à copier, et vous permet de tester la lecture ici même." + +msgid "You are responsible for your own backups. This is a helper, not a system recovery tool." +msgstr "Vous êtes responsable de vos propres sauvegardes. Ceci est une aide, pas un outil de récupération système." + +msgid "What to test with" +msgstr "Avec quoi tester" + +msgid "The built-in test plays four tracks spanning different bit-depths and sample rates (16/44.1, 24/44.1, 24/96, 24/192) so you can see your DAC switch. If you'd rather use your own queue, add one track of each kind first so every rate is exercised." +msgstr "Le test intégré lit quatre pistes couvrant différentes profondeurs de bits et fréquences d'échantillonnage (16/44.1, 24/44.1, 24/96, 24/192) afin que vous puissiez voir votre DAC changer. Si vous préférez utiliser votre propre file d'attente, ajoutez d'abord une piste de chaque type pour que toutes les fréquences soient testées." + +msgid "I understand and accept these terms" +msgstr "Je comprends et j'accepte ces conditions" + +msgid "Check Audio Stack" +msgstr "Vérifier la pile audio" + +msgid "Sandboxed — host check unavailable" +msgstr "En bac à sable — vérification de l'hôte indisponible" + +msgid "QBZ is packaged as Flatpak/Snap and can't inspect your host audio stack. If playback already works, you're set. Pick your distribution and init system below for the right setup commands." +msgstr "QBZ est empaqueté en Flatpak/Snap et ne peut pas inspecter la pile audio de votre hôte. Si la lecture fonctionne déjà, tout est prêt. Choisissez ci-dessous votre distribution et votre système d'init pour obtenir les bonnes commandes de configuration." + +msgid "Audio stack ready" +msgstr "Pile audio prête" + +msgid "Some packages are missing" +msgstr "Certains paquets sont manquants" + +msgid "Your distribution" +msgstr "Votre distribution" + +msgid "Init system (service commands)" +msgstr "Système d'init (commandes de service)" + +msgid "Which DACs do you want to configure?" +msgstr "Quels DACs voulez-vous configurer ?" + +msgid "Detecting your DACs…" +msgstr "Détection de vos DAC…" + +msgid "default" +msgstr "par défaut" + +msgid "Hide manual entry" +msgstr "Masquer la saisie manuelle" + +msgid "Can't see your DAC?" +msgstr "Vous ne voyez pas votre DAC ?" + +msgid "Find your DAC's PipeWire node name and paste it here:" +msgstr "Trouvez le nom de nœud PipeWire de votre DAC et collez-le ici :" + +msgid "1. Find your DAC under 'Audio > Sinks' and note its number" +msgstr "1. Trouvez votre DAC sous « Audio > Sinks » et notez son numéro" + +msgid "2. Run with that number to read the node.name" +msgstr "2. Exécutez avec ce numéro pour lire le node.name" + +msgid "Valid node name" +msgstr "Nom de nœud valide" + +msgid "Invalid — should contain alsa_output or alsa_input" +msgstr "Non valide — doit contenir alsa_output ou alsa_input" + +msgid "Review & Apply" +msgstr "Vérifier et appliquer" + +msgid "QBZ never writes these files" +msgstr "QBZ n'écrit jamais ces fichiers" + +msgid "QBZ already pins rate, sink and exclusive mode at runtime. These files only change system-wide behavior — copy and run them yourself if you want them. Back up first." +msgstr "QBZ fixe déjà la fréquence, le sink et le mode exclusif à l'exécution. Ces fichiers ne modifient que le comportement au niveau du système — copiez-les et exécutez-les vous-même si vous le souhaitez. Faites d'abord une sauvegarde." + +msgid "1. Back up your current PipeWire/WirePlumber config" +msgstr "1. Sauvegardez votre configuration PipeWire/WirePlumber actuelle" + +msgid "I created a backup" +msgstr "J'ai créé une sauvegarde" + +msgid "Sample-rate switching (PipeWire)" +msgstr "Changement de fréquence d'échantillonnage (PipeWire)" + +msgid "Per-app bit-perfect (PipeWire client)" +msgstr "Bit-perfect par application (client PipeWire)" + +msgid "Pin the DAC + rates (WirePlumber)" +msgstr "Fixer le DAC + les fréquences (WirePlumber)" + +msgid "I created the configuration files" +msgstr "J'ai créé les fichiers de configuration" + +msgid "3. Restart the audio services to load the config" +msgstr "3. Redémarrez les services audio pour charger la configuration" + +msgid "I restarted the audio services" +msgstr "J'ai redémarré les services audio" + +msgid "Test Playback" +msgstr "Tester la lecture" + +msgid "Plays four tracks at different bit-depths and sample rates through the selected DAC. Watch your DAC's display switch rates — and confirm it below." +msgstr "Lit quatre pistes à différentes profondeurs de bits et fréquences d'échantillonnage via le DAC sélectionné. Observez l'écran de votre DAC changer de fréquence — puis confirmez ci-dessous." + +msgid "Stop" +msgstr "Arrêter" + +msgid "Play test" +msgstr "Lancer le test" + +msgid "Use my current queue" +msgstr "Utiliser ma file d'attente actuelle" + +msgid "All set" +msgstr "Tout est prêt" + +msgid "Your DAC is ready for bit-perfect playback. If you applied the config files, restart the audio services (or log out and back in) for them to take effect." +msgstr "Votre DAC est prêt pour la lecture bit-perfect. Si vous avez appliqué les fichiers de configuration, redémarrez les services audio (ou déconnectez-vous puis reconnectez-vous) pour qu'ils prennent effet." + +msgid "Config files you can create:" +msgstr "Fichiers de configuration que vous pouvez créer :" + +msgid "Back" +msgstr "Retour" + +msgid "Start" +msgstr "Commencer" + +msgid "Finish" +msgstr "Terminer" + +msgid "Next" +msgstr "Suivant" + +msgid "Play now" +msgstr "Lire maintenant" + +msgid "Start playing now?" +msgstr "Lancer la lecture maintenant ?" + +msgid "Not now" +msgstr "Pas maintenant" + +msgid "Show location" +msgstr "Afficher l’emplacement" + +msgid "Remove from Local Library" +msgstr "Retirer de la bibliothèque locale" + +msgid "Forget download record" +msgstr "Oublier l’enregistrement du téléchargement" + +msgid "Could not open that folder." +msgstr "Impossible d’ouvrir ce dossier." + +msgid "Could not remove that library folder." +msgstr "Impossible de retirer ce dossier de la bibliothèque." + +msgid "Could not forget that download." +msgstr "Impossible d’oublier ce téléchargement." + +msgid "Create radio" +msgstr "Créer une radio" + +msgid "Add to library" +msgstr "Ajouter à la bibliothèque" + +msgid "Remove from playlist" +msgstr "Retirer de la liste de lecture" + +msgid "Share Song.link" +msgstr "Partager Song.link" + +msgid "Remove offline copy" +msgstr "Supprimer la copie hors ligne" + +msgid "Go to album" +msgstr "Aller à l'album" + +msgid "Go to artist" +msgstr "Aller à l'artiste" + +msgid "Track info" +msgstr "Infos sur la piste" + +msgid "selected" +msgstr "sélectionné" + +msgid "Artist is blacklisted" +msgstr "L'artiste est sur la liste noire" + +msgid "Main Artist" +msgstr "Artiste principal" + +msgid "Performer" +msgstr "Interprète" + +msgid "Composer" +msgstr "Compositeur" + +msgid "Release Name" +msgstr "Nom / Titre" + +msgid "Most popular" +msgstr "Plus populaire" + +msgid "No results." +msgstr "Aucun résultat." + +msgid "No results for" +msgstr "Aucun résultat pour" + +msgid "Top result" +msgstr "Meilleur résultat" + +msgid "View more" +msgstr "Voir plus" + +msgid "QOBUZ CONNECT" +msgstr "QOBUZ CONNECT" + +msgid "Connect diagnostics" +msgstr "Diagnostics Connect" + +msgid "Live session topology and a rolling event log for debugging Qobuz Connect at runtime." +msgstr "Topologie de session en direct et journal d'événements continu pour déboguer Qobuz Connect à l'exécution." + +msgid "Open diagnostics" +msgstr "Ouvrir les diagnostics" + +msgid "PLAYBACK" +msgstr "LECTURE" + +msgid "Continue playback after track ends" +msgstr "Continuer la lecture après la fin du titre" + +msgid "Keep playing the rest of the album or playlist instead of stopping." +msgstr "Continuer à lire le reste de l'album ou de la playlist au lieu de s'arrêter." + +msgid "Show track playing context" +msgstr "Afficher le contexte de lecture de la piste" + +msgid "Display the context-stack icon in the player." +msgstr "Afficher l'icône de la pile de contexte dans le lecteur." + +msgid "Gapless playback" +msgstr "Lecture sans coupure" + +msgid "Play consecutive same-format tracks without a gap." +msgstr "Lire les pistes consécutives au même format sans coupure." + +msgid "SESSION" +msgstr "SESSION" + +msgid "Restore session on startup" +msgstr "Restaurer la session au démarrage" + +msgid "Restore the queue and current track on the next launch." +msgstr "Restaurer la file d'attente et la piste en cours au prochain lancement." + +msgid "Resume playback position" +msgstr "Reprendre à la position sauvegardée" + +msgid "Also seek back to where the saved track left off." +msgstr "Revenir également à l'endroit où la piste enregistrée s'était arrêtée." + +msgid "STREAMING" +msgstr "STREAMING" + +msgid "Stream uncached tracks" +msgstr "Diffuser les pistes non mises en cache" + +msgid "Start uncached tracks via streaming instead of waiting for the full download." +msgstr "Démarrer les pistes non mises en cache en streaming au lieu d'attendre le téléchargement complet." + +msgid "Initial buffer size" +msgstr "Taille du tampon initial" + +msgid "Seconds of audio buffered before streaming playback starts." +msgstr "Secondes d'audio mises en mémoire tampon avant le début de la lecture en streaming." + +msgid "s" +msgstr "s" + +msgid "Streaming only" +msgstr "Streaming uniquement" + +msgid "Skip writing tracks to the local cache while streaming." +msgstr "Ne pas écrire les pistes dans le cache local pendant le streaming." + +msgid "When quality retries fail" +msgstr "Quand les tentatives de qualite echouent" + +msgid "What to do when every quality tier for a track is unavailable." +msgstr "Que faire lorsque aucun niveau de qualité n'est disponible pour une piste." + +msgid "Folder settings" +msgstr "Paramètres du dossier" + +msgid "Folder settings saved" +msgstr "Paramètres du dossier enregistrés" + +msgid "Could not save the folder settings." +msgstr "Impossible d'enregistrer les paramètres du dossier." + +msgid "Folder location" +msgstr "Emplacement du dossier" + +msgid "Change" +msgstr "Modifier" + +msgid "Checking..." +msgstr "Vérification..." + +msgid "Folder is accessible" +msgstr "Le dossier est accessible" + +msgid "Folder is not accessible" +msgstr "Le dossier n'est pas accessible" + +msgid "Display name" +msgstr "Nom d'affichage" + +msgid "Optional friendly name" +msgstr "Nom convivial facultatif" + +msgid "Shown instead of the full path in the folder list." +msgstr "Affiché à la place du chemin complet dans la liste des dossiers." + +msgid "Enabled" +msgstr "Activé" + +msgid "Disabled folders are skipped during scans." +msgstr "Les dossiers désactivés sont ignorés lors des analyses." + +msgid "Network folder" +msgstr "Dossier réseau" + +msgid "Mark this folder as a network share (excluded when offline)." +msgstr "Marquer ce dossier comme un partage réseau (exclu hors ligne)." + +msgid "Network filesystem type" +msgstr "Type de système de fichiers réseau" + +msgid "Auto-detect" +msgstr "Détection automatique" + +msgid "Last scanned" +msgstr "Dernière analyse" + +msgid "Scan this folder" +msgstr "Analyser ce dossier" + +msgid "FOLDER" +msgstr "DOSSIER" + +msgid "LAST SCAN" +msgstr "DERNIÈRE ANALYSE" + +msgid "STATUS" +msgstr "STATUT" + +msgid "Unavailable" +msgstr "Indisponible" + +msgid "Active" +msgstr "Active" + +msgid "LIBRARY FOLDERS" +msgstr "DOSSIERS DE LA BIBLIOTHÈQUE" + +msgid "Browse..." +msgstr "Parcourir..." + +msgid "Choose a music folder" +msgstr "Choisir un dossier de musique" + +msgid "Browse for a music folder, or type the full path." +msgstr "Parcourez pour trouver un dossier de musique, ou saisissez le chemin complet." + +msgid "Filter folders..." +msgstr "Filtrer les dossiers..." + +msgid "No folders yet. Add a folder to build your local library." +msgstr "Aucun dossier pour l'instant. Ajoutez un dossier pour constituer votre bibliothèque locale." + +msgid "No folders match your filter." +msgstr "Aucun dossier ne correspond à votre filtre." + +msgid "Scanning" +msgstr "Analyse en cours" + +msgid "MAINTENANCE" +msgstr "MAINTENANCE" + +msgid "Cleanup missing files" +msgstr "Nettoyer les fichiers manquants" + +msgid "Remove tracks whose files no longer exist on disk." +msgstr "Supprimer les pistes dont les fichiers n'existent plus sur le disque." + +msgid "Cleaning up..." +msgstr "Nettoyage en cours..." + +msgid "Cleanup" +msgstr "Nettoyage" + +msgid "DANGER ZONE" +msgstr "ZONE DANGER" + +msgid "Clear library database" +msgstr "Effacer la base de données de la bibliothèque" + +msgid "Remove all indexed tracks. Your audio files are not deleted." +msgstr "Supprimer toutes les pistes indexées. Vos fichiers audio ne sont pas supprimés." + +msgid "Clearing..." +msgstr "Effacement..." + +msgid "Clear all" +msgstr "Tout effacer" + +msgid "PLEX" +msgstr "PLEX" + +msgid "Connect a Plex Media Server on your local network." +msgstr "Connectez un Plex Media Server sur votre réseau local." + +msgid "Server address" +msgstr "Adresse du serveur" + +msgid "Only local network servers are supported." +msgstr "Seuls les serveurs du réseau local sont pris en charge." + +msgid "Enter a valid server address." +msgstr "Saisissez une adresse de serveur valide." + +msgid "http://127.0.0.1:32400" +msgstr "http://127.0.0.1:32400" + +msgid "Authorize" +msgstr "Autoriser" + +msgid "Generate a code and sign in to Plex in your browser." +msgstr "Générez un code et connectez-vous à Plex dans votre navigateur." + +msgid "Working..." +msgstr "Traitement..." + +msgid "Generate code" +msgstr "Générer un code" + +msgid "Link code" +msgstr "Code de liaison" + +msgid "Enter this code at the Plex sign-in page." +msgstr "Saisissez ce code sur la page de connexion de Plex." + +msgid "Copy code" +msgstr "Copier le code" + +msgid "Open Plex sign-in" +msgstr "Ouvrir la connexion Plex" + +msgid "Connected to Plex" +msgstr "Connecté à Plex" + +msgid "The code expired. Generate a new one." +msgstr "Le code a expiré. Générez-en un nouveau." + +msgid "Ping the saved server and report what answers." +msgstr "Envoie un ping au serveur enregistré et indique ce qui répond." + +msgid "Connected to {}" +msgstr "Connecté à {}" + +msgid "Could not reach the Plex server." +msgstr "Impossible de joindre le serveur Plex." + +msgid "Plex is not configured." +msgstr "Plex n'est pas configuré." + +msgid "Enter token manually" +msgstr "Saisir le jeton manuellement" + +msgid "Use an existing X-Plex-Token instead of the PIN flow." +msgstr "Utiliser un X-Plex-Token existant au lieu de la procédure par code PIN." + +msgid "Token" +msgstr "Jeton" + +msgid "X-Plex-Token" +msgstr "X-Plex-Token" + +msgid "Check connection" +msgstr "Vérifier la connexion" + +msgid "Verify the server is reachable." +msgstr "Vérifier que le serveur est accessible." + +msgid "Libraries" +msgstr "Bibliothèques" + +msgid "Fetch your Plex music libraries." +msgstr "Récupérer vos bibliothèques musicales Plex." + +msgid "Get libraries" +msgstr "Obtenir les bibliothèques" + +msgid "MUSIC LIBRARIES" +msgstr "BIBLIOTHÈQUES MUSICALES" + +msgid "({} tracks)" +msgstr "({} pistes)" + +msgid "Write metadata to Plex (experimental)" +msgstr "Écrire les métadonnées dans Plex (expérimental)" + +msgid "Allow QBZ to write track metadata back to your Plex server." +msgstr "Autoriser QBZ à réécrire les métadonnées des pistes sur votre serveur Plex." + +msgid "PLEX DANGER ZONE" +msgstr "ZONE DE DANGER PLEX" + +msgid "Disconnect" +msgstr "Déconnecter" + +msgid "Sign out of Plex and clear the local cache." +msgstr "Se déconnecter de Plex et vider le cache local." + +msgid "Clear cache" +msgstr "Effacer le cache" + +msgid "Remove cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Supprimer les bibliothèques et pistes Plex en cache. Votre connexion est conservée." + +msgid "OFFLINE MODE" +msgstr "MODE HORS LIGNE" + +msgid "Status" +msgstr "Statut" + +msgid "Captive portal detected — sign in to the network to get online." +msgstr "Portail captif détecté — connectez-vous au réseau pour accéder à Internet." + +msgid "Offline mode enabled" +msgstr "Mode hors ligne activé" + +msgid "No connection" +msgstr "Aucune connexion" + +msgid "Online" +msgstr "En ligne" + +msgid "Check now" +msgstr "Vérifier maintenant" + +msgid "Enable Offline Mode" +msgstr "Activer le mode hors ligne" + +msgid "Test the connection now instead of waiting for the next automatic check." +msgstr "Tester la connexion maintenant au lieu d'attendre la prochaine vérification automatique." + +msgid "Include sign-in credentials" +msgstr "Inclure les identifiants de connexion" + +msgid "The bundle will contain your tokens. Anyone who opens the file can sign in as you." +msgstr "L'archive contiendra vos jetons. Quiconque ouvre le fichier peut se connecter à votre place." + +msgid "Manually switch to offline mode even with internet." +msgstr "Passer manuellement en mode hors ligne même avec une connexion Internet." + +msgid "OFFLINE CACHE" +msgstr "CACHE HORS LIGNE" + +msgid "Manage offline cache" +msgstr "Gérer le cache hors-ligne" + +msgid "Browse and manage your downloaded tracks and albums." +msgstr "Parcourir et gérer vos pistes et albums téléchargés." + +msgid "Open manager" +msgstr "Ouvrir le gestionnaire" + +msgid "Cache folder" +msgstr "Dossier du cache" + +msgid "Open the folder where offline tracks are stored on disk." +msgstr "Ouvrir le dossier où les pistes hors ligne sont stockées sur le disque." + +msgid "Open folder" +msgstr "Ouvrir le dossier" + +msgid "Clear offline cache" +msgstr "Vider le cache hors ligne" + +msgid "Remove every downloaded track. This cannot be undone." +msgstr "Supprimer toutes les pistes téléchargées. Cette action est irréversible." + +msgid "LYRICS" +msgstr "PAROLES" + +msgid "Clear lyrics cache" +msgstr "Vider le cache des paroles" + +msgid "{} entries using {}" +msgstr "{} entrées utilisant {}" + +msgid "Manage" +msgstr "Gérer" + +msgid "Artist Blacklist" +msgstr "Liste noire des artistes" + +msgid "{} artist(s) blocked" +msgstr "{} artiste(s) bloqué(s)" + +msgid "{} artist(s) blocked (disabled)" +msgstr "{} artiste(s) bloqué(s) (désactivé)" + +msgid "Settings" +msgstr "Paramètres" + +msgid "Audio" +msgstr "Audio" + +msgid "Playback" +msgstr "Lecture" + +msgid "Appearance" +msgstr "Apparence" + +msgid "Offline" +msgstr "Hors ligne" + +msgid "Blacklist" +msgstr "Liste noire" + +msgid "Integrations" +msgstr "Intégrations" + +msgid "Developer" +msgstr "Développeur" + +msgid "Loading settings..." +msgstr "Chargement des paramètres..." + +msgid "Added {}" +msgstr "Ajouté {}" + +msgid "Blacklisted artists are hidden from search results, radio, suggestions, and similar artists." +msgstr "Les artistes sur liste noire sont cachés des résultats de recherche, de la radio, des suggestions et des artistes similaires." + +msgid "Clear All" +msgstr "Tout effacer" + +msgid "{} artists" +msgstr "{} artistes" + +msgid "Blacklist filtering is disabled. Blacklisted artists are shown everywhere until you re-enable it." +msgstr "Le filtrage de la liste noire est désactivé. Les artistes sur liste noire sont affichés partout jusqu'à ce que vous le réactiviez." + +msgid "Loading blacklist…" +msgstr "Chargement de la liste noire…" + +msgid "No blacklisted artists" +msgstr "Aucun artiste sur liste noire" + +msgid "To hide an artist, go to their page and use the menu." +msgstr "Pour masquer un artiste, accédez à sa page et utilisez le menu." + +msgid "No results for \"{}\"" +msgstr "Aucun résultat pour \"{}\"" + +msgid "Clear Blacklist?" +msgstr "Effacer la liste noire ?" + +msgid "This will remove all {} blacklisted artists. This cannot be undone." +msgstr "Cela supprimera les {} artistes de la liste noire. Cette action est irréversible." + +msgid "SCROBBLERS" +msgstr "SCROBBLERS" + +msgid "Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services." +msgstr "Envoyez vos écoutes vers Last.fm et ListenBrainz. Fonctionne pour les titres Qobuz, locaux et Plex. Cet interrupteur décide aussi si les Recommandations peuvent lire votre historique d’écoute depuis ces services." + +msgid "LAST.FM" +msgstr "LAST.FM" + +msgid "Scrobble to Last.fm" +msgstr "Scrobbler vers Last.fm" + +msgid "Signed in as {}." +msgstr "Connecté en tant que {}." + +msgid "Connect your Last.fm account to enable scrobbling." +msgstr "Connectez votre compte Last.fm pour activer le scrobbling." + +msgid "Connect" +msgstr "Connecter" + +msgid "Authorize QBZ in your browser, then click Finish." +msgstr "Autorisez QBZ dans votre navigateur, puis cliquez sur Terminer." + +msgid "Connect Last.fm" +msgstr "Connecter Last.fm" + +msgid "Open authorize page" +msgstr "Ouvrir la page d'autorisation" + +msgid "Disconnect Last.fm" +msgstr "Déconnecter Last.fm" + +msgid "Sign out of Last.fm." +msgstr "Se déconnecter de Last.fm." + +msgid "LISTENBRAINZ" +msgstr "LISTENBRAINZ" + +msgid "Scrobble to ListenBrainz" +msgstr "Scrobbler vers ListenBrainz" + +msgid "Paste your ListenBrainz user token to enable scrobbling." +msgstr "Collez votre jeton utilisateur ListenBrainz pour activer le scrobbling." + +msgid "User token" +msgstr "Token utilisateur" + +msgid "From listenbrainz.org/settings." +msgstr "Depuis listenbrainz.org/settings." + +msgid "ListenBrainz token" +msgstr "Jeton ListenBrainz" + +msgid "Save token" +msgstr "Enregistrer le token" + +msgid "Validates the token against ListenBrainz." +msgstr "Valide le token auprès de ListenBrainz." + +msgid "Validating..." +msgstr "Validation..." + +msgid "Connect ListenBrainz" +msgstr "Connecter ListenBrainz" + +msgid "Disconnect ListenBrainz" +msgstr "Déconnecter ListenBrainz" + +msgid "Sign out of ListenBrainz." +msgstr "Se déconnecter de ListenBrainz." + +msgid "DISCORD" +msgstr "DISCORD" + +msgid "Discord Rich Presence" +msgstr "Discord Rich Presence" + +msgid "Show what you're listening to as your Discord status. Opt-in — Discord must be running." +msgstr "Affiche ce que vous écoutez comme statut Discord. Facultatif — Discord doit être en cours d'exécution." + +msgid "Flatpak socket access" +msgstr "Accès au socket Flatpak" + +msgid "" +"Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" +msgstr "" +"Installation Flatpak et la présence ne s'affiche pas ? Accordez l'accès au socket IPC de Discord, puis redémarrez QBZ :\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" + +msgid "Streaming quality" +msgstr "Qualité de streaming" + +msgid "The quality tier QBZ requests for playback." +msgstr "Le niveau de qualité que QBZ demande pour la lecture." + +msgid "Limit quality to device" +msgstr "Limiter la qualité à l'appareil" + +msgid "Cap streaming quality at the output device's maximum sample rate." +msgstr "Limite la qualité de streaming à la fréquence d'échantillonnage maximale du périphérique de sortie." + +msgid "Maximum sample rate" +msgstr "Fréquence d'échantillonnage maximale" + +msgid "The highest sample rate streamed tracks may use." +msgstr "La fréquence d'échantillonnage la plus élevée que les pistes en streaming peuvent utiliser." + +msgid "OUTPUT" +msgstr "SORTIE" + +msgid "Audio backend" +msgstr "Backend audio" + +msgid "The audio stack QBZ routes playback through." +msgstr "La pile audio par laquelle QBZ achemine la lecture." + +msgid "Output device" +msgstr "Périphérique de sortie" + +msgid "The DAC or sound device that receives audio." +msgstr "Le DAC ou périphérique audio qui reçoit le son." + +msgid "ALSA plugin" +msgstr "Plugin ALSA" + +msgid "How ALSA opens the device — hw is bit-perfect, plughw converts." +msgstr "Comment ALSA ouvre le périphérique — hw est bit-perfect, plughw convertit." + +msgid "Hardware volume control" +msgstr "Contrôle du volume matériel" + +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Contrôle le volume via un mixeur exposé par le périphérique ALSA sélectionné. S’il n’est pas disponible, la lecture directe reste fixée à 100 %." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Ce périphérique ALSA ne possède aucun contrôle matériel du volume compatible. La lecture directe reste fixée à 100 %." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ n’a pas pu libérer complètement le périphérique audio précédent. Consultez le journal audio pour plus de détails." + +msgid "BIT-PERFECT" +msgstr "BIT-PERFECT" + +msgid "Exclusive mode" +msgstr "Mode exclusif" + +msgid "Lock the device so no other app can resample it." +msgstr "Verrouille le périphérique pour qu'aucune autre application ne puisse le rééchantillonner." + +msgid "Reserve DAC while running" +msgstr "Réserver le DAC pendant l'exécution" + +msgid "Hold the device reserved so other apps can't grab it." +msgstr "Maintient le périphérique réservé pour que d'autres applications ne puissent pas le prendre." + +msgid "DAC passthrough" +msgstr "Passthrough du DAC" + +msgid "Send the bitstream untouched to the DAC." +msgstr "Envoie le flux binaire intact au DAC." + +msgid "Force bit-perfect" +msgstr "Forcer le bit-perfect" + +msgid "Pin the PipeWire quantum and rate for the active track." +msgstr "Fixe le quantum et la fréquence PipeWire pour la piste active." + +msgid "Allow quality fallback" +msgstr "Autoriser le repli de qualité" + +msgid "Drop to a lower tier when the requested one is unavailable." +msgstr "Passe à un niveau inférieur lorsque celui demandé n'est pas disponible." + +msgid "HiFi Wizard" +msgstr "Assistant HiFi" + +msgid "Auto-detect your DACs and set up bit-perfect playback, step by step." +msgstr "Détecte automatiquement vos DAC et configure la lecture bit-perfect, étape par étape." + +msgid "Open Wizard" +msgstr "Ouvrir l'assistant" + +msgid "STARTUP" +msgstr "DÉMARRAGE" + +msgid "Sync audio settings on startup" +msgstr "Synchroniser la configuration audio au démarrage" + +msgid "Reload saved audio settings into the player when QBZ launches." +msgstr "Recharge les réglages audio enregistrés dans le lecteur au démarrage de QBZ." + +msgid "Lock output device" +msgstr "Verrouiller le périphérique de sortie" + +msgid "Keep external routing intact — skip switching the default sink." +msgstr "Conserve le routage externe intact — ne change pas le sink par défaut." + +msgid "THEME" +msgstr "THÈME" + +msgid "Theme" +msgstr "Thème" + +msgid "Album header gradient" +msgstr "Dégradé dans l'en-tête de l'album" + +msgid "Use artwork-derived blur as a backdrop in album and artist detail views." +msgstr "Utilise un flou dérivé de la pochette comme arrière-plan dans les vues détaillées d'album et d'artiste." + +msgid "Intelligent Search" +msgstr "Recherche intelligente" + +msgid "Smart search cache, ranking, and the search preview dropdown. On by default." +msgstr "Cache de recherche intelligent, classement et menu déroulant d'aperçu de recherche. Activé par défaut." + +msgid "Generate a color theme from your system wallpaper or a custom image" +msgstr "Génère un thème de couleurs à partir de votre fond d'écran ou d'une image personnalisée" + +msgid "Select Image..." +msgstr "Sélectionner une image..." + +msgid "Detected: " +msgstr "Détecté : " + +msgid "Experimental: theme may not match your system exactly." +msgstr "Expérimental : le thème peut ne pas correspondre exactement à votre système." + +msgid "Regenerate" +msgstr "Régénérer" + +msgid "TYPOGRAPHY & LANGUAGE" +msgstr "TYPOGRAPHIE ET LANGUE" + +msgid "Language" +msgstr "Langue" + +msgid "UI Scale" +msgstr "Échelle de l'interface" + +msgid "Immersive search" +msgstr "Recherche immersive" + +msgid "What selecting a result in the Immersive search does. Disabled turns the in-immersive search off." +msgstr "Ce que fait la sélection d'un résultat dans la recherche immersive. Désactivé coupe la recherche dans le mode immersif." + +msgid "Immersive default view" +msgstr "Vue par défaut du mode immersif" + +msgid "Which immersive view opens by default. 'Remember last' restores your last view." +msgstr "Quelle vue immersive s'ouvre par défaut. « Mémoriser la dernière » restaure votre dernière vue." + +msgid "LIBRARY & VISUALS" +msgstr "BIBLIOTHÈQUE ET VISUELS" + +msgid "Show navigation in sidebar" +msgstr "Afficher la navigation dans la barre latérale" + +msgid "Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar." +msgstr "Déplace les sections Découvrir, Bibliothèque, Bibliothèque locale et Mon QBZ de l'en-tête vers la barre latérale." + +msgid "My QBZ" +msgstr "My QBZ" + +msgid "Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default." +msgstr "Renomme le hub Mon QBZ. Laissez le nom vide (ou cliquez sur réinitialiser) pour restaurer la valeur par défaut." + +msgid "Show playlist cover collages in sidebar" +msgstr "Afficher les collages de pochettes des playlists dans la barre latérale" + +msgid "Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images." +msgstr "Affiche une miniature 2×2 des pochettes de pistes à côté de chaque playlist. À désactiver sur les machines peu puissantes pour éviter les images supplémentaires." + +msgid "Show album artwork in Local Library track list" +msgstr "Afficher la pochette dans la liste des pistes de la Bibliothèque locale" + +msgid "Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost." +msgstr "Affiche la miniature de la pochette à côté de chaque piste dans les vues Pistes et Dossiers. Désactivé par défaut — les grandes bibliothèques paient un coût de décodage d'image par ligne." + +msgid "NOTIFICATIONS" +msgstr "NOTIFICATIONS" + +msgid "In-app toasts notifications" +msgstr "Notifications toast intégrées" + +msgid "System Notifications" +msgstr "Notifications système" + +msgid "WINDOW TITLE" +msgstr "TITRE DE LA FENÊTRE" + +msgid "Show track in window title" +msgstr "Afficher la piste dans le titre" + +msgid "Title template" +msgstr "Modèle du titre" + +msgid "Placeholders: {{artist}}, {{track}}, {{album}}" +msgstr "Espaces réservés : {{artist}}, {{track}}, {{album}}" + +msgid "TITLE BAR" +msgstr "BARRE DE TITRE" + +msgid "Use system title bar" +msgstr "Utiliser la barre de titre du système" + +msgid "Replace QBZ custom title bar with your system's native window decorations. Changing this will restart the app." +msgstr "Remplacer la barre de titre personnalisée de QBZ par les décorations natives de votre système. Ce changement redémarrera l'application." + +msgid "Hide title bar" +msgstr "Masquer la barre de titre" + +msgid "Hide the title bar completely (for tiling window manager users)" +msgstr "Masquer complètement la barre de titre (pour les utilisateurs de gestionnaires de fenêtres en mosaïque)" + +msgid "WINDOW CONTROLS" +msgstr "COMMANDES DE FENÊTRE" + +msgid "Window controls position" +msgstr "Position des contrôles de fenêtre" + +msgid "Place the window control buttons on the left or right side of the title bar" +msgstr "Placer les boutons de contrôle de fenêtre à gauche ou à droite de la barre de titre" + +msgid "Show window controls" +msgstr "Afficher les contrôles de fenêtre" + +msgid "Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these." +msgstr "Afficher les boutons minimiser, maximiser et fermer dans la barre de titre. Désactiver si votre gestionnaire de fenêtres les gère." + +msgid "PLAYER VIEWS" +msgstr "VUES DU LECTEUR" + +msgid "Show volume +/- buttons" +msgstr "Afficher les boutons de volume +/-" + +msgid "Add discrete plus and minus buttons next to the volume slider in the player bar." +msgstr "Ajoute des boutons plus et moins distincts à côté du curseur de volume dans la barre du lecteur." + +msgid "Mini player default view" +msgstr "Vue par défaut du mini-lecteur" + +msgid "Startup page" +msgstr "Page de démarrage" + +msgid "Choose which page to show when the app starts" +msgstr "Page à afficher au lancement de l'application" + +msgid "Show Purchases" +msgstr "Afficher les achats" + +msgid "Show the Purchases section in the sidebar for browsing and downloading your purchased music" +msgstr "Afficher la section Achats dans la barre latérale pour parcourir et télécharger votre musique achetée" + +msgid "MENU BAR" +msgstr "BARRE DE MENUS" + +msgid "SYSTEM TRAY" +msgstr "ZONE DE NOTIFICATION" + +msgid "Enable tray icon" +msgstr "Activer l'icône dans la barre système" + +msgid "Show icon in system tray (requires restart)" +msgstr "Afficher l'icône dans la barre système (nécessite un redémarrage)" + +msgid "Close to tray" +msgstr "Réduire dans la barre d'état système" + +msgid "Keep playing in the menu bar instead of quitting when you close the window" +msgstr "Continue la lecture dans la barre de menus au lieu de quitter lorsque vous fermez la fenêtre" + +msgid "Keep playing in the tray instead of quitting when you close the window" +msgstr "Continue la lecture dans la zone de notification au lieu de quitter lorsque vous fermez la fenêtre" + +msgid "Hide Dock icon when closed to menu bar" +msgstr "Masquer l'icône du Dock lors de la fermeture dans la barre de menus" + +msgid "Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)" +msgstr "Exécuter en tant qu'application uniquement dans la barre de menus lorsque la fenêtre est fermée. Désactivé : l'icône du Dock est conservée (comme Spotify)" + +msgid "Tray icon variant" +msgstr "Variante de l'icône" + +msgid "Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo" +msgstr "Choisis une icône monochrome adaptée à ta barre (Plasma, la barre supérieure toujours sombre de GNOME) ou le logo vinyle couleur" + +msgid "IMMERSIVE" +msgstr "IMMERSIF" + +msgid "Background effect" +msgstr "Effet de fond" + +msgid "Controls how the blurred artwork background is rendered in immersive mode." +msgstr "Contrôle le rendu du fond flou dans le mode immersif." + +msgid "Immersive Panels" +msgstr "Panneaux immersifs" + +msgid "Enable or disable individual immersive view panels" +msgstr "Activez ou désactivez individuellement les panneaux de la vue immersive" + +msgid "Disabled panels are hidden from the visualizer menu so you don't land on them by mistake." +msgstr "Les panneaux désactivés sont masqués du menu du visualiseur pour éviter d'y arriver par erreur." + +msgid "Animation Frame Rate" +msgstr "Fréquence d'affichage" + +msgid "Per-animation FPS control for immersive mode" +msgstr "Contrôle la fréquence d'affichage des animations en mode immersif" + +msgid "Configure frame rate per animation. Lower values save CPU. Requires re-entering immersive mode to apply." +msgstr "Configurez la fréquence d'affichage (IPS) des animations. Des valeurs basses économisent le CPU. Nécessite de re-entrer en mode immersif." + +msgid "PLAYLISTS" +msgstr "PLAYLISTS" + +msgid "No playlists yet." +msgstr "Aucune playlist pour le moment." + +msgid "Manage playlists" +msgstr "Gérer les playlists" + +msgid "Remove from queue" +msgstr "Retirer de la file d'attente" + +msgid "Stop after this" +msgstr "Arrêter après ce titre" + +msgid "Remove all after" +msgstr "Tout retirer après" + +msgid "History" +msgstr "Historique" + +msgid "NOW PLAYING" +msgstr "EN LECTURE" + +msgid "UP NEXT ({} of {}, {} left)" +msgstr "À SUIVRE ({} sur {}, {} restant(s))" + +msgid "Page {} of {}" +msgstr "Page {} sur {}" + +msgid "Your queue is empty" +msgstr "Votre file d'attente est vide" + +msgid "Play an album or track to get started" +msgstr "Lancez un album ou un titre pour commencer" + +msgid "No tracks match your search" +msgstr "Aucun titre ne correspond à votre recherche" + +msgid "RECENTLY PLAYED" +msgstr "ÉCOUTÉ RÉCEMMENT" + +msgid "Nothing played yet" +msgstr "Rien n'a encore été lu" + +msgid "Search queue" +msgstr "Rechercher dans la file d'attente" + +msgid "Auto-follow" +msgstr "Défilement auto" + +msgid "Font" +msgstr "Police" + +msgid "System" +msgstr "Système" + +msgid "LINE Seed JP" +msgstr "LINE Seed JP" + +msgid "Montserrat" +msgstr "Montserrat" + +msgid "Noto Sans" +msgstr "Noto Sans" + +msgid "Source Sans 3" +msgstr "Source Sans 3" + +msgid "Size" +msgstr "Taille" + +msgid "S" +msgstr "S" + +msgid "M" +msgstr "M" + +msgid "L" +msgstr "L" + +msgid "XL" +msgstr "XL" + +msgid "Active color" +msgstr "Couleur active" + +msgid "Uppercase" +msgstr "Majuscules" + +msgid "Dimming" +msgstr "Atténuation" + +msgid "Soft" +msgstr "Légère" + +msgid "Strong" +msgstr "Forte" + +msgid "Copy lyrics" +msgstr "Copier les paroles" + +msgid "Reset" +msgstr "Réinitialiser" + +msgid "Previous" +msgstr "Précédent" + +msgid "Pause" +msgstr "Pause" + +msgid "Repeat" +msgstr "Répéter" + +msgid "Add to…" +msgstr "Ajouter à…" + +msgid "Add album to collection" +msgstr "Ajouter l'album à la collection" + +msgid "Remove album from collection" +msgstr "Retirer l'album de la collection" + +msgid "Remove from library" +msgstr "Retirer de la bibliothèque" + +msgid "{} (not playable)" +msgstr "{} (non lisible)" + +msgid "Cast" +msgstr "Diffuser" + +msgid "Connected to" +msgstr "Connecté à" + +msgid "Streaming {} · {}" +msgstr "Diffusion {} · {}" + +msgid "No devices found" +msgstr "Aucun appareil trouvé" + +msgid "Looking for Chromecast & DLNA on your network" +msgstr "Recherche de Chromecast et DLNA sur votre réseau" + +msgid "Display settings" +msgstr "Options d'affichage" + +msgid "Fetching lyrics..." +msgstr "Récupération des paroles..." + +msgid "No lyrics available" +msgstr "Aucune parole disponible" + +msgid "Missing lyrics? You can contribute them to LRCLIB:" +msgstr "Paroles manquantes ? Vous pouvez les ajouter à LRCLIB :" + +msgid "Lyrics unavailable offline" +msgstr "Paroles indisponibles hors ligne" + +msgid "Lyrics source" +msgstr "Source des paroles" + +msgid "Qobuz Connect" +msgstr "Qobuz Connect" + +msgid "Connected" +msgstr "Connecté" + +msgid "Disconnected" +msgstr "Déconnecté" + +msgid "Playing on: {}" +msgstr "Lecture sur : {}" + +msgid "Looking for devices…" +msgstr "Recherche d'appareils…" + +msgid "Make sure your renderer is on the same network" +msgstr "Assurez-vous que votre renderer est sur le même réseau" + +msgid "Play here" +msgstr "Lire ici" + +msgid "Audio settings" +msgstr "Paramètres audio" + +msgid "Normalization" +msgstr "Normalisation" + +msgid "Level loudness across tracks" +msgstr "Niveler le volume entre les pistes" + +msgid "Gapless" +msgstr "Sans coupure" + +msgid "Seamless track transitions" +msgstr "Transitions fluides entre les pistes" + +msgid "Now Playing view" +msgstr "Vue Lecture en cours" + +msgid "New" +msgstr "Nouvelle" + +msgid "Classic" +msgstr "Classique" + +msgid "Miniplayer" +msgstr "Mini-lecteur" + +msgid "Immersive" +msgstr "Immersif" + +msgid "Unmute" +msgstr "Réactiver le son" + +msgid "Mute" +msgstr "Muet" + +msgid "Volume" +msgstr "Volume" + +msgid "Qobuz Connect: On" +msgstr "Qobuz Connect : Activé" + +msgid "Qobuz Connect: Off" +msgstr "Qobuz Connect : Désactivé" + +msgid "Volume down" +msgstr "Baisser le volume" + +msgid "Volume up" +msgstr "Augmenter le volume" + +msgid "Nothing playing" +msgstr "Aucune lecture en cours" + +msgid "Go to where this is playing from" +msgstr "Accéder à la source de lecture" + +msgid "Casting" +msgstr "Diffusion en cours" + +msgid "Output mode" +msgstr "Mode de sortie" + +msgid "Move to root" +msgstr "Déplacer à la racine" + +msgid "No folders yet." +msgstr "Aucun dossier pour le moment." + +msgid "Discover" +msgstr "Découvrir" + +msgid "Mixtapes" +msgstr "Mixtapes" + +msgid "Collections" +msgstr "Collections" + +msgid "Search playlists" +msgstr "Rechercher des playlists" + +msgid "Sort by" +msgstr "Trier par" + +msgid "Name (A-Z)" +msgstr "Nom (A-Z)" + +msgid "# of tracks" +msgstr "Nbre de pistes" + +msgid "Refresh" +msgstr "Actualiser" + +msgid "Logged out" +msgstr "Déconnecté" + +msgid "Offline (manual)" +msgstr "Hors ligne (manuel)" + +msgid "Offline (hard)" +msgstr "Hors ligne (forcé)" + +msgid "You're signed out. Sign-in needs a connection." +msgstr "Vous êtes déconnecté. La connexion nécessite un accès à Internet." + +msgid "Connection available — sign back in to Qobuz." +msgstr "Connexion disponible — reconnectez-vous à Qobuz." + +msgid "You're signed out — sign back in to Qobuz." +msgstr "Vous êtes déconnecté — reconnectez-vous à Qobuz." + +msgid "No internet connection — your local library and downloads keep working." +msgstr "Aucune connexion Internet — votre bibliothèque locale et vos téléchargements continuent de fonctionner." + +msgid "Sign in" +msgstr "Se connecter" + +msgid "Offline mode" +msgstr "Mode hors ligne" + +msgid "Enter" +msgstr "Entrée" + +msgid "Guest" +msgstr "Invité" + +msgid "Not signed in" +msgstr "Non connecté" + +msgid "Keyboard Shortcuts" +msgstr "Raccourcis clavier" + +msgid "Documentation" +msgstr "Documentation" + +msgid "Report an Issue" +msgstr "Signaler un problème" + +msgid "Log Out" +msgstr "Se déconnecter" + +msgid "Create Mixtape" +msgstr "Créer un Mixtape" + +msgid "Create Collection" +msgstr "Créer une Collection" + +msgid "NAME" +msgstr "NOM" + +msgid "KIND" +msgstr "TYPE" + +msgid "+ New Mixtape" +msgstr "+ Nouveau Mixtape" + +msgid "+ New Collection" +msgstr "+ Nouvelle Collection" + +msgid "Rename" +msgstr "Renommer" + +msgid "Edit description" +msgstr "Modifier la description" + +msgid "Delete \"{}\"? This cannot be undone." +msgstr "Supprimer « {} » ? Cette action est irréversible." + +msgid "All ({})" +msgstr "Tous ({})" + +msgid "Random queue" +msgstr "File aléatoire" + +msgid "Create a random queue with songs from this collection. How many songs do you want?" +msgstr "Crée une file aléatoire avec des chansons de cette collection. Combien de chansons veux-tu ?" + +msgid "Number of songs" +msgstr "Nombre de chansons" + +msgid "{} songs available" +msgstr "{} chansons disponibles" + +msgid "No mixtapes yet" +msgstr "Aucun mixtape pour le moment" + +msgid "Position" +msgstr "Position" + +msgid "Items" +msgstr "Éléments" + +msgid "Recently updated" +msgstr "Récemment mis à jour" + +msgid "No results found" +msgstr "Aucun résultat trouvé" + +msgid "No collections yet" +msgstr "Aucune collection pour le moment" + +msgid "All kinds" +msgstr "Tous les types" + +msgid "Artist Collections" +msgstr "Artist Collections" + +msgid "Remove from collection" +msgstr "Retirer de la collection" + +msgid "Not found" +msgstr "Non trouvé" + +msgid "Upload cover" +msgstr "Téléverser une pochette" + +msgid "Clear custom cover" +msgstr "Supprimer la pochette personnalisée" + +msgid "Album shuffle" +msgstr "Aléatoire par album" + +msgid "In order" +msgstr "Dans l'ordre" + +msgid "Convert to Collection" +msgstr "Convertir en Collection" + +msgid "Convert to Mixtape" +msgstr "Convertir en Mixtape" + +msgid "No items yet. Add albums, tracks, or playlists from their detail pages." +msgstr "Aucun élément pour le moment. Ajoutez des albums, des titres ou des playlists depuis leurs pages de détails." + +msgid "1 filter" +msgstr "1 filtre" + +msgid "{} filters" +msgstr "{} filtres" + +msgid "Type" +msgstr "Type" + +msgid "All types" +msgstr "Tous" + +msgid "Track" +msgstr "Piste" + +msgid "Qobuz" +msgstr "Qobuz" + +msgid "Plex" +msgstr "Plex" + +msgid "Item" +msgstr "Élément" + +msgid "Already added" +msgstr "Déjà ajouté" + +msgid "ADD TO MIXTAPE/COLLECTION" +msgstr "AJOUTER À LA MIXTAPE/COLLECTION" + +msgid "No matches." +msgstr "Aucune correspondance." + +msgid "No mixtapes or collections yet." +msgstr "Aucune mixtape ni collection pour le moment." + +msgid "EP" +msgstr "EP" + +msgid "Single" +msgstr "Single" + +msgid "Live" +msgstr "Live" + +msgid "Compilation" +msgstr "Compilation" + +msgid "Use auto-detected" +msgstr "Utiliser la détection auto" + +msgid "alternate" +msgstr "alternative" + +msgid "compilation" +msgstr "compilation" + +msgid "BUILD ARTIST COLLECTION" +msgstr "CRÉER UNE COLLECTION D'ARTISTE" + +msgid "ORDER BY" +msgstr "TRIER PAR" + +msgid "Release date" +msgstr "Date de sortie" + +msgid "Manual" +msgstr "Manuel" + +msgid "Loading..." +msgstr "Chargement..." + +msgid "ALBUM" +msgstr "ALBUM" + +msgid "{} of {} album selected" +msgid_plural "{} of {} albums selected" +msgstr[0] "{} sur {} album sélectionné" +msgstr[1] "{} sur {} albums sélectionnés" + +msgid "Now Playing" +msgstr "Lecture en cours" + +msgid "No lyrics" +msgstr "Aucune parole" + +msgid "No track selected" +msgstr "Aucun titre sélectionné" + +msgid "Loading suggestions..." +msgstr "Chargement des suggestions..." + +msgid "Failed to load suggestions" +msgstr "Échec du chargement des suggestions" + +msgid "Recommended tracks" +msgstr "Titres recommandés" + +msgid "Exit Immersive" +msgstr "Quitter le mode immersif" + +msgid "FOCUS" +msgstr "FOCUS" + +msgid "Plasma" +msgstr "Plasma" + +msgid "Aurora" +msgstr "Aurora" + +msgid "SPLIT" +msgstr "DIVISER" + +msgid "Track Info" +msgstr "Infos du titre" + +msgid "Suggestions" +msgstr "Suggestions" + +msgid "UP NEXT" +msgstr "À SUIVRE" + +msgid "Tracks you play will appear here" +msgstr "Les titres que vous écoutez apparaîtront ici" + +msgid "No track playing" +msgstr "Aucun titre en cours de lecture" + +msgid "Queue is empty" +msgstr "La file d'attente est vide" + +msgid "Search albums & tracks…" +msgstr "Rechercher albums et pistes…" + +msgid "Acclaimed Releases" +msgstr "Sorties saluées" + +msgid "No award-winning releases yet." +msgstr "Aucune sortie récompensée pour le moment." + +msgid "Award" +msgstr "Récompense" + +msgid "Unfollow award" +msgstr "Ne plus suivre" + +msgid "Follow award" +msgstr "Suivre la récompense" + +msgid "See All" +msgstr "Voir tout" + +msgid "Other awards" +msgstr "Autres récompenses" + +msgid "{} item needs attention before bit-perfect playback will work." +msgid_plural "{} items need attention before bit-perfect playback will work." +msgstr[0] "{} élément nécessite votre attention avant que la lecture bit-perfect fonctionne." +msgstr[1] "{} éléments nécessitent votre attention avant que la lecture bit-perfect fonctionne." + +msgid "Removed {} artist from blacklist" +msgid_plural "Removed {} artists from blacklist" +msgstr[0] "{} artiste retiré de la liste noire" +msgstr[1] "{} artistes retirés de la liste noire" + +msgid "Removed {} folder" +msgid_plural "Removed {} folders" +msgstr[0] "{} dossier supprimé" +msgstr[1] "{} dossiers supprimés" + +msgid "Scan complete ({} file skipped)" +msgid_plural "Scan complete ({} files skipped)" +msgstr[0] "Analyse terminée ({} fichier ignoré)" +msgstr[1] "Analyse terminée ({} fichiers ignorés)" + +msgid "{} album" +msgid_plural "{} albums" +msgstr[0] "{} album" +msgstr[1] "{} albums" + +msgid "{} item" +msgid_plural "{} items" +msgstr[0] "{} élément" +msgstr[1] "{} éléments" + +msgid "{} duplicate skipped" +msgid_plural "{} duplicates skipped" +msgstr[0] "{} doublon ignoré" +msgstr[1] "{} doublons ignorés" + +msgid "Removed {} item" +msgid_plural "Removed {} items" +msgstr[0] "{} élément supprimé" +msgstr[1] "{} éléments supprimés" + +msgid "Caching {} track offline…" +msgid_plural "Caching {} tracks offline…" +msgstr[0] "Mise en cache hors ligne de {} piste…" +msgstr[1] "Mise en cache hors ligne de {} pistes…" + +msgid "(Re)start the PipeWire audio services" +msgstr "(Re)démarrer les services audio PipeWire" + +msgid "({} local)" +msgstr "({} local)" + +msgid "176.4 kHz" +msgstr "176.4 kHz" + +msgid "192 kHz" +msgstr "192 kHz" + +msgid "352.8 kHz" +msgstr "352.8 kHz" + +msgid "384 kHz" +msgstr "384 kHz" + +msgid "44.1 kHz" +msgstr "44.1 kHz" + +msgid "48 kHz" +msgstr "48 kHz" + +msgid "88.2 kHz" +msgstr "88.2 kHz" + +msgid "96 kHz" +msgstr "96 kHz" + +msgid "A fresh mix every week." +msgstr "Un nouveau mix chaque semaine." + +msgid "Add a folder before scanning" +msgstr "Ajoutez un dossier avant l'analyse" + +msgid "Added to queue" +msgstr "Ajouté à la file d'attente" + +msgid "Added {} to {}" +msgstr "{} ajouté à {}" + +msgid "Added {} to {} ({})" +msgstr "{} ajouté à {} ({})" + +msgid "Adding tracks to playlist..." +msgstr "Ajout des titres a la playlist..." + +msgid "Adding tracks: {} / {}" +msgstr "Ajout de pistes : {} / {}" + +msgid "Album folder not found on disk" +msgstr "Dossier de l'album introuvable sur le disque" + +msgid "Album metadata saved" +msgstr "Métadonnées de l'album enregistrées" + +msgid "Album title is required" +msgstr "Le titre de l'album est requis" + +msgid "Albums of the Week" +msgstr "Albums de la semaine" + +msgid "Albums on Local Library" +msgstr "Albums dans la bibliothèque locale" + +msgid "Already in {}" +msgstr "Déjà dans {}" + +msgid "Always skip track" +msgstr "Toujours passer le titre" + +msgid "Always try lowest quality" +msgstr "Toujours essayer la qualite la plus basse" + +msgid "April" +msgstr "Avril" + +msgid "Are you absolutely sure?" +msgstr "Êtes-vous absolument sûr ?" + +msgid "Artist Spotlight" +msgstr "Artiste à l'honneur" + +msgid "Artists on Local Library" +msgstr "Artistes dans la bibliothèque locale" + +msgid "Ask me" +msgstr "Me demander" + +msgid "August" +msgstr "Août" + +msgid "Authorize QBZ in your browser, then click \"Finish\"" +msgstr "Autorisez QBZ dans votre navigateur, puis cliquez sur « Terminer »" + +msgid "Available tracks only — offline" +msgstr "Pistes disponibles uniquement — hors ligne" + +msgid "Bit-perfect (Hardware / Digital)" +msgstr "Bit-perfect (Hardware / Numérique)" + +msgid "Blacklist disabled" +msgstr "Liste noire désactivée" + +msgid "Blacklist enabled" +msgstr "Liste noire activée" + +msgid "Booklet open task failed: {}" +msgstr "Échec de l'ouverture du livret : {}" + +msgid "COLLECTION" +msgstr "COLLECTION" + +msgid "Cache cleared" +msgstr "Cache vidé" + +msgid "Cached for offline" +msgstr "Mis en cache pour hors ligne" + +msgid "Cannot convert this kind" +msgstr "Impossible de convertir ce type" + +msgid "Checked {} tracks - all OK" +msgstr "{} pistes vérifiées - tout est OK" + +msgid "Checking playlist link..." +msgstr "Vérification du lien de la playlist..." + +msgid "Checking your audio stack…" +msgstr "Vérification de votre pile audio…" + +msgid "Couldn't load the test tracks (offline?)" +msgstr "Impossible de charger les pistes de test (hors ligne ?)" + +msgid "Choose a cover image" +msgstr "Choisissez une image de couverture" + +msgid "Choose a folder to play" +msgstr "Sélectionne un dossier à lire" + +msgid "Choose a {} icon" +msgstr "Choisissez une icône {}" + +msgid "Cleaning up missing files..." +msgstr "Nettoyage des fichiers manquants..." + +msgid "Cleanup failed" +msgstr "Échec du nettoyage" + +msgid "Clear Plex cache?" +msgstr "Vider le cache Plex ?" + +msgid "Clear library database?" +msgstr "Vider la base de données de la bibliothèque ?" + +msgid "Code copied" +msgstr "Code copié" + +msgid "Compilations" +msgstr "Compilations" + +msgid "Connected as {}" +msgstr "Connecté en tant que {}" + +msgid "Connected to {} (v{})" +msgstr "Connecté à {} (v{})" + +msgid "Continue Listening" +msgstr "Continuer l'écoute" + +msgid "Converted" +msgstr "Converti" + +msgid "Couldn't add folder" +msgstr "Impossible d'ajouter le dossier" + +msgid "Couldn't change folder location (path may already exist)" +msgstr "Impossible de modifier l'emplacement du dossier (le chemin existe peut-être déjà)" + +msgid "Couldn't clean up missing files" +msgstr "Impossible de nettoyer les fichiers manquants" + +msgid "Couldn't clear the Plex cache" +msgstr "Impossible de vider le cache Plex" + +msgid "Couldn't clear the library database" +msgstr "Impossible de vider la base de données de la bibliothèque" + +msgid "Couldn't clear the offline cache" +msgstr "Impossible de vider le cache hors ligne" + +msgid "Couldn't create the Qobuz playlist" +msgstr "Impossible de créer la playlist Qobuz" + +msgid "Couldn't load library folders" +msgstr "Impossible de charger les dossiers de la bibliothèque" + +msgid "Couldn't load that album" +msgstr "Impossible de charger cet album" + +msgid "Couldn't load that playlist" +msgstr "Impossible de charger cette playlist" + +msgid "Couldn't load that track" +msgstr "Impossible de charger cette piste" + +msgid "Couldn't load this album" +msgstr "Impossible de charger cet album" + +msgid "Couldn't load this artist" +msgstr "Impossible de charger cet artiste" + +msgid "Couldn't load this collection" +msgstr "Impossible de charger cette collection" + +msgid "Couldn't load this playlist" +msgstr "Impossible de charger cette playlist" + +msgid "Couldn't open that folder" +msgstr "Impossible d'ouvrir ce dossier" + +msgid "Couldn't save folder settings" +msgstr "Impossible d'enregistrer les paramètres du dossier" + +msgid "Couldn't save metadata: {}" +msgstr "Impossible d'enregistrer les métadonnées : {}" + +msgid "Cover removed" +msgstr "Pochette supprimée" + +msgid "Cover updated" +msgstr "Pochette mise à jour" + +msgid "Creating playlist..." +msgstr "Creation de la playlist..." + +msgid "Critics' Picks" +msgstr "Choix des critiques" + +msgid "DAC: {} · {} · {} ch" +msgstr "DAC : {} · {} · {} can." + +msgid "December" +msgstr "Décembre" + +msgid "Defaults" +msgstr "Valeurs par défaut" + +msgid "Disconnect from Plex?" +msgstr "Se déconnecter de Plex ?" + +msgid "EPs & Singles" +msgstr "EPs et singles" + +msgid "Enable PipeWire in your NixOS configuration" +msgstr "Activez PipeWire dans votre configuration NixOS" + +msgid "Enter a title or artist to search" +msgstr "Saisissez un titre ou un artiste pour rechercher" + +msgid "Enter code {} at the Plex sign-in page" +msgstr "Saisissez le code {} sur la page de connexion Plex" + +msgid "Error: {}" +msgstr "Erreur : {}" + +msgid "Error: {} (did you authorize in the browser?)" +msgstr "Erreur : {} (avez-vous autorisé dans le navigateur ?)" + +msgid "Essentials by Genre" +msgstr "Incontournables par genre" + +msgid "Every track needs a title" +msgstr "Chaque piste doit avoir un titre" + +msgid "Failed to cast track" +msgstr "Échec de la diffusion de la piste" + +msgid "Failed to change play mode" +msgstr "Échec du changement de mode de lecture" + +msgid "Failed to clear blacklist" +msgstr "Échec de l'effacement de la liste noire" + +msgid "Failed to clear lyrics cache" +msgstr "Échec de l'effacement du cache des paroles" + +msgid "Failed to create temp dir: {}" +msgstr "Échec de la création du répertoire temporaire : {}" + +msgid "Failed to decode booklet page: {}" +msgstr "Échec du décodage de la page du livret : {}" + +msgid "Failed to delete" +msgstr "Échec de la suppression" + +msgid "Failed to fetch booklet: {}" +msgstr "Échec de la récupération du livret : {}" + +msgid "Failed to fetch metadata" +msgstr "Échec de la récupération des métadonnées" + +msgid "Failed to read booklet: {}" +msgstr "Échec de la lecture du livret : {}" + +msgid "Failed to remove artist" +msgstr "Échec de la suppression de l'artiste" + +msgid "Failed to remove cover" +msgstr "Échec de la suppression de la pochette" + +msgid "Failed to remove items" +msgstr "Échec de la suppression des éléments" + +msgid "Failed to rename" +msgstr "Échec du renommage" + +msgid "Failed to save description" +msgstr "Échec de l'enregistrement de la description" + +msgid "Failed to toggle blacklist" +msgstr "Échec de la modification de la liste noire" + +msgid "Failed to upload cover" +msgstr "Échec du téléversement de la pochette" + +msgid "Failed to write booklet: {}" +msgstr "Échec de l'écriture du livret : {}" + +msgid "Library Albums" +msgstr "Albums de votre bibliothèque" + +msgid "February" +msgstr "Février" + +msgid "File not available — is the drive mounted?" +msgstr "Fichier indisponible — le lecteur est-il monté ?" + +msgid "Files not available — is the drive mounted?" +msgstr "Fichiers indisponibles — le lecteur est-il monté ?" + +msgid "Folder opened" +msgstr "Dossier ouvert" + +msgid "Found {} tracks from {}." +msgstr "{} pistes trouvées dans {}." + +msgid "From the same artist" +msgstr "Du même artiste" + +msgid "From your most-played playlists." +msgstr "À partir de vos playlists les plus écoutées." + +msgid "Ideal Discography" +msgstr "Discographie idéale" + +msgid "Image" +msgstr "Image" + +msgid "Import failed: {}" +msgstr "Échec de l'importation : {}" + +msgid "Imported {} of {} tracks into QBZ." +msgstr "{} sur {} pistes importées dans QBZ." + +msgid "Install the ALSA bridge so playback can reach PipeWire" +msgstr "Installez le pont ALSA pour que la lecture atteigne PipeWire" + +msgid "Install the PipeWire audio stack" +msgstr "Installez la pile audio PipeWire" + +msgid "Install the PipeWire tools (pw-dump)" +msgstr "Installez les outils PipeWire (pw-dump)" + +msgid "Install the Pulse compatibility tools (optional fallback)" +msgstr "Installez les outils de compatibilité Pulse (solution de repli facultative)" + +msgid "January" +msgstr "Janvier" + +msgid "July" +msgstr "Juillet" + +msgid "June" +msgstr "Juin" + +msgid "Last.fm disconnected" +msgstr "Last.fm déconnecté" + +msgid "Last.fm sign-in failed to start" +msgstr "Échec du démarrage de la connexion à Last.fm" + +msgid "Library database cleared" +msgstr "Base de données de la bibliothèque effacée" + +msgid "ListenBrainz disconnected" +msgstr "ListenBrainz déconnecté" + +msgid "Listening suggestions" +msgstr "Suggestions d'écoute" + +msgid "Loaded {} cached tracks" +msgstr "{} pistes en cache chargées" + +msgid "Log in to cache tracks offline" +msgstr "Connectez-vous pour mettre des pistes en cache hors ligne" + +msgid "Lossless: {}" +msgstr "LOSSLESS : {}" + +msgid "Lyrics cache cleared" +msgstr "Cache des paroles effacé" + +msgid "Lyrics copied" +msgstr "Paroles copiées" + +msgid "MIXTAPE" +msgstr "MIXTAPE" + +msgid "March" +msgstr "Mars" + +msgid "Matching tracks: {} / {} ({} found)" +msgstr "Pistes correspondantes : {} / {} ({} trouvées)" + +msgid "May" +msgstr "Mai" + +msgid "Middle-click to pause" +msgstr "Clic central pour mettre en pause" + +msgid "Middle-click to play" +msgstr "Clic central pour lire" + +msgid "Mixed queue — not cast to Qobuz Connect" +msgstr "File d'attente mixte — non diffusée vers Qobuz Connect" + +msgid "More From Your Library" +msgstr "Plus de votre bibliothèque" + +msgid "More Labels" +msgstr "Plus de labels" + +msgid "Most Streamed" +msgstr "Les plus écoutées" + +msgid "" +"Music Player\n" +"Nothing playing" +msgstr "" +"Lecteur de musique\n" +"Aucune lecture en cours" + +msgid "Network folder detected" +msgstr "Dossier réseau détecté" + +msgid "Never" +msgstr "Jamais" + +msgid "New Releases" +msgstr "Nouveautés" + +msgid "Next Track" +msgstr "Titre suivant" + +msgid "No limit" +msgstr "Aucune limite" + +msgid "No matching tracks found." +msgstr "Aucune piste correspondante trouvée." + +msgid "No sinks detected — reinstall the ALSA UCM profiles, then reboot" +msgstr "Aucune sortie détectée — réinstallez les profils UCM ALSA, puis redémarrez" + +msgid "No top tracks available for this artist" +msgstr "Aucune piste populaire disponible pour cet artiste" + +msgid "No tracks available offline" +msgstr "Aucune piste disponible hors ligne" + +msgid "Not available offline" +msgstr "Indisponible hors ligne" + +msgid "Nothing playable in this playlist right now" +msgstr "Rien à lire dans cette playlist pour le moment" + +msgid "November" +msgstr "Novembre" + +msgid "October" +msgstr "Octobre" + +msgid "Offline cache cleared" +msgstr "Cache hors ligne vidé" + +msgid "Offline cache is full — free space or raise the limit" +msgstr "Le cache hors ligne est plein — libérez de l'espace ou augmentez la limite" + +msgid "Offline caching failed" +msgstr "Échec de la mise en cache hors ligne" + +msgid "Offline listening period expired — reconnect to verify your subscription" +msgstr "La période d'écoute hors ligne a expiré — reconnectez-vous pour vérifier votre abonnement" + +msgid "On Local Library" +msgstr "Dans la bibliothèque locale" + +msgid "Opened folder ({} files skipped)" +msgstr "Dossier ouvert ({} fichiers ignorés)" + +msgid "Other Outputs" +msgstr "Autres sorties" + +msgid "PLAYLIST" +msgstr "PLAYLIST" + +msgid "Paste your ListenBrainz user token first" +msgstr "Collez d'abord votre jeton d'utilisateur ListenBrainz" + +msgid "Play/Pause" +msgstr "Lecture/Pause" + +msgid "Playing next" +msgstr "Lecture suivante" + +msgid "Playing {} of {}" +msgstr "Lecture de {} sur {}" + +msgid "Playlist ({} local)" +msgstr "Playlist ({} local)" + +msgid "Playlist created in Qobuz™." +msgstr "Playlist créée dans Qobuz™." + +msgid "Playlist uploaded to Qobuz" +msgstr "Playlist envoyée vers Qobuz" + +msgid "Playlist: {}" +msgstr "Playlist : {}" + +msgid "Plex link failed to start" +msgstr "Échec du démarrage de la liaison Plex" + +msgid "Plugin Hardware" +msgstr "Matériel via plugin" + +msgid "Press Accolades" +msgstr "Distinctions de la presse" + +msgid "Previous Track" +msgstr "Titre précédent" + +msgid "Purchase Only" +msgstr "Achat uniquement" + +msgid "QBZ requesting {} · {}-bit" +msgstr "QBZ demande {} · {}-bit" + +msgid "Qobuz Connect is unavailable while offline" +msgstr "Qobuz Connect est indisponible hors ligne" + +msgid "Qobuz Connect renderer disconnected" +msgstr "Récepteur Qobuz Connect déconnecté" + +msgid "Qobuz Connect renderer unreachable" +msgstr "Récepteur Qobuz Connect injoignable" + +msgid "Qobuzissimes" +msgstr "Qobuzissimes" + +msgid "Quit QBZ" +msgstr "Quitter QBZ" + +msgid "Rate limited, try again shortly" +msgstr "Limite de requêtes atteinte, réessayez sous peu" + +msgid "Recently Played" +msgstr "Récemment écouté" + +msgid "Rediscover Your Library" +msgstr "Redécouvrez votre bibliothèque" + +msgid "Release Watch" +msgstr "Radar des sorties" + +msgid "Removed \"{}\" ({})" +msgstr "« {} » retiré ({})" + +msgid "Removed album from offline" +msgstr "Album retiré du mode hors ligne" + +msgid "Removed from offline" +msgstr "Retiré du mode hors ligne" + +msgid "Removed {} of {} tracks" +msgstr "{} sur {} titres supprimés" + +msgid "Scan cancelled" +msgstr "Analyse annulée" + +msgid "Scan complete" +msgstr "Analyse terminée" + +msgid "Scan failed" +msgstr "Échec de l'analyse" + +msgid "Scanning track paths..." +msgstr "Analyse des chemins des titres..." + +msgid "Scroll to adjust volume" +msgstr "Faire défiler pour régler le volume" + +msgid "Search failed: {}" +msgstr "Échec de la recherche : {}" + +msgid "Searching Qobuz catalog..." +msgstr "Recherche dans le catalogue Qobuz..." + +msgid "Select music folder" +msgstr "Sélectionner le dossier de musique" + +msgid "September" +msgstr "Septembre" + +msgid "Show/Hide Window" +msgstr "Afficher/Masquer la fenêtre" + +msgid "Similar to {}" +msgstr "Similaire à {}" + +msgid "Skipped: {}" +msgstr "Ignoré : {}" + +msgid "Some tracks can't be cast to Qobuz Connect" +msgstr "Certains titres ne peuvent pas être diffusés vers Qobuz Connect" + +msgid "Song Radio" +msgstr "Radio chanson" + +msgid "Split into {} playlists (Qobuz 2000-track limit)" +msgstr "Divisé en {} playlists (limite de 2000 titres Qobuz)" + +msgid "Start the sign-in first" +msgstr "Démarrez d'abord la connexion" + +msgid "Starting…" +msgstr "Démarrage…" + +msgid "System default" +msgstr "Valeur par défaut du système" + +msgid "TRACK" +msgstr "TITRE" + +msgid "These items resolved to 0 playable tracks" +msgstr "Ces éléments ne contiennent aucun titre lisible" + +msgid "This action cannot be undone." +msgstr "Cette action est irréversible." + +msgid "This album has no playable tracks" +msgstr "Cet album ne contient aucun titre lisible" + +msgid "This collection resolved to 0 playable tracks" +msgstr "Cette collection ne contient aucun titre lisible" + +msgid "This item resolved to 0 playable tracks" +msgstr "Cet élément ne contient aucun titre lisible" + +msgid "This modifies your audio files on disk and cannot be undone." +msgstr "Cette opération modifie vos fichiers audio sur le disque et est irréversible." + +msgid "This playlist has no playable tracks" +msgstr "Cette playlist ne contient aucun titre lisible" + +msgid "This removes ALL indexed tracks from the database. Your audio files are NOT deleted. You will need to re-scan your folders afterward." +msgstr "Cette opération supprime TOUS les titres indexés de la base de données. Vos fichiers audio ne sont PAS supprimés. Vous devrez ensuite réanalyser vos dossiers." + +msgid "This removes cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Cette opération supprime les bibliothèques et titres Plex en cache. Votre connexion est conservée." + +msgid "This signs out of Plex and clears the locally cached libraries and tracks." +msgstr "Cette opération vous déconnecte de Plex et efface les bibliothèques et titres mis en cache localement." + +msgid "This track is no longer available" +msgstr "Ce titre n'est plus disponible" + +msgid "This track is not yet available" +msgstr "Ce titre n'est pas encore disponible" + +msgid "Track count differs from the result; titles applied by position" +msgstr "Le nombre de titres diffère du résultat ; les titres ont été appliqués par position" + +msgid "Track not available offline" +msgstr "Titre non disponible hors ligne" + +msgid "Track not castable to Qobuz Connect" +msgstr "Titre ne pouvant pas être diffusé vers Qobuz Connect" + +msgid "Track unavailable on Qobuz Connect" +msgstr "Titre indisponible sur Qobuz Connect" + +msgid "Tracks matched: {} / {}" +msgstr "Pistes correspondantes : {} / {}" + +msgid "Unavailable Plex track" +msgstr "Piste Plex indisponible" + +msgid "Unavailable track" +msgstr "Piste indisponible" + +msgid "Unknown" +msgstr "Inconnu" + +msgid "Unknown Artist" +msgstr "Artiste inconnu" + +msgid "Unknown source" +msgstr "Source inconnue" + +msgid "Upcoming" +msgstr "À venir" + +msgid "Upload incomplete — local playlist kept" +msgstr "Téléversement incomplet — playlist locale conservée" + +msgid "Various Artists" +msgstr "Artistes variés" + +msgid "Waiting for the DAC to start playing…" +msgstr "En attente du démarrage de la lecture par le DAC…" + +msgid "Write tags to audio files?" +msgstr "Écrire les balises dans les fichiers audio ?" + +msgid "Writing tags to files isn't supported for CUE albums; use sidecar mode" +msgstr "L'écriture des balises dans les fichiers n'est pas prise en charge pour les albums CUE ; utilisez le mode sidecar" + +msgid "Year must be a number" +msgstr "L'année doit être un nombre" + +msgid "You're offline — try again when connected" +msgstr "Vous êtes hors ligne — réessayez une fois connecté" + +msgid "Your audio stack is ready for bit-perfect playback." +msgstr "Votre chaîne audio est prête pour la lecture bit-perfect." + +msgid "Your queue is empty — add some tracks first, or press Play test." +msgstr "Votre file d'attente est vide — ajoutez d'abord des pistes, ou appuyez sur Lancer le test." + +msgid "by {}" +msgstr "par {}" + +msgid "hw (Direct Hardware)" +msgstr "hw (Matériel direct)" + +msgid "pcm (Most compatible)" +msgstr "pcm (Le plus compatible)" + +msgid "plughw (Auto-convert)" +msgstr "plughw (Conversion automatique)" + +msgid "{} + {} more" +msgstr "{} + {} de plus" + +msgid "{} albums · {} tracks" +msgstr "{} albums · {} pistes" + +msgid "{} h {} min" +msgstr "{} h {} min" + +msgid "{} libraries found" +msgstr "{} bibliothèques trouvées" + +msgid "{} min" +msgstr "{} min" + +msgid "{} removed from blacklist" +msgstr "{} retiré de la liste noire" + +msgid "{} tracks loaded" +msgstr "{} pistes chargées" + +msgid "{} tracks · {}" +msgstr "{} pistes · {}" + +msgid "{} {}" +msgstr "{} {}" + +msgid "{} {}, {}" +msgstr "{} {}, {}" + +msgid "{} — Complete Discography" +msgstr "{} — Discographie complète" + +msgid "· Unlimited" +msgstr "· Illimité" + +msgid "· of {}" +msgstr "· sur {}" + +# --- Purchases (Slice 13) --- +msgid "My Purchases" +msgstr "Mes achats" + +msgid "Purchases are downloaded as DRM-free files. Availability depends on your Qobuz region." +msgstr "Les achats sont téléchargés sous forme de fichiers sans DRM. La disponibilité dépend de votre région Qobuz." + +msgid "Search purchases…" +msgstr "Rechercher dans les achats…" + +msgid "No purchases yet" +msgstr "Aucun achat pour le moment" + +msgid "No purchased tracks yet" +msgstr "Aucun titre acheté pour le moment" + +msgid "Filters" +msgstr "Filtres" + +msgid "Availability" +msgstr "Disponibilité" + +msgid "Hide unavailable" +msgstr "Masquer les indisponibles" + +msgid "CD Quality" +msgstr "Qualité CD" + +msgid "MP3" +msgstr "MP3" + +msgid "Downloads" +msgstr "Téléchargements" + +msgid "Hide downloaded" +msgstr "Masquer les téléchargés sur cet ordinateur" + +msgid "Group: A–Z" +msgstr "Grouper : A-Z" + +msgid "Sort: Date" +msgstr "Trier : Date" + +msgid "Sort: Artist" +msgstr "Trier : Artiste" + +msgid "Sort: Album" +msgstr "Trier : Album" + +msgid "Sort: Quality" +msgstr "Trier : Qualité" + +msgid "Date" +msgstr "Date" + +msgid "Download all" +msgstr "Tout télécharger" + +msgid "Download complete" +msgstr "Téléchargement terminé" + +msgid "Download cancelled ({}/{})" +msgstr "Téléchargement annulé ({}/{})" + +msgid "Downloading {}/{}..." +msgstr "Téléchargement {}/{}..." + +msgid "Select format" +msgstr "Sélectionner le format" + +msgid "Purchased on" +msgstr "Acheté le" + +msgid "Add these downloaded tracks to your local library." +msgstr "Ajouter ces titres téléchargés à votre bibliothèque locale." + +msgid "{} tracks" +msgstr "{} titres" + +msgid "Choose folder" +msgstr "Choisir le dossier" + +msgid "Couldn't add to library" +msgstr "Impossible d'ajouter à la bibliothèque" + +msgid "Added to library" +msgstr "Ajouté à la bibliothèque" + +msgid "Purchased from Qobuz" +msgstr "Achat Qobuz" + +msgid "Couldn't load purchases. Check your connection." +msgstr "Impossible de charger les achats. Vérifiez votre connexion." + +msgid "This track has no album to download" +msgstr "Ce titre n'a aucun album à télécharger" + +msgid "Failed to start download. Please try again." +msgstr "Échec du démarrage du téléchargement. Veuillez réessayer." + +msgid "No downloadable formats available" +msgstr "Aucun format téléchargeable disponible" + +msgid "Download goodies" +msgstr "Télécharger les goodies" + +# Playlist "Suggested Songs" section (T8) +msgid "Suggested songs" +msgstr "Titres suggérés" + +msgid "Suggest songs for this playlist" +msgstr "Suggérer des titres pour cette playlist" + +msgid "Finding similar artists..." +msgstr "Recherche d'artistes similaires..." + +msgid "Refresh suggestions" +msgstr "Actualiser les suggestions" + +msgid "Not interested" +msgstr "Pas intéressé" + +# Slint polish batch (radio / playlist follow-copy / MusicBrainz / reco) +msgid "QBZ Radio" +msgstr "Radio QBZ" + +msgid "Qobuz Radio" +msgstr "Radio Qobuz" + +msgid "Create QBZ radio" +msgstr "Créer une radio QBZ" + +msgid "Create Qobuz radio" +msgstr "Créer une radio Qobuz" + +msgid "Follow on Qobuz" +msgstr "Suivre sur Qobuz" + +msgid "Following on Qobuz" +msgstr "Suivi sur Qobuz" + +msgid "Copy to your library" +msgstr "Copier dans votre bibliothèque" + +msgid "METADATA" +msgstr "MÉTADONNÉES" + +msgid "MusicBrainz" +msgstr "MusicBrainz" + +msgid "Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections." +msgstr "Activez les relations entre artistes et les métadonnées enrichies de MusicBrainz. Aucune télémétrie — l'option associe uniquement les sorties pour enrichir les pages d'artistes et les suggestions de titres des playlists. Désactivez-la pour masquer ces sections." + +msgid "No tracks to add" +msgstr "Aucun titre à ajouter" + +msgid "Failed to add tracks" +msgstr "Échec de l'ajout des titres" + +msgid "Copied to your library" +msgstr "Copié dans votre bibliothèque" + +msgid "Failed to copy playlist" +msgstr "Échec de la copie de la playlist" + +msgid "Playlist has no tracks to copy" +msgstr "La playlist n'a aucun titre à copier" + +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{} titre ajouté" +msgstr[1] "{} titres ajoutés" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{} titre ajouté à {}" +msgstr[1] "{} titres ajoutés à {}" + +msgid "Added {} track to queue" +msgid_plural "Added {} tracks to queue" +msgstr[0] "{} titre ajouté à la file d'attente" +msgstr[1] "{} titres ajoutés à la file d'attente" + +msgid "Unfollow on Qobuz" +msgstr "Ne plus suivre sur Qobuz" + +msgid "Stops in" +msgstr "S'arrête dans" + +msgid "Cancel Timer" +msgstr "Annuler la minuterie" + +msgid "Stop playback after:" +msgstr "Arrêter la lecture après :" + +msgid "30 min" +msgstr "30 min" + +msgid "1 hr" +msgstr "1 h" + +msgid "2 hr" +msgstr "2 h" + +msgid "3 hr" +msgstr "3 h" + +msgid "5 hr" +msgstr "5 h" + +msgid "Custom…" +msgstr "Personnalisé…" + +msgid "min" +msgstr "min" + +msgid "Set Timer" +msgstr "Définir la minuterie" + +msgid "Cancel stop after this" +msgstr "Annuler l'arrêt après ce titre" + +msgid "LOGS" +msgstr "JOURNAUX" + +msgid "Application logs" +msgstr "Journaux de l'application" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Consultez le journal de l'application, copiez-le (secrets masqués) ou téléversez-le pour le partager dans une issue GitHub." + +msgid "Open log viewer" +msgstr "Ouvrir le visualiseur de journaux" + +msgid "Setting" +msgstr "Paramètre" + +msgid "Saved" +msgstr "Enregistré" + +msgid "Runtime" +msgstr "Exécution" + +msgid "Diagnostics" +msgstr "Diagnostic" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Comparaison entre la configuration enregistrée et active." + +msgid "Exported" +msgstr "Exporté" + +msgid "Export to clipboard" +msgstr "Exporter vers le presse-papiers" + +msgid "Cast Discovery" +msgstr "Découverte Cast" + +msgid "Scanning…" +msgstr "Recherche…" + +msgid "Scan for devices" +msgstr "Rechercher des appareils" + +msgid "Graphics" +msgstr "Graphismes" + +msgid "Environment" +msgstr "Environnement" + +msgid "Logs" +msgstr "Journaux" + +msgid "View logs" +msgstr "Voir les journaux" + +msgid "Upload failed." +msgstr "Échec de l'envoi." + +msgid "All levels" +msgstr "Tous les niveaux" + +msgid "Error" +msgstr "Erreur" + +msgid "Warn" +msgstr "Avert." + +msgid "Info" +msgstr "Info" + +msgid "Debug" +msgstr "Débogage" + +msgid "Trace" +msgstr "Trace" + +msgid "Search logs…" +msgstr "Rechercher dans les journaux…" + +msgid "Auto-tail" +msgstr "Suivi auto" + +msgid "Time" +msgstr "Heure" + +msgid "Level" +msgstr "Niveau" + +msgid "Target" +msgstr "Module" + +msgid "Message" +msgstr "Message" + +msgid "No log entries." +msgstr "Aucune entrée de journal." + +msgid "Copied" +msgstr "Copié" + +msgid "Copy all" +msgstr "Tout copier" + +msgid "Copy diagnostics bundle" +msgstr "Copier le rapport de diagnostic" + +msgid "Uploading…" +msgstr "Envoi…" + +msgid "Upload (public)" +msgstr "Téléverser (public)" + +msgid "Open log file" +msgstr "Ouvrir le fichier journal" + +msgid "Report an issue" +msgstr "Signaler un problème" + +msgid "Go to logs" +msgstr "Aller aux journaux" + +msgid "Create issue report" +msgstr "Créer un rapport" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Pour faciliter la résolution des bugs et des problèmes, il est recommandé de partager les journaux de l'application. Nous avons pris soin de masquer les journaux envoyés afin qu'aucune donnée sensible ne soit incluse. Le processus est manuel, jamais automatique, pour que vous puissiez vérifier ce qui est partagé." + +msgid "Cache window" +msgstr "Fenêtre de cache" + +msgid "24 hours" +msgstr "24 heures" + +msgid "36 hours" +msgstr "36 heures" + +msgid "48 hours" +msgstr "48 heures" + +msgid "72 hours" +msgstr "72 heures" + +msgid "Refresh now" +msgstr "Actualiser maintenant" + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback without browser sample rate limitations." +msgstr "Un client de streaming musical natif pour Linux, conçu pour les audiophiles qui recherchent une lecture Hi-Fi sans perte et sans les limitations de fréquence d'échantillonnage des navigateurs." + +msgid "Acknowledgments" +msgstr "Remerciements" + +msgid "Animated background renderer" +msgstr "Moteur de rendu d'arrière-plans animés" + +msgid "Audio playback engine" +msgstr "Moteur de lecture audio" + +msgid "Author" +msgstr "Auteur" + +msgid "Build" +msgstr "Compilation" + +msgid "Build Info" +msgstr "Informations de compilation" + +msgid "Codename" +msgstr "Nom de code" + +msgid "Contributors" +msgstr "Contributeurs" + +msgid "Hatred towards all those companies that discriminate against the Linux community and don't provide us with a decent client for their product." +msgstr "Haine envers toutes ces entreprises qui font preuve de discrimination envers la communauté Linux et qui ne fournissent pas de client digne de ce nom pour leur produit." + +msgid "Icon library" +msgstr "Bibliothèque d'icônes" + +msgid "License" +msgstr "Licence" + +msgid "Lyrics API" +msgstr "API de paroles" + +msgid "Made with" +msgstr "Fait avec" + +msgid "Music link aggregation API" +msgstr "API d'aggrégation musicale" + +msgid "Music metadata database" +msgstr "Base de donnée de données musicales" + +msgid "Music streaming service and API" +msgstr "Service de streaming musical et API" + +msgid "Platform" +msgstr "Platforme" + +msgid "Release" +msgstr "Release" + +msgid "Release notes are not available." +msgstr "Les notes de version ne sont pas disponibles." + +msgid "Synced lyrics provider" +msgstr "Fournisseur de paroles synchronisées" + +msgid "UI framework" +msgstr "Framework d'UI" + +msgid "Version" +msgstr "Version" + +msgid "Website" +msgstr "Site web" + +msgid "What's new" +msgstr "Quoi de neuf" + +msgid "What's new in v{}" +msgstr "Quoi de neuf dans v{}" + +msgid "in" +msgstr "à" + +msgid "QBZ uses your Last.fm and ListenBrainz account data when they're connected (if they aren't, we recommend connecting them to enrich this section). Here you set how often that data is fetched — it defaults to 48 hours, or you can request an update right now." +msgstr "QBZ utilise les données de vos comptes Last.fm et ListenBrainz lorsqu'ils sont connectés (s'ils ne le sont pas, nous vous recommandons de les connecter pour enrichir cette section). Vous définissez ici la fréquence à laquelle ces données sont récupérées — elle est de 48 heures par défaut, ou vous pouvez demander une mise à jour dès maintenant." + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback." +msgstr "Un client de streaming musical natif pour Linux, conçu pour les audiophiles qui recherchent une lecture Hi-Fi sans perte." + +msgid "Built with Rust and Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, and Lucide." +msgstr "Conçu avec Rust et Slint, et propulsé par Rodio et Symphonia. Avec nos remerciements à Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh et Lucide." + +# Recommendations (Discover external-reco) + JACK not-bit-perfect note +msgid "RECOMMENDATIONS" +msgstr "RECOMMANDATIONS" + +msgid "Show Recommendations in Discover" +msgstr "Afficher les recommandations dans Découvrir" + +msgid "A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases." +msgstr "Un onglet Découvrir personnalisé à partir de vos écoutes. Pour de meilleurs résultats, connectez Last.fm et ListenBrainz ci-dessous — MusicBrainz est utilisé automatiquement pour faire correspondre les sorties." + +msgid "Recommendations" +msgstr "Recommandations" + +msgid "More like the artists you love" +msgstr "Plus des artistes que vous aimez" + +msgid "Based on what you've been into lately" +msgstr "D'après ce que vous écoutez ces derniers temps" + +msgid "Weekly Exploration" +msgstr "Exploration hebdomadaire" + +msgid "Weekly Jams" +msgstr "Pépites de la semaine" + +msgid "Popular artists on Qobuz" +msgstr "Artistes populaires sur Qobuz" + +msgid "No recommendations yet" +msgstr "Aucune recommandation pour le moment" + +msgid "Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations." +msgstr "Connectez Last.fm ou ListenBrainz dans les Paramètres, ou écoutez plus de musique, pour obtenir des recommandations personnalisées." + +msgid "JACK is not bit-perfect" +msgstr "JACK n'est pas bit-perfect" + +msgid "The JACK backend routes audio through the JACK graph, which resamples to the graph's sample rate. For bit-perfect playback use ALSA (direct) or PipeWire with passthrough." +msgstr "Le backend JACK achemine l'audio via le graphe JACK, qui rééchantillonne à la fréquence du graphe. Pour une lecture bit-perfect, utilisez ALSA (direct) ou PipeWire avec passthrough." + +msgid "Extra large" +msgstr "Très grand" + +msgid "INTERFACE" +msgstr "INTERFACE" + +msgid "Interface size" +msgstr "Taille de l'interface" + +msgid "Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)" +msgstr "Met à l'échelle toute l'interface, comme le zoom du navigateur. Petit affiche plus de contenu à l'écran, Grand et Très grand améliorent la lisibilité (redémarrage requis)" + +msgid "Interface size changed — restart QBZ to apply" +msgstr "Taille de l'interface modifiée — redémarrez QBZ pour appliquer" + +msgid "Extra small" +msgstr "Très petit" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Se connecter avec votre navigateur" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Continuez dans votre navigateur web — QBZ attend que vous terminiez la connexion." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "Le navigateur peut s'ouvrir en arrière-plan sans prendre le focus — vérifiez vos autres fenêtres." + +msgid "Signing you in…" +msgstr "Connexion en cours…" + +msgid "Sign-in failed" +msgstr "Échec de la connexion" + +msgid "About QBZ" +msgstr "À propos de QBZ" + +msgid "Already used by" +msgstr "Déjà utilisé par" + +msgid "Auto (recommended)" +msgstr "Auto (recommandé)" + +msgid "Discrete GPU (higher performance)" +msgstr "GPU dédié (plus de performances)" + +msgid "Integrated GPU (lower power)" +msgstr "GPU intégré (moins d'énergie)" + +msgid "Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)" +msgstr "Auto choisit le meilleur moteur de rendu pour votre matériel graphique. Ne le changez que si l’application semble lente ou s’affiche mal (redémarrage requis)" + +msgid "Customize Shortcuts" +msgstr "Personnaliser les raccourcis" + +msgid "GPU" +msgstr "GPU" + +msgid "GPU (compatibility)" +msgstr "GPU (compatibilité)" + +msgid "Go" +msgstr "Aller" + +msgid "Line Bed" +msgstr "Line Bed" + +msgid "Tunnel Flow" +msgstr "Tunnel Flow" + +msgid "Open Music Link" +msgstr "Ouvrir un lien musical" + +msgid "Open Playlist Importer" +msgstr "Ouvrir l’importateur de playlists" + +msgid "Open Qobuz Link" +msgstr "Ouvrir un lien Qobuz" + +msgid "Paste a Qobuz, Spotify, Apple Music, Tidal, Deezer or song.link URL" +msgstr "Collez une URL Qobuz, Spotify, Apple Music, Tidal, Deezer ou song.link" + +msgid "Place the Purchases entry in the custom title bar instead of the sidebar" +msgstr "Place l’entrée Achats dans la barre de titre personnalisée plutôt que dans la barre latérale" + +msgid "Press keys…" +msgstr "Appuyez sur des touches…" + +msgid "Purchases" +msgstr "Achats" + +msgid "Purchases in title bar" +msgstr "Achats dans la barre de titre" + +msgid "RENDERER" +msgstr "MOTEUR DE RENDU" + +msgid "Rendering backend" +msgstr "Backend de rendu" + +msgid "Reset All to Defaults" +msgstr "Tout réinitialiser" + +msgid "Resolving…" +msgstr "Résolution…" + +msgid "Software" +msgstr "Logiciel" + +msgid "Spectral Ribbon" +msgstr "Spectral Ribbon" + +msgid "This looks like a playlist." +msgstr "Ceci ressemble à une playlist." + +msgid "Wave Bed" +msgstr "Wave Bed" + +msgid "What's New" +msgstr "Nouveautés" + +msgid "modified" +msgstr "modifié" + +msgid "Block this album" +msgstr "Bloquer cet album" + +msgid "Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed." +msgstr "Les albums bloqués sont masqués de la recherche, de la découverte et des listes — même si leur artiste est autorisé." + +msgid "No blocked albums" +msgstr "Aucun album bloqué" + +msgid "This will remove all {} blocked albums. This cannot be undone." +msgstr "Cela supprimera les {} albums bloqués. Cette action est irréversible." + +msgid "To block an album, open its menu and choose Block this album." +msgstr "Pour bloquer un album, ouvrez son menu et choisissez Bloquer cet album." + +msgid "Unblock album" +msgstr "Débloquer l’album" + +msgid "{} albums" +msgstr "{} albums" + +msgid "{} artists, {} albums blocked" +msgstr "{} artistes, {} albums bloqués" + +msgid "{} artists, {} albums blocked (disabled)" +msgstr "{} artistes, {} albums bloqués (désactivé)" + +msgid "Keep your system's native window decorations. Turn off to use the QBZ header as the title bar, with its own window controls and drag support. Takes effect after restarting QBZ." +msgstr "Conserve les décorations de fenêtre natives de votre système. Désactivez pour utiliser l'en-tête de QBZ comme barre de titre, avec ses propres contrôles de fenêtre et le déplacement par glisser. Prend effet après le redémarrage de QBZ." + +msgid "Frameless window without window controls or header drag (for tiling window manager users)" +msgstr "Fenêtre sans cadre, sans contrôles de fenêtre ni glisser depuis l'en-tête (pour les utilisateurs de gestionnaires de fenêtres en mosaïque)" + +msgid "Title bar mode changed — restart QBZ to apply" +msgstr "Mode de barre de titre modifié — redémarrez QBZ pour appliquer" + +# Compact header navigation (opt-in, 2026-07-03) +msgid "Compact header navigation" +msgstr "Navigation compacte dans l'en-tête" + +msgid "Use the icon-only section navigation in the header even while the sidebar is open." +msgstr "Utilise la navigation des sections en icônes seules dans l'en-tête, même lorsque la barre latérale est ouverte." + +# DSD support Phase 1 (2026-07-03) +msgid "DSD: {}" +msgstr "DSD: {}" + +msgid "Cannot play DSD file" +msgstr "Impossible de lire le fichier DSD" + +# DSD Phases 2-3: delivery mode (2026-07-03) +msgid "DSD playback" +msgstr "Lecture DSD" + +msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC." +msgstr "Comment les pistes DSD atteignent le DAC. ATTENTION : ne choisissez DoP ou Natif que si votre DAC le prend en charge — sur tout autre DAC, cela produit un bruit fort. Le volume est fixe en mode DoP/Natif. Le mode Natif nécessite en plus la prise en charge du noyau pour le DAC." + +msgid "Convert to PCM (works everywhere)" +msgstr "Convertir en PCM (fonctionne partout)" + +msgid "DoP — DSD over PCM (bit-perfect)" +msgstr "DoP — DSD sur PCM (bit-perfect)" + +msgid "Native DSD (kernel support required)" +msgstr "DSD natif (prise en charge du noyau requise)" + +msgid "Date added" +msgstr "Date d'ajout" + +msgid "Renderer setting reverted to Auto — the previous start didn't finish" +msgstr "Réglage du moteur de rendu revenu sur Automatique — le démarrage précédent ne s'est pas terminé" + +msgid "Renderer switched to GPU (compatibility) — the GPU renderer failed to start" +msgstr "Moteur de rendu basculé sur GPU (compatibilité) — le rendu GPU n'a pas pu démarrer" + +msgid "Audio output error" +msgstr "Erreur de sortie audio" + +msgid "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default" +msgstr "L'accès direct aux périphériques ALSA est bloqué dans Flatpak/Snap — passez le backend audio sur PipeWire ou la sortie système" + +msgid "Auto theme generation failed" +msgstr "Échec de la génération du thème automatique" + +msgid "Play all tracks" +msgstr "Lire tous les titres" + +msgid "Shuffle all tracks" +msgstr "Lecture aléatoire de tous les titres" + +msgid "Select multiple tracks" +msgstr "Sélectionner plusieurs titres" + +msgid "Play a random album" +msgstr "Lire un album au hasard" + +msgid "Open a random artist" +msgstr "Ouvrir un artiste au hasard" + +msgid "Play a random playlist" +msgstr "Lire une playlist au hasard" + +msgid "Open a random label" +msgstr "Ouvrir un label au hasard" + +msgid "Start from current theme" +msgstr "Partir du thème actuel" + +msgid "Copy the colors of the currently applied theme into the editor as a starting point." +msgstr "Copie les couleurs du thème actuellement appliqué dans l'éditeur comme point de départ." + +msgid "Use current colors" +msgstr "Utiliser les couleurs actuelles" + +msgid "Dark theme" +msgstr "Thème sombre" + +msgid "Set the overall light or dark polarity. Affects derived shades, borders and overlays." +msgstr "Définit la polarité globale claire ou sombre. Affecte les nuances dérivées, les bordures et les superpositions." + +msgid "SURFACES" +msgstr "SURFACES" + +msgid "Background" +msgstr "Arrière-plan" + +msgid "Surface" +msgstr "Surface" + +msgid "Elevated" +msgstr "Surélevé" + +msgid "TEXT" +msgstr "TEXTE" + +msgid "Text" +msgstr "Texte" + +msgid "Secondary text" +msgstr "Texte secondaire" + +msgid "ACCENT" +msgstr "ACCENT" + +msgid "Accent" +msgstr "Accent" + +msgid "Danger" +msgstr "Danger" + +msgid "Warning" +msgstr "Avertissement" + +msgid "Success" +msgstr "Succès" + +msgid "OTHER" +msgstr "AUTRE" + +msgid "Border" +msgstr "Bordure" + +# Unavailable-track auto-skip (Tauri #467 parity) +msgid "No available tracks to play" +msgstr "Aucune piste disponible à lire" +# Always-visible log uploader entry (settings sub-nav footer) +msgid "Share logs" +msgstr "Partager les journaux" +# Guest local-only mode disclaimer line (login screen, #553) +msgid "QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library)." +msgstr "QBZ peut être utilisé comme lecteur hors ligne sans compte Qobuz™ (pas d'accès à la bibliothèque Qobuz™)." +# Renderer degradation ladder toast (generic: GL or software rung) +msgid "Renderer switched automatically — the previous renderer failed to start" +msgstr "Moteur de rendu changé automatiquement — le précédent n'a pas pu démarrer" + +# QConnect startup behavior (Settings > Playback, ported from Tauri) +msgid "Auto-connect Qobuz Connect on startup" +msgstr "Connecter Qobuz Connect automatiquement au démarrage" +msgid "Choose whether Qobuz Connect activates automatically when QBZ launches." +msgstr "Choisir si Qobuz Connect s'active automatiquement au lancement de QBZ." +msgid "Remember state" +msgstr "Mémoriser l'état" +msgid "On by default" +msgstr "Activé par défaut" +msgid "Off by default" +msgstr "Désactivé par défaut" + +msgid "QBZ recovered from repeated startup crashes — some restored state was reset" +msgstr "QBZ s'est remis de plantages répétés au démarrage — une partie de l'état restauré a été réinitialisée" + +# QConnect device name (Settings > Playback, ported from Tauri) +msgid "Qobuz Connect device name" +msgstr "Nom d'appareil Qobuz Connect" + +msgid "The name other Qobuz Connect apps see for this device. Applies on the next connection." +msgstr "Le nom que les autres applications Qobuz Connect voient pour cet appareil. Appliqué à la prochaine connexion." + +# Quality downgrade tooltip (#590 follow-up) +msgid "Source: {}" +msgstr "Source : {}" + +msgid "Output: {}" +msgstr "Sortie : {}" + +# Local Library header Plex sync button (#573) +msgid "Sync Plex library" +msgstr "Synchroniser la bibliothèque Plex" + +# Library Albums sort options (#566) +msgid "Recently added" +msgstr "Ajoutés récemment" + +msgid "First added" +msgstr "Ajoutés en premier" + +msgid "Random" +msgstr "Aléatoire" + + +# Offline cache clear — communicate it does not remove downloads (#606) +msgid "Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above." +msgstr "Libère les données en cache. Vos albums téléchargés sont conservés — supprimez-les depuis le gestionnaire hors ligne ci-dessus." + +msgid "Couldn't clear the cache" +msgstr "Impossible de vider le cache" + + +# Task 18 — Settings > Developer > Export settings modal (daemon 04 §4.2) +msgid "SETTINGS PORTABILITY" +msgstr "PORTABILITÉ DES RÉGLAGES" + +msgid "Export settings…" +msgstr "Exporter les réglages…" + +msgid "Save a portable bundle of your settings to move to another machine or the qbzd daemon." +msgstr "Enregistre un ensemble portable de vos réglages pour les transférer vers une autre machine ou le démon qbzd." + +msgid "Export…" +msgstr "Exporter…" + +msgid "Export settings" +msgstr "Exporter les réglages" + +msgid "Save your settings as a bundle to import on the qbzd daemon or another machine." +msgstr "Enregistrez vos réglages sous forme d'un ensemble à importer sur le démon qbzd ou une autre machine." + +msgid "Include Qobuz login and integration tokens" +msgstr "Inclure la connexion Qobuz et les jetons d'intégration" + +msgid "Anyone with the file can use your accounts. It is saved readable only by you; delete it after importing." +msgstr "Quiconque peut lire le fichier peut utiliser vos comptes. Il est enregistré de façon lisible par vous seul ; supprimez-le après l'import." + +msgid "Export" +msgstr "Exporter" + +msgid "Bundle saved. On the daemon box:" +msgstr "Ensemble enregistré. Sur la machine du démon :" + +msgid "Could not export settings" +msgstr "Impossible d'exporter les réglages" + + +# Share menu Song.link/Album.link toasts (#514) +msgid "Fetching Song.link..." +msgstr "Récupération du Song.link..." + +msgid "Fetching Album.link..." +msgstr "Récupération de l'Album.link..." + +msgid "Link copied" +msgstr "Lien copié" + +msgid "Failed to copy link" +msgstr "Échec de la copie du lien" + +# Dynamic app background (Appearance settings) +msgid "Ambient" +msgstr "Ambiant" + +msgid "Blurred art" +msgstr "Pochette floutée" + +msgid "Dynamic background" +msgstr "Arrière-plan dynamique" + +msgid "Animated album-art background behind the whole app. High resource use — GPU accelerated." +msgstr "Arrière-plan animé à partir de la pochette derrière toute l'application. Consommation de ressources élevée — accéléré par le GPU." + +# Preferred GPU (Renderer settings) +msgid "Preferred GPU" +msgstr "GPU préféré" + +msgid "Which GPU renders the app. Only the GPUs actually present are listed. Choosing one other than the default also switches the renderer to Vulkan — the only backend Qt can select a GPU on (requires restart)" +msgstr "Quel GPU affiche l'application. Seuls les GPU réellement présents sont listés. En choisir un autre que celui par défaut bascule aussi le moteur de rendu sur Vulkan — le seul backend où Qt peut sélectionner un GPU (redémarrage requis)" + +msgid "Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)" +msgstr "Quel GPU affiche l'application. Sur un portable hybride, choisir le GPU dédié déplace la charge de l'arrière-plan dynamique hors du GPU intégré (plus frais, mais plus gourmand en énergie). Auto est recommandé (redémarrage requis)" + +msgid "discrete" +msgstr "dédié" + +msgid "integrated" +msgstr "intégré" + +msgid "Preferred GPU changed — restart QBZ to apply" +msgstr "GPU préféré modifié — redémarrez QBZ pour appliquer" + +msgid "Selected GPU is unavailable in this display session — using Auto" +msgstr "Le GPU sélectionné n’est pas disponible dans cette session d’affichage — utilisation du mode Auto" + +msgid "Renderer changed — restart QBZ to apply" +msgstr "Moteur de rendu modifié — redémarrez QBZ pour l'appliquer" + +msgid "Search your library" +msgstr "Rechercher dans votre bibliothèque" + +msgid "From catalog" +msgstr "Du catalogue" + +msgid "In library" +msgstr "Dans la bibliothèque" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Ajouter aux favoris" + +msgid "Remove from favorites" +msgstr "Retirer des favoris" + +msgid "Start radio" +msgstr "Démarrer la radio" + +msgid "Booklet" +msgstr "Livret" + +msgid "Add to Mixtape" +msgstr "Ajouter à la Mixtape" + +msgid "Album info" +msgstr "Infos de l'album" + +msgid "More options" +msgstr "Plus d'options" + +msgid "Follow artist" +msgstr "Suivre l'artiste" + +msgid "Unfollow" +msgstr "Ne plus suivre" + +msgid "Artist network" +msgstr "Réseau de l'artiste" + +msgid "Upload to Qobuz" +msgstr "Envoyer vers Qobuz" + +msgid "Select tracks" +msgstr "Sélectionner des pistes" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz refuse temporairement les requêtes — pause en cours. Réessayez dans un instant." + + +msgid "Flatpak" +msgstr "Flatpak" + +msgid "Snap" +msgstr "Snap" + +msgid "FLATPAK SANDBOX" +msgstr "SANDBOX FLATPAK" + +msgid "SNAP SANDBOX" +msgstr "SANDBOX SNAP" + +msgid "QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates." +msgstr "QBZ s'exécute dans un sandbox Flatpak. Certaines fonctions nécessitent des autorisations à accorder une seule fois — exécutez les commandes dans un terminal, puis redémarrez QBZ. Les autorisations sont conservées après les mises à jour." + +msgid "QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ." +msgstr "QBZ s'exécute dans un sandbox Snap. Certaines interfaces audio doivent être connectées une seule fois — exécutez ces commandes dans un terminal, puis redémarrez QBZ." + +msgid "Exclusive DAC access (bit-perfect)" +msgstr "Accès exclusif au DAC (bit-perfect)" + +msgid "Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected." +msgstr "Recommandé pour le mode exclusif : permet à QBZ de demander à PipeWire de céder le DAC proprement (réservation de périphérique D-Bus). Sans cela, PipeWire conserve le DAC et d'autres applications continuent de mixer dessus même avec ALSA + hw + Exclusif sélectionné." + +msgid "Music on NAS or external drives" +msgstr "Musique sur NAS ou disques externes" + +msgid "Grants access to a folder outside the sandbox. Repeat the command for each folder you need." +msgstr "Donne accès à un dossier hors du sandbox. Répétez la commande pour chaque dossier nécessaire." + +msgid "Required audio connections" +msgstr "Connexions audio requises" + +msgid "Optional — external drives & NAS" +msgstr "Optionnel — disques externes et NAS" + +msgid "Copy" +msgstr "Copier" + +msgid "Cut" +msgstr "Couper" + +msgid "Paste" +msgstr "Coller" + +msgid "Copied!" +msgstr "Copié !" + +msgid "Limited by your streaming quality setting" +msgstr "Limité par votre réglage de qualité de streaming" + +msgid "Qobuz has no higher quality for this track" +msgstr "Qobuz ne propose pas de qualité supérieure pour cette piste" + +msgid "offline copy — cap not applied" +msgstr "copie hors ligne — plafond non appliqué" + +msgid "cached copy — cap not applied" +msgstr "copie en cache — plafond non appliqué" + +msgid "Limited by your output device's limit" +msgstr "Limité par le plafond de votre périphérique de sortie" + +msgid "Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting." +msgstr "Limite la qualité de streaming demandée au plafond de votre périphérique de sortie. S'applique uniquement à la lecture locale, jamais à la diffusion." + +msgid "Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly." +msgstr "Les limites exactes de votre périphérique n'ont pas pu être lues sur ce système, un ensemble courant est donc supposé. Le plafond s'applique quand même, mais peut ne pas correspondre exactement à votre matériel." + +msgid "Detected device limit" +msgstr "Limite détectée du périphérique" + +msgid "Quality for this device" +msgstr "Qualité pour cet appareil" + +msgid "Follow app setting ({})" +msgstr "Suivre le réglage de l'app ({})" + +msgid "Hi-Res — 24-bit, up to 96 kHz" +msgstr "Hi-Res — 24-bit, jusqu'à 96 kHz" + +msgid "CD — 16-bit / 44.1 kHz" +msgstr "CD — 16-bit / 44.1 kHz" + +msgid "MP3 320" +msgstr "MP3 320" + +msgid "Qobuz only offers these tiers — there is no 48 kHz option. The step below Hi-Res is CD 44.1 kHz." +msgstr "Qobuz ne propose que ces paliers — il n'existe pas d'option 48 kHz. Le palier sous Hi-Res est CD 44.1 kHz." + +msgid "Limited by the quality cap for {}" +msgstr "Limité par le plafond de qualité pour {}" + +msgid "capped for this device" +msgstr "plafonné pour cet appareil" + +msgid "Albums by folder" +msgstr "Albums par dossier" + +msgid "Albums by metadata" +msgstr "Albums par métadonnées" + +msgid "ALBUMS VIEW" +msgstr "VUE ALBUMS" + +msgid "Album grouping" +msgstr "Regroupement des albums" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Dossiers : une carte par dossier d'album — idéal pour les compilations et coffrets. Métadonnées : séparer par tags album + artiste." + +msgid "+{} more artists" +msgstr "+{} artistes de plus" + +msgid "Show less" +msgstr "Afficher moins" +# Most Played Albums rail title +msgid "Most Played Albums" +msgstr "Albums les plus écoutés" + +# Most Played Albums View-all — per-card play count +msgid "{} plays" +msgstr "{} lectures" + +# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast +msgid "Show lyrics translation" +msgstr "Afficher la traduction des paroles" + +msgid "Hide lyrics translation" +msgstr "Masquer la traduction des paroles" + +msgid "Translation language" +msgstr "Langue de traduction" + +msgid "Auto (account language)" +msgstr "Automatique (langue du compte)" + +msgid "Translation is not available right now. Please try again later." +msgstr "La traduction n'est pas disponible pour le moment. Veuillez réessayer plus tard." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Les artistes écartés disparaissent uniquement de vos Recommandations — ils restent visibles dans la recherche et partout ailleurs." + +msgid "No dismissed artists" +msgstr "Aucun artiste écarté" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Pour ne plus voir un artiste dans Recommandations, ouvrez le menu de sa carte et choisissez Pas intéressé." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} n'apparaîtra plus dans Recommandations" + +msgid "Artist dismissed from Recommendations" +msgstr "Artiste écarté des Recommandations" + +msgid "{} restored to Recommendations" +msgstr "{} rétabli dans Recommandations" + +msgid "LIBRARY › ALL" +msgstr "BIBLIOTHÈQUE › TOUT" + +msgid "Show local files & Plex" +msgstr "Afficher les fichiers locaux et Plex" + +msgid "Include your favorited local files and Plex items in the Library All feed." +msgstr "Inclut vos fichiers locaux et éléments Plex favoris dans le flux Tout de la bibliothèque." + +msgid "Play indicator animation" +msgstr "Animation de l'indicateur de lecture" + +msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU." +msgstr "Anime la ligne en cours de lecture avec des barres d'égaliseur. Désactivée (défaut), une icône de pause statique avec une marque d'accent sur le bord s'affiche — plus léger pour le CPU." + +msgid "Invert swipe navigation direction" +msgstr "Inverser la direction du geste de navigation" + +msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward." +msgstr "Inverse le balayage à deux doigts du pavé tactile : gauche recule, droite avance." + +msgid "Local items in Library All" +msgstr "Éléments locaux dans Bibliothèque › Tout" + +msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track." +msgstr "Favoris uniquement : ce que vous avez marqué. Toute la bibliothèque locale : chaque album, artiste et piste des dossiers et de Plex." + +msgid "Favorited items only" +msgstr "Favoris uniquement" + +msgid "Entire local library" +msgstr "Toute la bibliothèque locale" + +msgid "Select multiple albums" +msgstr "Sélectionner plusieurs albums" + +msgid "Play later" +msgstr "Lire plus tard" + +msgid "Added to play later" +msgstr "Ajouté pour plus tard" + +msgid "Next in queue" +msgstr "Suivant dans la file d'attente" + +msgid "Next up" +msgstr "À suivre" + +msgid "Removed {} tracks from favorites" +msgstr "{} pistes retirées des favoris" + +msgid "Removing {} tracks from favorites…" +msgstr "Suppression de {} pistes des favoris…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Album \"{}\" bloqué" + +msgid "Album \"{}\" unblocked" +msgstr "Album \"{}\" débloqué" + +msgid "Failed to block album" +msgstr "Échec du blocage de l'album" + +msgid "Failed to unblock album" +msgstr "Échec du déblocage de l'album" + +msgid "Failed to clear album blacklist" +msgstr "Échec de l'effacement de la liste noire des albums" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "{} album retiré de la liste noire" +msgstr[1] "{} albums retirés de la liste noire" + +msgid "Playlist imported" +msgstr "Playlist importée" + +msgid "Playlist import failed" +msgstr "Échec de l'importation de la playlist" + +msgid "Not logged in to Qobuz" +msgstr "Vous n'êtes pas connecté à Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Supprimer le dossier" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Supprimer le dossier « {} » ? Ses playlists retournent à la racine." + +msgid "Choose a folder image" +msgstr "Choisissez une image de dossier" + +msgid "Images" +msgstr "Images" + +msgid "Failed to create folder" +msgstr "Échec de la création du dossier" + +msgid "Failed to save folder" +msgstr "Échec de l’enregistrement du dossier" + +msgid "Failed to delete folder" +msgstr "Échec de la suppression du dossier" + +msgid "Failed to rename playlist" +msgstr "Échec du renommage de la playlist" + +msgid "Failed to delete playlist" +msgstr "Échec de la suppression de la playlist" + +msgid "Couldn't create the playlist" +msgstr "Impossible de créer la playlist" + +# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard, +# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. +msgid "Open playlist" +msgstr "Ouvrir la playlist" + +# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut +# action labels + category names for the cheatsheet and the customize editor. +# Slint's Rust-side labels were never extracted (the port inherits the gap); +# this block fixes it for both frontends. en is EXCLUDED on purpose +# (sparse-override convention, qbz-i18n/src/lib.rs:120-125). + +msgid "Play / Pause" +msgstr "Lecture / Pause" + +msgid "Go Back" +msgstr "Retour" + +msgid "Go Forward" +msgstr "Avancer" + +msgid "Toggle Sidebar" +msgstr "Afficher/masquer la barre latérale" + +msgid "Immersive Mode" +msgstr "Mode immersif" + +msgid "Close / Dismiss" +msgstr "Fermer / Ignorer" + +msgid "Show Shortcuts" +msgstr "Afficher les raccourcis" + +msgid "Toggle Mini Player" +msgstr "Afficher/masquer le mini-lecteur" + +msgid "Seek Forward (5s)" +msgstr "Avancer de 5 s" + +msgid "Seek Back (5s)" +msgstr "Reculer de 5 s" + +msgid "Seek Forward (10s)" +msgstr "Avancer de 10 s" + +msgid "Seek Back (10s)" +msgstr "Reculer de 10 s" + +msgid "Micro View" +msgstr "Vue micro" + +msgid "Compact View" +msgstr "Vue compacte" + +msgid "Artwork View" +msgstr "Vue pochette" + +msgid "Listen list" +msgstr "Liste d'écoute" + +msgid "Lyrics View" +msgstr "Vue paroles" + +msgid "Navigation" +msgstr "Navigation" + +msgid "Interface" +msgstr "Interface" + +msgid "Mini Player" +msgstr "Mini-lecteur" + + +# Kiosk shell (2026-08-02 kiosk-port contract) + +msgid "Cover" +msgstr "Pochette" + +msgid "Radio" +msgstr "Radio" + +msgid "Up Next" +msgstr "À suivre" + +# Kiosk <-> Desktop profile toggle (view-mode menu) + +msgid "Kiosk mode" +msgstr "Mode kiosque" + +msgid "Desktop mode" +msgstr "Mode bureau" + +# AlbumView cover menu / lightbox (Qt port, 2026-08-10) + +msgid "View cover" +msgstr "Voir la pochette" + +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Voir l'image" + +msgid "Could not load the image" +msgstr "Impossible de charger l'image" + +# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's +# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen +# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter", +# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On", +# "Bands & Projects", "Load more", "Loading…", "Artist Scene", +# "No artists found for this scene.", "No album appearances found." and +# "Select an artist to see their albums." (period-only delta) already exist. +# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7, +# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them +# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every +# call site substitutes explicitly, e.g. .replace("{artist}", name). + +msgid "Based on {artist} · {genres}" +msgstr "D'après {artist} · {genres}" + +msgid "Searching MusicBrainz for {genre} artists..." +msgstr "Recherche d'artistes {genre} sur MusicBrainz..." + +msgid "Verifying availability on Qobuz..." +msgstr "Vérification de la disponibilité sur Qobuz..." + +msgid "Preparing results..." +msgstr "Préparation des résultats..." + +msgid "No matching artists from this scene are currently available on Qobuz." +msgstr "Aucun artiste correspondant à cette scène n'est actuellement disponible sur Qobuz." + +msgid "Loading albums..." +msgstr "Chargement des albums..." + +msgid "Failed to load albums" +msgstr "Échec du chargement des albums" + +msgid "Failed to load appearances" +msgstr "Échec du chargement des participations" + +msgid "Looking up {name}..." +msgstr "Recherche de {name}..." + +msgid "Failed to look up musician" +msgstr "Impossible de rechercher ce musicien" + +msgid "Browse view" +msgstr "Vue exploration" + +msgid "Grid view" +msgstr "Vue en grille" + +msgid "Alphabetical (A-Z)" +msgstr "Alphabétique (A-Z)" + +msgid "This musician appears in album credits but has limited catalog information available in Qobuz." +msgstr "Ce musicien apparaît dans les crédits de l'album, mais peu d'informations de catalogue sont disponibles sur Qobuz." + +msgid "Unable to verify this musician's catalog. The information shown is based on album credits." +msgstr "Impossible de vérifier le catalogue de ce musicien. Les informations affichées proviennent des crédits de l'album." + +msgid "This musician appears in album credits." +msgstr "Ce musicien apparaît dans les crédits de l'album." + +msgid "Discography" +msgstr "Discographie" + +msgid "Live Albums" +msgstr "Albums live" + +msgid "Select an artist to see their albums" +msgstr "Sélectionnez un artiste pour voir ses albums" + +msgid "{} / {} artists" +msgstr "{} / {} artistes" + +# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17 +# §8-6). "Unavailable" and "This track is no longer available" already +# exist in this file and are REUSED by the render and the reactive skip toast +# — nothing below twins them. +# +# The skipped-tracks toast is a plural PAIR, per the convention already set by +# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build +# drops N rows at once, and a singular-only string reads wrong in every locale +# with plural rules. Russian carries all three forms. +# +# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled +# state (contract §5.3/F5): the track left the catalogue but the user owns a +# downloaded copy, so it still plays. It cannot fold into "This track is no +# longer available" — that sentence would be a lie on a row playing right now. +# +# Em dash is U+2014 with spaces on both sides, as everywhere else in this file. +msgid "Find available version" +msgstr "Trouver une version disponible" + +msgid "Replace unavailable track" +msgstr "Remplacer le titre indisponible" + +msgid "No available version found" +msgstr "Aucune version disponible trouvée" + +msgid "Track replaced" +msgstr "Titre remplacé" + +msgid "Could not replace the track" +msgstr "Échec du remplacement du titre" + +msgid "The replacement was added at the end of the playlist" +msgstr "Le remplacement a été ajouté à la fin de la playlist" + +msgid "{} track skipped — no longer available" +msgid_plural "{} tracks skipped — no longer available" +msgstr[0] "{} titre ignoré — plus disponible" +msgstr[1] "{} titres ignorés — plus disponibles" + +msgid "No longer on Qobuz — playing your downloaded copy" +msgstr "Plus disponible sur Qobuz — lecture de votre copie téléchargée" + +msgid "Exact match" +msgstr "Correspondance exacte" + +msgid "Low confidence" +msgstr "Faible confiance" + +msgid "Replace" +msgstr "Remplacer" + +msgid "Replacing..." +msgstr "Remplacement…" + +msgid "The replacement takes the unavailable track's place in the playlist." +msgstr "Le remplacement prend la place du titre indisponible dans la playlist." + +# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19) +msgid "Items per carousel" +msgstr "Éléments par carrousel" + +# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19) +msgid "Recently Played Playlists" +msgstr "Playlists écoutées récemment" + +msgid "Playlists you play will appear here." +msgstr "Les playlists que vous écoutez apparaîtront ici." + +# Applied-filters tooltip (2026-08-19) +msgid "+{} more" +msgstr "+{} de plus" + +msgid "+{} more filters" +msgstr "+{} filtres de plus" + +msgid "Applied filters" +msgstr "Filtres appliqués" + +# Applied-filters tooltip: group labels (2026-08-19) +msgid "Category" +msgstr "Catégorie" + +# Applied-filters tooltip: Library source switches (2026-08-19) +msgid "Favorites" +msgstr "Favoris" + +# Discover configurator: Refresh content (2026-08-19) +msgid "Refresh content" +msgstr "Actualiser le contenu" + +# Appearance: click-on-section opens the first tab (2026-08-19) +msgid "Click on menu item navigates to first tab" +msgstr "Cliquer sur une section ouvre son premier onglet" + +msgid "Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu" +msgstr "Cliquer sur une section dans la barre latérale ou la barre de titre l'ouvre sur son premier onglet — Discover sur Home, Library sur All — au lieu d'afficher seulement son menu" + +# Discover configurator: per-rail default item count (2026-08-19) +msgid "Default ({})" +msgstr "Par défaut ({})" + +# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources + +msgid "Streaming URL" +msgstr "URL de streaming" + +msgid "Playlist file" +msgstr "Fichier de playlist" + +msgid "JSON file" +msgstr "Fichier JSON" + +msgid "ListenBrainz" +msgstr "ListenBrainz" + +msgid "Last.fm" +msgstr "Last.fm" + +msgid "Choose file…" +msgstr "Choisir un fichier…" + +msgid "No file selected" +msgstr "Aucun fichier sélectionné" + +msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog." +msgstr "Seule la liste des titres est lue — les fichiers audio référencés ne sont jamais ouverts, copiés ni ajoutés à votre bibliothèque locale. Chaque entrée est recherchée dans le catalogue Qobuz." + +msgid "JSON is read best-effort — check the track count below before importing." +msgstr "Le JSON est lu au mieux — vérifiez le nombre de titres avant d'importer." + +msgid "Could not read the file." +msgstr "Impossible de lire le fichier." + +msgid "No tracks found in the file." +msgstr "Aucun titre trouvé dans le fichier." + +msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON." +msgstr "Format de fichier non reconnu — utilisez XSPF, PLS, M3U/M3U8 ou JSON." + +msgid "This looks like a live-stream manifest, not a playlist." +msgstr "Cela ressemble à un manifeste de flux en direct, pas à une playlist." + +msgid "That file is too large to import." +msgstr "Ce fichier est trop volumineux pour être importé." + +msgid "Could not recognize a track list in this JSON." +msgstr "Impossible de reconnaître une liste de titres dans ce JSON." + +msgid "Username or profile URL" +msgstr "Nom d'utilisateur ou URL de profil" + +msgid "Username or playlist URL" +msgstr "Nom d'utilisateur ou URL de playlist" + +msgid "Play your library" +msgstr "Lire votre bibliothèque" + +msgid "Play your mix" +msgstr "Lire votre mix" + +msgid "Play your recommendations" +msgstr "Lire vos recommandations" + +msgid "Station" +msgstr "Station" + +msgid "No playlists found for {}." +msgstr "Aucune playlist trouvée pour {}." + +msgid "Enter a public username." +msgstr "Saisissez un nom d'utilisateur public." + +msgid "Duplicates: {}" +msgstr "Doublons : {}" + +msgid "Media" +msgstr "Média" + +msgid "Open audio CD" +msgstr "Ouvrir le CD audio" + +msgid "Audio CD" +msgstr "CD audio" + +msgid "No optical drive found." +msgstr "Aucun lecteur optique trouvé." + +msgid "No disc in the drive." +msgstr "Aucun disque dans le lecteur." + +msgid "That disc has no audio tracks." +msgstr "Ce disque n'a aucune piste audio." + +msgid "Could not read the disc." +msgstr "Impossible de lire le disque." + +msgid "Track {}" +msgstr "Piste {}" + +msgid "Open SACD image…" +msgstr "Ouvrir une image SACD…" + +msgid "Choose a disc image" +msgstr "Choisir une image disque" + +msgid "That image has no stereo audio area." +msgstr "Cette image n'a pas de zone audio stéréo." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Ce disque est compressé en DST, que QBZ ne peut pas encore lire." + +msgid "That file is not a disc image." +msgstr "Ce fichier n'est pas une image disque." + +msgid "That file is not a SACD image." +msgstr "Ce fichier n'est pas une image SACD." + +msgid "That SACD image is damaged or unsupported." +msgstr "Cette image SACD est endommagée ou non prise en charge." + +msgid "SACD" +msgstr "SACD" + +msgid "Library Explorer" +msgstr "Explorateur de bibliothèque" + +msgid "Genres" +msgstr "Genres" + +msgid "All genres" +msgstr "Tous les genres" + +msgid "All albums" +msgstr "Tous les albums" + +msgid "No albums match these filters" +msgstr "Aucun album ne correspond à ces filtres" + +msgid "Try a different genre, artist, album, source or quality." +msgstr "Essayez un autre genre, artiste, album, source ou niveau de qualité." + +msgid "Favorites only" +msgstr "Favoris uniquement" + +msgid "Where should the ripped album go?" +msgstr "Où placer l'album extrait ?" + +msgid "Ripping" +msgstr "Extraction" + +msgid "Nothing to rip." +msgstr "Rien à extraire." + +msgid "Ripped {} track to your library" +msgid_plural "Ripped {} tracks to your library" +msgstr[0] "{} piste extraite vers votre bibliothèque" +msgstr[1] "{} pistes extraites vers votre bibliothèque" + +# Discs: rip wizard, metadata lookup and the disc tooltips (2026-08-21) + +msgid "Play a folder, CD or disc image without adding it to your library" +msgstr "Lire un dossier, un CD ou une image disque sans l'ajouter à votre bibliothèque" + +msgid "Reading the disc…" +msgstr "Lecture du disque…" + +msgid "Re-scan your folders and media servers for new music" +msgstr "Réanalyser vos dossiers et serveurs multimédias à la recherche de nouveautés" + +msgid "Manage the folders your library is built from" +msgstr "Gérer les dossiers à partir desquels votre bibliothèque est constituée" + +msgid "Play the whole disc from the first track" +msgstr "Lire tout le disque à partir de la première piste" + +msgid "Play it in a shuffled order" +msgstr "Lire dans un ordre aléatoire" + +msgid "Look this disc up and correct its details" +msgstr "Rechercher ce disque et corriger ses informations" + +msgid "Copy this CD to your computer as FLAC files" +msgstr "Copier ce CD sur votre ordinateur en fichiers FLAC" + +msgid "Close this disc and go back to your library" +msgstr "Fermer ce disque et revenir à votre bibliothèque" + +msgid "Correct disc details" +msgstr "Corriger les informations du disque" + +msgid "SACD image" +msgstr "Image SACD" + +msgid "Close without changing anything" +msgstr "Fermer sans rien modifier" + +msgid "Community database. Knows discs by their table of contents." +msgstr "Base de données communautaire. Reconnaît les disques à leur table des matières." + +msgid "Collector database. Stronger on pressings, editions and catalogue numbers." +msgstr "Base de données de collectionneurs. Plus complète sur les pressages, éditions et numéros de catalogue." + +msgid "Search this provider" +msgstr "Rechercher dans cette source" + +msgid "The provider is rate-limiting right now. Try again in a moment." +msgstr "La source limite les requêtes en ce moment. Réessayez dans un instant." + +msgid "No releases matched. Try fewer words, or the other provider." +msgstr "Aucun résultat. Essayez avec moins de mots, ou avec l'autre source." + +msgid "Search for the release this disc actually is." +msgstr "Recherchez l'édition que ce disque est réellement." + +msgid "Remove my correction" +msgstr "Supprimer ma correction" + +msgid "Forget the saved details for this disc and look it up fresh next time" +msgstr "Oublier les informations enregistrées pour ce disque et les rechercher à nouveau la prochaine fois" + +msgid "Applying..." +msgstr "Application..." + +msgid "Use these details" +msgstr "Utiliser ces informations" + +msgid "Rename this disc and remember it for next time" +msgstr "Renommer ce disque et le mémoriser pour la prochaine fois" + +msgid "Rip this CD" +msgstr "Extraire ce CD" + +msgid "What will happen" +msgstr "Ce qui va se passer" + +msgid "Track names" +msgstr "Noms des pistes" + +msgid "Your library" +msgstr "Votre bibliothèque" + +msgid "Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags." +msgstr "Chaque piste du disque est lue puis écrite en FLAC : un dossier par album, un fichier par piste, avec les métadonnées." + +msgid "Format: FLAC (lossless) — the only format QBZ writes" +msgstr "Format : FLAC (sans perte) — le seul format que QBZ écrit" + +msgid "Where the album goes" +msgstr "Où placer l'album" + +msgid "Choose folder…" +msgstr "Choisir un dossier…" + +msgid "Change…" +msgstr "Modifier…" + +msgid "Pick the folder the album folder will be created in" +msgstr "Choisissez le dossier dans lequel le dossier de l'album sera créé" + +msgid "Nothing chosen yet" +msgstr "Rien de choisi pour l'instant" + +msgid "This folder is already part of your Local Library." +msgstr "Ce dossier fait déjà partie de votre bibliothèque locale." + +msgid "This folder is outside your Local Library, so the album will not appear there on its own." +msgstr "Ce dossier est en dehors de votre bibliothèque locale : l'album n'y apparaîtra pas tout seul." + +msgid "Choose a destination first and this step will know what to offer." +msgstr "Choisissez d'abord une destination et cette étape saura quoi proposer." + +msgid "Re-scan that folder when the rip finishes" +msgstr "Réanalyser ce dossier une fois l'extraction terminée" + +msgid "Only that folder, not your whole library." +msgstr "Uniquement ce dossier, pas toute votre bibliothèque." + +msgid "Do nothing" +msgstr "Ne rien faire" + +msgid "The album appears the next time you scan." +msgstr "L'album apparaîtra lors de la prochaine analyse." + +msgid "Add this folder to Local Library and scan it" +msgstr "Ajouter ce dossier à la bibliothèque locale et l'analyser" + +msgid "Future rips into the same folder will be picked up too." +msgstr "Les extractions suivantes vers le même dossier seront aussi prises en compte." + +msgid "The files are written; the library will not know about them." +msgstr "Les fichiers sont écrits ; la bibliothèque ne les connaîtra pas." + +msgid "A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool." +msgstr "Un extracteur simple, conçu pour intégrer rapidement vos CD à la bibliothèque. Pour les vérifications AccurateRip et les options avancées, utilisez un outil dédié." + +msgid "Close without ripping anything" +msgstr "Fermer sans rien extraire" + +msgid "Go back a step" +msgstr "Revenir d'une étape" + +msgid "Start ripping" +msgstr "Lancer l'extraction" + +msgid "Go to the next step" +msgstr "Passer à l'étape suivante" + +msgid "Read the disc and write the FLAC files" +msgstr "Lire le disque et écrire les fichiers FLAC" + +msgid "Choose a destination folder first" +msgstr "Choisissez d'abord un dossier de destination" + +msgid "No disc is open." +msgstr "Aucun disque n'est ouvert." + +msgid "Couldn't load that release." +msgstr "Impossible de charger cette édition." + +msgid "Disc details updated" +msgstr "Informations du disque mises à jour" + +msgid "Correction removed. Re-open the disc to look it up again." +msgstr "Correction supprimée. Rouvrez le disque pour le rechercher à nouveau." + +msgid "Only a physical CD can be ripped." +msgstr "Seul un CD physique peut être extrait." + +msgid "Choose where the album should go first." +msgstr "Choisissez d'abord où placer l'album." + +msgid "Couldn't add that folder to the library" +msgstr "Impossible d'ajouter ce dossier à la bibliothèque" + +# Rip progress panel, cancel and partial rips (2026-08-21) + +msgid "Writing FLAC files to" +msgstr "Écriture des fichiers FLAC dans" + +msgid "Hide this panel — the rip keeps going" +msgstr "Masquer ce panneau — l'extraction continue" + +msgid "Ripping — don't eject the disc until this finishes." +msgstr "Extraction en cours — n'éjectez pas le disque avant la fin." + +msgid "Finished" +msgstr "Terminé" + +msgid "Files already written stay on disk. Delete them yourself if you don't want them." +msgstr "Les fichiers déjà écrits restent sur le disque. Supprimez-les vous-même si vous n'en voulez pas." + +msgid "Stop ripping" +msgstr "Arrêter l'extraction" + +msgid "Stopping…" +msgstr "Arrêt…" + +msgid "Stop after the current track. Nothing is deleted." +msgstr "Arrêter après la piste en cours. Rien n'est supprimé." + +msgid "Rip stopped. The files already written are still there." +msgstr "Extraction arrêtée. Les fichiers déjà écrits sont toujours là." + +msgid "See what the rip is doing, or stop it" +msgstr "Voir ce que fait l'extraction, ou l'arrêter" + +msgid "Pick at least one track to rip." +msgstr "Choisissez au moins une piste à extraire." + +# Ephemeral pane: per-disc blocks and the track filter (2026-08-21) + +msgid "Disc {}" +msgstr "Disque {}" + +msgid "Filter tracks" +msgstr "Filtrer les pistes" + +msgid "No track matches that." +msgstr "Aucune piste ne correspond." + +msgid "NAVIGATION" +msgstr "NAVIGATION" + +msgid "SEARCH" +msgstr "RECHERCHE" + +msgid "PLAYER & VISUALS" +msgstr "LECTEUR ET VISUELS" + +msgid "WINDOW" +msgstr "FENÊTRE" + +msgid "SYSTEM INTEGRATION" +msgstr "INTÉGRATION SYSTÈME" + +msgid "The typeface used across the app. System follows your operating system; the other choices are bundled with QBZ (requires restart)" +msgstr "La police utilisée dans toute l’application. Système suit le système d’exploitation ; les autres choix sont fournis avec QBZ (redémarrage requis)" + +msgid "Library folders" +msgstr "Dossiers de la bibliothèque" + +msgid "{} folders" +msgstr "{} dossiers" + +msgid "No Qobuz account? You can still use QBZ as a player for your own music library" +msgstr "Pas de compte Qobuz ? Vous pouvez tout de même utiliser QBZ comme lecteur de votre propre musique" + +msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working." +msgstr "Vous utilisez QBZ sans compte Qobuz. Votre bibliothèque locale et vos téléchargements continuent de fonctionner." + +msgid "Continuous playback" +msgstr "Lecture continue" + +msgid "Open" +msgstr "Ouvrir" + +msgid "Folder view" +msgstr "Vue par dossiers" + +msgid "Open media" +msgstr "Ouvrir un média" + +msgid "Open folder…" +msgstr "Ouvrir un dossier…" + +# Ordre des onglets de la bibliothèque locale et démarrage hors session (2026-08-24) +msgid "LIBRARY TABS" +msgstr "ONGLETS DE LA BIBLIOTHÈQUE" + +msgid "Tab order and default" +msgstr "Ordre des onglets et onglet par défaut" + +msgid "Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session." +msgstr "Choisissez l’ordre des onglets de la bibliothèque locale. Le premier s’ouvre par défaut, y compris lorsque QBZ démarre sans session Qobuz." + +msgid "Local Library tabs" +msgstr "Onglets de la bibliothèque locale" + +msgid "Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session." +msgstr "Réorganisez les onglets de la bibliothèque locale. Le premier s’ouvre par défaut et s’affiche lors d’un démarrage sans session Qobuz." + +msgid "Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu" +msgstr "Cliquer sur une section de la barre latérale ou de titre ouvre son premier onglet — y compris celui choisi par défaut pour la bibliothèque locale — au lieu d’afficher uniquement son menu" + +# Position des filtres de genre de la bibliothèque locale (2026-08-24) +msgid "GENRES VIEW" +msgstr "VUE PAR GENRES" + +msgid "Genre filters position" +msgstr "Position des filtres de genre" + +msgid "Place the chained genre filters above, beside or below the album results." +msgstr "Placez les filtres de genre liés au-dessus, à côté ou en dessous des résultats d’albums." + +msgid "Top" +msgstr "En haut" + +msgid "Bottom" +msgstr "En bas" + +msgid "Album metadata saved." +msgstr "Métadonnées de l’album enregistrées." + +msgid "Album title is required." +msgstr "Le titre de l’album est obligatoire." + +msgid "Apply to form" +msgstr "Appliquer au formulaire" + +msgid "CUE-based albums can only be edited with a sidecar." +msgstr "Les albums basés sur un CUE ne peuvent être modifiés qu’avec un fichier annexe." + +msgid "CUE-based albums use sidecar metadata." +msgstr "Les albums basés sur un CUE utilisent des métadonnées annexes." + +msgid "Canonical" +msgstr "Canonique" + +msgid "Close without saving" +msgstr "Fermer sans enregistrer" + +msgid "Couldn't inspect album metadata." +msgstr "Impossible d’inspecter les métadonnées de l’album." + +msgid "Couldn't load that metadata result." +msgstr "Impossible de charger ce résultat de métadonnées." + +msgid "Couldn't open the metadata source." +msgstr "Impossible d’ouvrir la source des métadonnées." + +msgid "Detected" +msgstr "Détectées" + +msgid "Disc number" +msgstr "Numéro de disque" + +msgid "Editing the selected physical version" +msgstr "Modification de la version physique sélectionnée" + +msgid "Enter an album title or artist to search." +msgstr "Saisissez un titre d’album ou un artiste à rechercher." + +msgid "Every track needs a title." +msgstr "Chaque piste doit avoir un titre." + +msgid "File names are shown for reference and cannot be changed here." +msgstr "Les noms de fichiers sont affichés à titre indicatif et ne peuvent pas être modifiés ici." + +msgid "Find metadata" +msgstr "Rechercher des métadonnées" + +msgid "ID3 writing" +msgstr "Écriture ID3" + +msgid "Metadata editing is available for local file versions only." +msgstr "La modification des métadonnées est réservée aux versions de fichiers locaux." + +msgid "Metadata search failed." +msgstr "La recherche de métadonnées a échoué." + +msgid "No local album version is selected." +msgstr "Aucune version locale de l’album n’est sélectionnée." + +msgid "None" +msgstr "Aucune" + +msgid "One or more audio files are unavailable." +msgstr "Un ou plusieurs fichiers audio sont indisponibles." + +msgid "Open source" +msgstr "Ouvrir la source" + +msgid "QBZ will preflight every file, update its canonical tag layer, then read it back to verify the change. Keep a backup if these files cannot be replaced." +msgstr "QBZ vérifiera chaque fichier, mettra à jour sa couche de tags canonique, puis la relira pour confirmer la modification. Conservez une sauvegarde si ces fichiers ne peuvent pas être remplacés." + +msgid "Save changes to" +msgstr "Enregistrer les modifications dans" + +msgid "Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled." +msgstr "Certains fichiers contiennent des couches de tags contradictoires. La couche canonique prévaut sauf si la synchronisation est activée." + +msgid "Synchronize compatible tag layers" +msgstr "Synchroniser les couches de tags compatibles" + +msgid "Tag layers" +msgstr "Couches de tags" + +msgid "The canonical tag is not writable for every file." +msgstr "Le tag canonique ne peut pas être écrit dans tous les fichiers." + +msgid "The metadata editor is no longer open." +msgstr "L’éditeur de métadonnées n’est plus ouvert." + +msgid "The metadata form could not be read." +msgstr "Le formulaire de métadonnées n’a pas pu être lu." + +msgid "The track list changed; reopen the editor." +msgstr "La liste des pistes a changé ; rouvrez l’éditeur." + +msgid "The track list contains a duplicate row." +msgstr "La liste des pistes contient une ligne en double." + +msgid "Track number" +msgstr "Numéro de piste" + +msgid "Unknown metadata persistence mode." +msgstr "Mode de stockage des métadonnées inconnu." + +msgid "Write and verify" +msgstr "Écrire et vérifier" + +msgid "Write metadata into audio files?" +msgstr "Écrire les métadonnées dans les fichiers audio ?" + +msgid "Year must be a valid number." +msgstr "L’année doit être un nombre valide." + +msgid "{} must be a positive number." +msgstr "{} doit être un nombre positif." + +msgid "The ephemeral session changed; reopen the metadata editor." +msgstr "La session éphémère a changé ; rouvrez l’éditeur de métadonnées." + +msgid "Album artists" +msgstr "Artistes de l’album" + +msgid "Album artists (ordered; separate with semicolons)" +msgstr "Artistes de l’album (ordonnés ; séparer par des points-virgules)" + +msgid "Artist credit" +msgstr "Crédit artiste" + +msgid "Artists (ordered; semicolon separated)" +msgstr "Artistes (ordonnés ; séparés par des points-virgules)" + +msgid "Artwork is larger than 25 MiB." +msgstr "La pochette dépasse 25 Mio." + +msgid "Artwork search failed." +msgstr "La recherche de pochettes a échoué." + +msgid "Artwork source did not provide a secure URL." +msgstr "La source de la pochette n’a pas fourni d’URL sécurisée." + +msgid "Choose album artwork" +msgstr "Choisir la pochette de l’album" + +msgid "Choose another file" +msgstr "Choisir un autre fichier" + +msgid "Choose file" +msgstr "Choisir un fichier" + +msgid "Compilation / Various Artists" +msgstr "Compilation / Artistes divers" + +msgid "Composers" +msgstr "Compositeurs" + +msgid "Couldn't download that artwork." +msgstr "Impossible de télécharger cette pochette." + +msgid "Couldn't stage artwork" +msgstr "Impossible de préparer la pochette" + +msgid "Current artwork" +msgstr "Pochette actuelle" + +msgid "Discogs release ID" +msgstr "ID de parution Discogs" + +msgid "Enter an album title to find artwork." +msgstr "Saisissez un titre d’album pour rechercher une pochette." + +msgid "File" +msgstr "Fichier" + +msgid "Find artwork" +msgstr "Rechercher une pochette" + +msgid "Local file" +msgstr "Fichier local" + +msgid "MusicBrainz album artist IDs" +msgstr "ID MusicBrainz des artistes de l’album" + +msgid "MusicBrainz artist IDs" +msgstr "ID d’artistes MusicBrainz" + +msgid "MusicBrainz recording ID" +msgstr "ID d’enregistrement MusicBrainz" + +msgid "MusicBrainz release ID" +msgstr "ID de parution MusicBrainz" + +msgid "MusicBrainz release-group ID" +msgstr "ID de groupe de parutions MusicBrainz" + +msgid "MusicBrainz track ID" +msgstr "ID de piste MusicBrainz" + +msgid "Performers" +msgstr "Interprètes" + +msgid "Revert selection" +msgstr "Annuler la sélection" + +msgid "Selected track details" +msgstr "Détails de la piste sélectionnée" + +msgid "That artwork result is no longer available." +msgstr "Ce résultat de pochette n’est plus disponible." + +msgid "The selected artwork changed; choose it again." +msgstr "La pochette sélectionnée a changé ; choisissez-la de nouveau." + +msgid "The selected artwork has unsupported dimensions." +msgstr "La pochette sélectionnée a des dimensions non prises en charge." + +msgid "The selected file is not a supported image." +msgstr "Le fichier sélectionné n’est pas une image prise en charge." + +msgid "Goniometer" +msgstr "Goniomètre" + +msgid "Oscilloscope" +msgstr "Oscilloscope" + +msgid "Track waveform" +msgstr "Forme d’onde du morceau" + +msgid "Show the full-track waveform in the player seek bar." +msgstr "Affiche la forme d’onde complète du morceau dans la barre de lecture." + +msgid "Compact header" +msgstr "En-tête compact" + +msgid "Show less data in album headers." +msgstr "Afficher moins d’informations dans les en-têtes d’album." + +msgid "Show full album header" +msgstr "Afficher l’en-tête complet de l’album" + +msgid "Show compact album header" +msgstr "Afficher l’en-tête compact de l’album" + +msgid "Immediate scrobbling" +msgstr "Scrobbling immédiat" + +msgid "Send scrobbles immediately while manual offline mode is enabled." +msgstr "Envoyer immédiatement les scrobbles lorsque le mode hors ligne manuel est activé." + +msgid "Accumulated scrobbling" +msgstr "Scrobbling accumulé" + +msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old." +msgstr "Mettre les scrobbles en attente lorsque le réseau est indisponible et les envoyer à son retour. Last.fm accepte les scrobbles datant de moins de 2 semaines." + +msgid "Scrobble while logged out" +msgstr "Scrobbler sans connexion" + +msgid "Continue sending plays to connected scrobblers without a Qobuz session." +msgstr "Continuer d’envoyer les écoutes aux services connectés sans session Qobuz." + +msgid "Focus on playing" +msgstr "Suivre la lecture" + +msgid "Go to top" +msgstr "Aller en haut" + +msgid "Quick view" +msgstr "Aperçu rapide" + +msgid "Could not load album" +msgstr "Impossible de charger l’album" + +msgid "This album has no tracks." +msgstr "Cet album ne contient aucun titre." + +msgid "Reactive Rings" +msgstr "Anneaux réactifs" + +msgid "Windows — yes, but read this first" +msgstr "Windows — oui, mais lisez d'abord ceci" + +msgid "Don't show this again" +msgstr "Ne plus afficher" + +msgid "I understand" +msgstr "Je comprends" +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker + +msgid "Volume Up" +msgstr "Augmenter le volume" + +msgid "Volume Down" +msgstr "Baisser le volume" + +msgid "Mute / Unmute" +msgstr "Couper / Rétablir le son" + +msgid "Toggle Shuffle" +msgstr "Basculer la lecture aléatoire" + +msgid "Cycle Repeat" +msgstr "Changer le mode de répétition" + +msgid "Like Track" +msgstr "Aimer le titre" + +msgid "Seek Forward" +msgstr "Avancer" + +msgid "Seek Back" +msgstr "Reculer" + +msgid "Keymap" +msgstr "Schéma de touches" + +msgid "Vim" +msgstr "Vim" + +# Local Library QoL: Explorer, scan progress, and per-track metadata editor +msgid "All years" +msgstr "Toutes les années" + +msgid "Explorer columns" +msgstr "Colonnes de l’explorateur" + +msgid "Genre and year" +msgstr "Genre et année" + +msgid "Years" +msgstr "Années" + +msgid "Edit track metadata" +msgstr "Modifier les métadonnées du morceau" + +msgid "Building library index" +msgstr "Création de l’index de la bibliothèque" + +msgid "Local folders" +msgstr "Dossiers locaux" + +msgid "Next track" +msgstr "Morceau suivant" + +msgid "Overall" +msgstr "Global" + +msgid "Previous track" +msgstr "Morceau précédent" + +msgid "Scanning library" +msgstr "Analyse de la bibliothèque" + +msgid "The track is no longer in the library." +msgstr "Ce morceau n’est plus dans la bibliothèque." + +msgid "Updating library index" +msgstr "Mise à jour de l’index de la bibliothèque" + +msgid "Edit album" +msgstr "Modifier l’album" + +msgid "Editing a local metadata sidecar" +msgstr "Modification d’un fichier sidecar de métadonnées local" + +msgid "Media-server metadata is stored in a local sidecar." +msgstr "Les métadonnées du serveur multimédia sont stockées dans un fichier sidecar local." + +msgid "Media-server metadata can only be edited with a sidecar." +msgstr "Les métadonnées du serveur multimédia ne peuvent être modifiées qu’avec un fichier sidecar." + +msgid "Network file" +msgstr "Fichier réseau" + +msgid "Offline download" +msgstr "Téléchargement hors ligne" + +msgid "File path" +msgstr "Chemin du fichier" + +msgid "Containing folder" +msgstr "Dossier parent" + +msgid "Media item ID" +msgstr "ID de l’élément multimédia" + +msgid "Album ID" +msgstr "ID de l’album" + +msgid "Server" +msgstr "Serveur" + +msgid "Duration" +msgstr "Durée" + +msgid "Format" +msgstr "Format" + +msgid "Channels" +msgstr "Canaux" + +msgid "File size" +msgstr "Taille du fichier" + +msgid "Location" +msgstr "Emplacement" + +msgid "The media item is no longer available." +msgstr "L’élément multimédia n’est plus disponible." + +msgid "The local album folder is not currently available." +msgstr "Le dossier local de l’album n’est pas disponible actuellement." + +msgid "Everything is already available offline" +msgstr "Tout est déjà disponible hors ligne" + +msgid "Some tracks are already available offline" +msgstr "Certaines pistes sont déjà disponibles hors ligne" + +msgid "{} of {} tracks in “{}” are already available offline." +msgstr "{} pistes sur {} de « {} » sont déjà disponibles hors ligne." + +msgid "Download the entire playlist again" +msgstr "Télécharger de nouveau toute la playlist" + +msgid "Download the entire album again" +msgstr "Télécharger de nouveau tout l’album" + +msgid "Re-downloads every track and can repair corrupt offline copies." +msgstr "Retélécharge chaque piste et peut réparer les copies hors ligne corrompues." + +msgid "Only missing tracks" +msgstr "Uniquement les pistes manquantes" + +msgid "Keeps existing offline copies and downloads tracks that are not available yet." +msgstr "Conserve les copies hors ligne existantes et télécharge les pistes qui ne sont pas encore disponibles." + +msgid "PRIVACY" +msgstr "CONFIDENTIALITÉ" + +msgid "Listening history" +msgstr "Historique d’écoute" + +msgid "Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler." +msgstr "Conserve un journal local de ce que vous écoutez : titre, source, durée écoutée et moment. Il reste sur cette machine, par compte, et ne la quitte jamais sauf si vous activez un scrobbler." + +msgid "Clear listening history" +msgstr "Effacer l’historique d’écoute" + +msgid "Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected." +msgstr "Supprime toutes les écoutes enregistrées sur cette machine pour ce compte. Les scrobbles déjà envoyés ne sont pas concernés." + +msgid "Listening history cleared." +msgstr "Historique d’écoute effacé." + +msgid "Couldn't clear listening history." +msgstr "Impossible d’effacer l’historique d’écoute." + +msgid "Checking ALSA mixer controls…" +msgstr "Vérification des commandes du mixeur ALSA…" + +msgid "This device exposes multiple playback controls. Choose which one QBZ should use." +msgstr "Ce périphérique expose plusieurs commandes de lecture. Choisissez celle que QBZ doit utiliser." + +msgid "The saved mixer control is no longer available. Choose another control." +msgstr "La commande de mixage enregistrée n’est plus disponible. Choisissez-en une autre." + +msgid "QBZ does not have permission to read this device's ALSA mixer." +msgstr "QBZ n’est pas autorisé à lire le mixeur ALSA de ce périphérique." + +msgid "The ALSA mixer is busy. Close the app using it and try again." +msgstr "Le mixeur ALSA est occupé. Fermez l’application qui l’utilise et réessayez." + +msgid "The selected ALSA device is no longer available." +msgstr "Le périphérique ALSA sélectionné n’est plus disponible." + +msgid "This ALSA device does not expose a mixer." +msgstr "Ce périphérique ALSA n’expose aucun mixeur." + +msgid "QBZ could not write the selected ALSA mixer control. Direct playback remains fixed at 100%." +msgstr "QBZ n’a pas pu modifier la commande du mixeur ALSA sélectionnée. La lecture directe reste fixée à 100 %." + +msgid "Selected mixer control" +msgstr "Commande de mixage sélectionnée" + +msgid "Choose mixer control" +msgstr "Choisir la commande de mixage" + +msgid "Change mixer control" +msgstr "Changer la commande de mixage" + +msgid "Qobuz Connect could not shut down safely. Quit and reopen QBZ, then try logging out again." +msgstr "Qobuz Connect n’a pas pu s’arrêter en toute sécurité. Quittez et rouvrez QBZ, puis réessayez de vous déconnecter." + +msgid "Show filter panels" +msgstr "Afficher les panneaux de filtres" + +msgid "Hide filter panels" +msgstr "Masquer les panneaux de filtres" + +msgid "Already in" +msgstr "Déjà dans" + +msgid "Playlist index updating..." +msgstr "Mise à jour de l'index des playlists..." + +msgid "{} of {}" +msgstr "{} sur {}" + +msgid "Last playlist" +msgstr "Dernière playlist" + +msgid "OK" +msgstr "OK" + +msgid "Sponsors" +msgstr "Sponsors" + +msgid "And all those anonymous sponsors..." +msgstr "Et tous ces sponsors anonymes..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect ne peut pas résoudre les morceaux locaux. {} morceau a été ignoré et n’a pas été ajouté à la file d’attente. Désactivez Qobuz Connect pour le lire." +msgstr[1] "Qobuz Connect ne peut pas résoudre les morceaux locaux. {} morceaux ont été ignorés et n’ont pas été ajoutés à la file d’attente. Désactivez Qobuz Connect pour les lire." + +msgid "Qobuz Connect device" +msgstr "Appareil Qobuz Connect" + +msgid "Continue playback on %1" +msgstr "Continuer la lecture sur %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Utiliser la file Qobuz Connect et garder cet appareil actif." + +msgid "Continue playback on this device" +msgstr "Continuer la lecture sur cet appareil" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Utiliser la file Qobuz Connect et remplacer la file locale." + +msgid "Continue this device's current playback" +msgstr "Continuer la lecture actuelle de cet appareil" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Remplacer la file et l’état de lecture de Qobuz Connect par la file et la position actuelles de cet appareil." + +msgid "Cancel Qobuz Connect" +msgstr "Annuler Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Conserver les deux files inchangées et continuer la lecture locale actuelle." + +msgid "Choose how to continue" +msgstr "Choisir comment continuer" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Cet appareil et un autre lecteur Qobuz Connect sont tous deux en cours de lecture. Choisissez la file et l’appareil qui doivent continuer." + +msgid "Ask every time" +msgstr "Toujours demander" + +msgid "Continue on the active renderer" +msgstr "Continuer sur le lecteur actif" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Continuer ici avec la file d’attente Qobuz Connect" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Continuer la lecture locale et remplacer la file d’attente Qobuz Connect" + +msgid "When playback conflicts" +msgstr "En cas de conflit de lecture" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Demander à chaque fois ou choisir automatiquement la file d’attente et l’appareil qui doivent continuer." + +msgid "Purchased" +msgstr "Acheté" + +msgid "Sort: Release date" +msgstr "Tri : Date de sortie" + +msgid "Until the purchased file is downloaded, Play streams the best quality Qobuz offers for this album. Once it is on disk, Play uses your copy." +msgstr "Tant que le fichier acheté n’est pas téléchargé, Lecture diffuse la meilleure qualité que Qobuz propose pour cet album. Une fois sur le disque, Lecture utilise votre copie." + +msgid "Open in Local Library" +msgstr "Ouvrir dans la bibliothèque locale" + +msgid "Import / Export" +msgstr "Import / Export" + +msgid "This bundle holds the app's own settings: audio, playback, appearance, integrations. It does not contain your Qobuz favorites, playlists, blacklist or purchases." +msgstr "Ce paquet contient les réglages de l'application : audio, lecture, apparence, intégrations. Il ne contient ni vos favoris, playlists, blacklist ni achats Qobuz." + +msgid "BLACKLIST" +msgstr "BLACKLIST" + +msgid "Export blacklist…" +msgstr "Exporter la blacklist…" + +msgid "Save your blocked artists and albums as a file you can hand to another QBZ user." +msgstr "Enregistre vos artistes et albums bloqués dans un fichier que vous pouvez transmettre à un autre utilisateur de QBZ." + +msgid "Import blacklist…" +msgstr "Importer une blacklist…" + +msgid "Add blocked artists and albums from a file. Nothing already on your blacklist is removed." +msgstr "Ajoute des artistes et albums bloqués depuis un fichier. Rien de ce qui est déjà sur votre blacklist n'est retiré." + +msgid "Import…" +msgstr "Importer…" + +msgid "Import failed." +msgstr "Échec de l'import." + +msgid "Not a QBZ blacklist file." +msgstr "Ce n'est pas un fichier de blacklist QBZ." + +msgid "Choose a blacklist file" +msgstr "Choisir un fichier de blacklist" + +msgid "Imported {} artists and {} albums ({} already blocked)." +msgstr "{} artistes et {} albums importés ({} déjà bloqués)." + +msgid "Member" +msgstr "Membre" + +msgid "QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™." +msgstr "QBZ nécessite un compte Qobuz™. Sans abonnement actif, la lecture se limite à des extraits de 30 secondes ; favoris, playlists et achats fonctionnent toujours. Vos identifiants sont envoyés directement à Qobuz™." + +msgid "ACCOUNT MIGRATION" +msgstr "MIGRATION DE COMPTE" + +msgid "Moves favorites, playlists and followed playlists from one Qobuz account into another, adding only what is missing and deleting nothing on either side. It does not move subscriptions or purchases: those stay with the Qobuz account that holds them." +msgstr "Transfère favoris, playlists et playlists suivies d'un compte Qobuz vers un autre, en n'ajoutant que ce qui manque et sans rien supprimer d'aucun côté. Les abonnements et les achats ne sont pas transférés : ils restent sur le compte Qobuz qui les détient." + +msgid "Create migration snapshot" +msgstr "Créer un instantané de migration" + +msgid "Sign in with the account you are moving FROM, then save its favorites and playlists to a file in its QBZ profile." +msgstr "Connectez-vous avec le compte DEPUIS lequel vous migrez, puis enregistrez ses favoris et playlists dans un fichier de son profil QBZ." + +msgid "Create…" +msgstr "Créer…" + +msgid "Migrate into this account" +msgstr "Migrer vers ce compte" + +msgid "Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing." +msgstr "Connectez-vous avec le compte VERS lequel vous migrez, puis choisissez un instantané ci-dessous. Répétable sans risque : une seconde exécution n'ajoute rien." + +msgid "No snapshots yet. Sign in with the old account and create one first." +msgstr "Aucun instantané pour l'instant. Connectez-vous avec l'ancien compte et créez-en un d'abord." + +msgid "{} favorites · {} playlists · {} followed" +msgstr "{} favoris · {} playlists · {} suivies" + +msgid "this account" +msgstr "ce compte" + +msgid "Migrate…" +msgstr "Migrer…" + +msgid "Account {} · {}" +msgstr "Compte {} · {}" + +msgid "Reading favorites and playlists…" +msgstr "Lecture des favoris et des playlists…" + +msgid "Reading this account…" +msgstr "Lecture de ce compte…" + +msgid "Adding favorites…" +msgstr "Ajout des favoris…" + +msgid "Adding playlists…" +msgstr "Ajout des playlists…" + +msgid "Following playlists…" +msgstr "Abonnement aux playlists…" + +msgid "{} / {} — {}" +msgstr "{} / {} — {}" + +msgid "Snapshot failed." +msgstr "Échec de l'instantané." + +msgid "Migration failed." +msgstr "Échec de la migration." + +msgid "Saved {} favorites, {} playlists and {} followed playlists to {}" +msgstr "{} favoris, {} playlists et {} playlists suivies enregistrés dans {}" + +msgid "That snapshot was taken from this same account." +msgstr "Cet instantané provient de ce même compte." + +msgid "Nothing to migrate: everything is already in this account." +msgstr "Rien à migrer : tout est déjà sur ce compte." + +msgid "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there." +msgstr "{} favoris, {} playlists ({} titres) et {} playlists suivies ajoutés ; {} étaient déjà là." + +msgid "{} items failed; see the log." +msgstr "{} éléments ont échoué ; voir le journal." + +msgid "Also copy the local profile" +msgstr "Copier aussi le profil local" + +msgid "Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer." +msgstr "Dossiers de la bibliothèque, dossiers et ordre des playlists, éléments épinglés, blacklist et préférences du profil QBZ de l'ancien compte sur cet ordinateur." + +msgid "Media server connections" +msgstr "Connexions aux serveurs multimédias" + +msgid "Plex, Jellyfin and Subsonic settings, including their credentials." +msgstr "Réglages Plex, Jellyfin et Subsonic, identifiants compris." + +msgid "Scrobbler accounts" +msgstr "Comptes de scrobbling" + +msgid "Last.fm and ListenBrainz settings, including their credentials." +msgstr "Réglages Last.fm et ListenBrainz, identifiants compris." + +msgid "The listen log and the events behind offline recommendations." +msgstr "Le journal d'écoute et les événements derrière les recommandations hors ligne." + +msgid "Local profile: {} rows copied; {} playlist rows had no match." +msgstr "Profil local : {} lignes copiées ; {} lignes de playlist sans correspondance." + +msgid "The library already had folders: run a rescan to index the added ones." +msgstr "La bibliothèque avait déjà des dossiers : relancez une analyse pour indexer ceux ajoutés." + +msgid "Restart QBZ to see the migrated local profile." +msgstr "Redémarrez QBZ pour voir le profil local migré." + +msgid "The local profile could not be copied; see the log." +msgstr "Le profil local n'a pas pu être copié ; voir le journal." +msgid "Delete…" +msgstr "Supprimer…" + +msgid "Delete migration snapshot?" +msgstr "Supprimer l'instantané de migration ?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Cela supprime uniquement le paquet de migration. Le profil QBZ et les données utilisateur de l'ancien compte sont conservés." + +msgid "Delete snapshot" +msgstr "Supprimer l'instantané" + +msgid "Migration snapshot deleted." +msgstr "Instantané de migration supprimé." + +msgid "Could not delete the migration snapshot." +msgstr "Impossible de supprimer l'instantané de migration." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} actualise sa bibliothèque ({} éléments analysés). Attendez la fin de l'opération, puis relancez la synchronisation." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "Aucune connexion Plex, Jellyfin ou Subsonic n'était enregistrée dans le profil source, aucune n'a donc pu être copiée." + +msgid "Media server connections copied: {} of {}." +msgstr "Connexions aux serveurs multimédias copiées : {} sur {}." + +msgid "Copying local profile…" +msgstr "Copie du profil local…" + +msgid "Refreshing migrated data…" +msgstr "Actualisation des données migrées…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Cette opération n'a lieu qu'une fois. Attendez qu'elle se termine et ne fermez pas la fenêtre." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Vous pouvez fermer ce panneau ; la migration se poursuivra en arrière-plan." + +msgid "From" +msgstr "Source" + +msgid "To" +msgstr "Destination" + +msgid "Step {} of {}" +msgstr "Étape {} sur {}" + +msgid "Elapsed: {}" +msgstr "Temps écoulé : {}" + +msgid "Remaining: {}" +msgstr "Temps restant : {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Vérifiez les profils sources enregistrés, choisissez les données à copier et confirmez le compte de destination connecté." + +msgid "Migrate account data" +msgstr "Migrer les données du compte" + +msgid "Start migration" +msgstr "Démarrer la migration" + +msgid "What is a migration profile?" +msgstr "Qu’est-ce qu’un profil de migration ?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Il s’agit d’un paquet à usage unique créé depuis le compte source. Il conserve ses favoris et playlists Qobuz afin de les ajouter au compte actuellement connecté. Les données locales facultatives sont copiées directement depuis l’ancien profil QBZ de cet ordinateur. Supprimer le paquet ne supprime ni l’ancien profil ni les données des comptes." + +msgid "SAVED SOURCE PROFILES" +msgstr "PROFILS SOURCES ENREGISTRÉS" + +msgid "No saved migration profiles are available." +msgstr "Aucun profil de migration enregistré n’est disponible." + +msgid "Selected" +msgstr "Sélectionné" + +msgid "Select" +msgstr "Sélectionner" + +msgid "MIGRATION DIRECTION" +msgstr "DIRECTION DE LA MIGRATION" + +msgid "Select a source profile above" +msgstr "Sélectionnez un profil source ci-dessus" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Compte Qobuz connecté · ID {}" + +msgid "WHAT TO COPY" +msgstr "DONNÉES À COPIER" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "La migration est additive : elle ne supprime aucun favori ni aucune playlist des deux comptes." + +msgid "That library folder is disabled." +msgstr "Ce dossier de bibliothèque est désactivé." + +msgid "That folder overlaps an existing library folder." +msgstr "Ce dossier chevauche un dossier de bibliothèque existant." + +msgid "This purchase is not available for download." +msgstr "Cet achat n’est pas disponible au téléchargement." + +msgid "Could not save playback preference." +msgstr "Impossible d’enregistrer la préférence de lecture." + +msgid "Qobuz · stream/offline copy" +msgstr "Qobuz · streaming/copie hors ligne" + +msgid "Local purchase" +msgstr "Achat local" + +msgid "Play with" +msgstr "Lire avec" + +msgid "Download purchase" +msgstr "Télécharger l’achat" + +msgid "1 track purchased" +msgstr "1 piste achetée" + +msgid "{} tracks purchased" +msgstr "{} pistes achetées" + +msgid "This copy cannot be continued right now." +msgstr "Cette copie ne peut pas être poursuivie pour le moment." + +msgid "Library folder refreshed." +msgstr "Dossier de bibliothèque actualisé." + +msgid "Download another copy" +msgstr "Télécharger une autre copie" + +msgid "Complete" +msgstr "Complète" + +msgid "Partial" +msgstr "Partielle" + +msgid "Missing files" +msgstr "Fichiers manquants" + +msgid "Changed files" +msgstr "Fichiers modifiés" + +msgid "Unreadable files" +msgstr "Fichiers illisibles" + +msgid "Local purchase copies" +msgstr "Copies locales de l’achat" + +msgid "{} of {} tracks" +msgstr "{} pistes sur {}" + +msgid "Continue copy" +msgstr "Poursuivre la copie" + +msgid "Add to Local Library" +msgstr "Ajouter à la bibliothèque locale" + +msgid "In Local Library" +msgstr "Dans la bibliothèque locale" + +msgid "{} of {} tracks available in this folder." +msgstr "{} pistes sur {} disponibles dans ce dossier." + +msgid "{} failed" +msgstr "{} en échec" + +msgid "{} cancelled" +msgstr "{} annulées" + +msgid "Download finished" +msgstr "Téléchargement terminé" + +msgid "Shuffle: On" +msgstr "Lecture aléatoire : activée" + +msgid "Shuffle: Off" +msgstr "Lecture aléatoire : désactivée" + +msgid "Repeat: Off" +msgstr "Répéter : désactivé" + +msgid "Repeat: All" +msgstr "Répéter : tout" + +msgid "Repeat: One" +msgstr "Répéter : un titre" + +msgid "Show album artwork in Queue track list" +msgstr "Afficher les pochettes dans la liste des pistes de la file d’attente" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Remplace les numéros de piste de la file d’attente par des miniatures de pochette. Désactivé par défaut." + +msgid "Show album artwork in Library track list" +msgstr "Afficher les pochettes dans la liste des pistes de la Bibliothèque" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Affiche la miniature de la pochette entre le numéro et le titre de la piste. Désactivé par défaut." + +msgid "Play random" +msgstr "Lecture aléatoire" + +msgid "Play selected" +msgstr "Lire la sélection" + +msgid "Cancel selection" +msgstr "Annuler la sélection" + +msgid "QOBUZ LINKS" +msgstr "LIENS QOBUZ" + +msgid "Open Qobuz links in QBZ" +msgstr "Ouvrir les liens Qobuz dans QBZ" + +msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here." +msgstr "Fait de QBZ le gestionnaire des liens qobuzapp:// venant du navigateur. Désactivé, l'application Qobuz officielle les conserve ; les liens qbz:// de QBZ s'ouvrent toujours ici." + +msgid "Look for better replacement" +msgstr "Chercher un meilleur remplacement" + +msgid "Remove all favorites from this release" +msgstr "Retirer tous les favoris de cette parution" + +msgid "Remove all favorites from this release?" +msgstr "Retirer tous les favoris de cette parution ?" + +msgid "Remove the album and every track from this release from your Library?" +msgstr "Retirer de votre Bibliothèque l’album et toutes les pistes de cette parution ?" + +msgid "No replacement releases found" +msgstr "Aucune parution de remplacement trouvée" + +msgid "Choosing a result opens the release. Your existing favorites are not changed." +msgstr "Le résultat choisi ouvre la parution. Vos favoris existants ne sont pas modifiés." + +msgid "No favorites from this release to remove" +msgstr "Aucun favori de cette parution à retirer" + +msgid "Favorites removed from this release" +msgstr "Favoris de cette parution retirés" + +msgid "Could not remove favorites from this release" +msgstr "Impossible de retirer les favoris de cette parution" + +msgid "Some favorites could not be removed" +msgstr "Certains favoris n’ont pas pu être retirés" + +msgid "Replace in Library" +msgstr "Remplacer dans la bibliothèque" + +msgid "Replacing favorite..." +msgstr "Remplacement du favori..." + +msgid "The selected release replaces the unavailable album favorite." +msgstr "La parution sélectionnée remplace l’album favori indisponible." + +msgid "The matching track from the selected release replaces the unavailable favorite." +msgstr "Le titre correspondant de la parution sélectionnée remplace le favori indisponible." + +msgid "Could not open the selected release" +msgstr "Impossible d’ouvrir la parution sélectionnée" + +msgid "Could not find a matching track in this release" +msgstr "Aucun titre correspondant trouvé dans cette parution" + +msgid "Could not add the replacement favorite" +msgstr "Impossible d’ajouter le favori de remplacement" + +msgid "Favorite replaced" +msgstr "Favori remplacé" + +msgid "The replacement was added, but the unavailable favorite could not be removed" +msgstr "Le remplacement a été ajouté, mais le favori indisponible n’a pas pu être supprimé" + +msgid "Failed to send playback to the selected device" +msgstr "Impossible de transférer la lecture vers l'appareil sélectionné" + +msgid "Move up" +msgstr "Monter" + +msgid "Move down" +msgstr "Descendre" + +msgid "Download" +msgstr "Télécharger" + +msgid "Edit" +msgstr "Modifier" + +msgid "Grid" +msgstr "Grille" + +msgid "List" +msgstr "Liste" + +msgid "Tree" +msgstr "Arborescence" + +msgid "Forward" +msgstr "Suivant" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po new file mode 100644 index 000000000..870a178d2 --- /dev/null +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -0,0 +1,6826 @@ +msgid "" +msgstr "" +"Project-Id-Version: qbz-slint\n" +"PO-Revision-Date: 2026-09-05 19:52-0600\n" +"Last-Translator: QBZ contributors\n" +"Language-Team: QBZ ja translators\n" +"Language: ja\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +msgid "System Colors" +msgstr "システムカラー" + +msgid "Wallpaper Sync" +msgstr "壁紙と同期" + +msgid "Custom Image" +msgstr "カスタム画像" + +msgid "Auto" +msgstr "自動" + +msgid "Disabled" +msgstr "無効" + +msgid "Replace current queue" +msgstr "現在のキューを置き換え" + +msgid "Play next" +msgstr "次に再生" + +msgid "Add to queue" +msgstr "キューに追加" + +msgid "Remember last" +msgstr "前回を記憶" + +msgid "Album Reactive" +msgstr "アルバム連動" + +msgid "Static" +msgstr "静止" + +msgid "Coverflow" +msgstr "カバーフロー" + +msgid "Spectrum" +msgstr "スペクトラム" + +msgid "Lyrics" +msgstr "歌詞" + +msgid "Queue" +msgstr "キュー" + +msgid "Auto (opposite to window controls)" +msgstr "自動(ウィンドウコントロールの反対側)" + +msgid "Left" +msgstr "左" + +msgid "Right" +msgstr "右" + +msgid "Rectangular" +msgstr "長方形" + +msgid "Full-height rounded" +msgstr "全高・角丸" + +msgid "Circular" +msgstr "円形" + +msgid "Square" +msgstr "正方形" + +msgid "Small" +msgstr "小" + +msgid "Normal" +msgstr "標準" + +msgid "Large" +msgstr "大" + +msgid "Default" +msgstr "デフォルト" + +msgid "Monochrome" +msgstr "モノクロ" + +msgid "Custom" +msgstr "カスタム" + +msgid "Remember last used" +msgstr "前回の使用を記憶" + +msgid "Micro" +msgstr "マイクロ" + +msgid "Compact" +msgstr "コンパクト" + +msgid "Artwork" +msgstr "アートワーク" + +msgid "Home" +msgstr "ホーム" + +msgid "Where you left off" +msgstr "続きから" + +msgid "Mono light" +msgstr "モノ・ライト" + +msgid "Mono dark" +msgstr "モノ・ダーク" + +msgid "Color" +msgstr "カラー" + +msgid "Vinyl" +msgstr "レコード" + +msgid "Visualizer" +msgstr "ビジュアライザー" + +msgid "Neon Flow" +msgstr "ネオンフロー" + +msgid "Tunnel" +msgstr "トンネル" + +msgid "Comet" +msgstr "コメット" + +msgid "Split: Lyrics" +msgstr "分割:歌詞" + +msgid "Split: Track Info" +msgstr "分割:トラック情報" + +msgid "Split: Suggestions" +msgstr "分割:おすすめ" + +msgid "Split: Queue" +msgstr "分割:キュー" + +msgid "Full" +msgstr "フル" + +msgid "Lite" +msgstr "ライト" + +msgid "Off" +msgstr "オフ" + +msgid "versions" +msgstr "バージョン" + +msgid "Search tracks..." +msgstr "トラックを検索..." + +msgid "Title" +msgstr "タイトル" + +msgid "Quality" +msgstr "音質" + +msgid "Disc" +msgstr "ディスク" + +msgid "Loading…" +msgstr "読み込み中…" + +msgid "Failed to load booklet" +msgstr "ブックレットを読み込めませんでした" + +msgid "Add cover" +msgstr "カバーを追加" + +msgid "Change cover" +msgstr "カバーを変更" + +msgid "Remove cover" +msgstr "カバーを削除" + +msgid "Open in browser" +msgstr "ブラウザで開く" + +msgid "Save as…" +msgstr "名前を付けて保存…" + +msgid "Read more" +msgstr "続きを読む" + +msgid "Loading album…" +msgstr "アルバムを読み込み中…" + +msgid "Select all" +msgstr "すべて選択" + +msgid "Add to playlist" +msgstr "プレイリストに追加" + +msgid "Add to Library" +msgstr "ライブラリに追加" + +msgid "Make available offline" +msgstr "オフラインで利用可能にする" + +msgid "Clear" +msgstr "クリア" + +msgid "LABEL" +msgstr "レーベル" + +msgid "AWARDS" +msgstr "アワード" + +msgid "EXTERNAL LINKS" +msgstr "外部リンク" + +msgid "About this album" +msgstr "このアルバムについて" + +msgid "Released by" +msgstr "リリース元" + +msgid "on" +msgstr "リリース日" + +msgid "Credits" +msgstr "クレジット" + +msgid "Review" +msgstr "レビュー" + +msgid "Failed to load album credits" +msgstr "アルバムのクレジットを読み込めませんでした" + +msgid "Loading credits..." +msgstr "クレジットを読み込み中..." + +msgid "Loading track info..." +msgstr "トラック情報を読み込み中..." + +msgid "Failed to load track info" +msgstr "トラック情報を読み込めませんでした" + +msgid "Label" +msgstr "レーベル" + +msgid "Newest" +msgstr "新しい順" + +msgid "Oldest" +msgstr "古い順" + +msgid "A–Z" +msgstr "A–Z" + +msgid "Z–A" +msgstr "Z–A" + +msgid "Failed to load Library" +msgstr "ライブラリを読み込めませんでした" + +msgid "Retry" +msgstr "再試行" + +msgid "No releases found." +msgstr "リリースが見つかりません。" + +msgid "See discography" +msgstr "ディスコグラフィを見る" + +msgid "Load more" +msgstr "さらに読み込む" + +msgid "Add image" +msgstr "画像を追加" + +msgid "Change image" +msgstr "画像を変更" + +msgid "Remove image" +msgstr "画像を削除" + +msgid "Create Artist Collection" +msgstr "アーティストコレクションを作成" + +msgid "Artist Scene" +msgstr "アーティストシーン" + +msgid "Share" +msgstr "共有" + +msgid "Show artist" +msgstr "アーティストを表示" + +msgid "Blacklist artist" +msgstr "アーティストをブラックリストに追加" + +msgid "This artist is hidden from discovery" +msgstr "このアーティストは「見つける」に表示されません" + +msgid "Loading artist…" +msgstr "アーティストを読み込み中…" + +msgid "Popular Tracks" +msgstr "人気のトラック" + +msgid "Play all next" +msgstr "すべて次に再生" + +msgid "Play all later" +msgstr "すべて後で再生" + +msgid "Add all to queue" +msgstr "すべてキューに追加" + +msgid "Shuffle all" +msgstr "すべてシャッフル" + +msgid "Add all to playlist" +msgstr "すべてプレイリストに追加" + +msgid "View less" +msgstr "表示を減らす" + +msgid "Latest release" +msgstr "最新リリース" + +msgid "Appears On" +msgstr "参加作品" + +msgid "Hide" +msgstr "非表示" + +msgid "Show" +msgstr "表示" + +msgid "Network" +msgstr "ネットワーク" + +msgid "Magazine" +msgstr "マガジン" + +msgid "Loading origin…" +msgstr "出身情報を読み込み中…" + +msgid "BORN" +msgstr "生年月日" + +msgid "FOUNDED" +msgstr "結成" + +msgid "BORN IN" +msgstr "出身地" + +msgid "FOUNDED IN" +msgstr "結成地" + +msgid "DIED" +msgstr "没年" + +msgid "DISBANDED" +msgstr "解散" + +msgid "LABELS" +msgstr "レーベル" + +msgid "No label info" +msgstr "レーベル情報なし" + +msgid "SIMILAR ARTISTS" +msgstr "類似アーティスト" + +msgid "RELATIONSHIPS" +msgstr "関係" + +msgid "Members & Former" +msgstr "メンバー・元メンバー" + +msgid "Member Of" +msgstr "所属" + +msgid "Collaborators" +msgstr "共演者" + +msgid "YOU MAY ALSO LIKE" +msgstr "こちらもおすすめ" + +msgid "No stories for this artist" +msgstr "このアーティストのストーリーはありません" + +msgid "About this artist" +msgstr "このアーティストについて" + +msgid "Source" +msgstr "出典" + +msgid "Following" +msgstr "フォロー中" + +msgid "Follow" +msgstr "フォロー" + +msgid "View all" +msgstr "すべて表示" + +msgid "View all →" +msgstr "すべて表示 →" + +msgid "Filter by genre" +msgstr "ジャンルで絞り込む" + +msgid "Remember selection" +msgstr "選択を記憶" + +msgid "Advanced view" +msgstr "詳細表示" + +msgid "Search genres…" +msgstr "ジャンルを検索…" + +msgid "Clear filter" +msgstr "フィルターをクリア" + +msgid "Qobuz Mixes" +msgstr "Qobuzミックス" + +msgid "Elevate your day with a customized selection of music." +msgstr "カスタマイズされた選曲で一日を彩りましょう。" + +msgid "Take a weekly journey with a fresh mix every Friday." +msgstr "毎週金曜日の新しいミックスで週ごとの旅へ。" + +msgid "A fresh shuffle from your personal library." +msgstr "あなたのライブラリからの新しいシャッフル。" + +msgid "Discover new music from your most-played playlists." +msgstr "よく再生するプレイリストから新しい音楽を見つけましょう。" + +msgid "Open album" +msgstr "アルバムを開く" + +msgid "Open containing album" +msgstr "収録アルバムを開く" + +msgid "Play" +msgstr "再生" + +msgid "Search…" +msgstr "検索…" + +msgid "1 genre" +msgstr "1ジャンル" + +msgid "{} genres" +msgstr "{}ジャンル" + +msgid "No results found." +msgstr "結果が見つかりません。" + +msgid "Editor's Picks" +msgstr "エディターズピック" + +msgid "For You" +msgstr "あなたへのおすすめ" + +msgid "Qobuz Playlists" +msgstr "Qobuzプレイリスト" + +msgid "No playlists match the selected categories." +msgstr "選択したカテゴリに一致するプレイリストはありません。" + +msgid "Recently Played Albums" +msgstr "最近再生したアルバム" + +msgid "Albums you play will appear here." +msgstr "再生したアルバムがここに表示されます。" + +msgid "Recently Played Tracks" +msgstr "最近再生したトラック" + +msgid "Tracks you play will appear here." +msgstr "再生したトラックがここに表示されます。" + +msgid "Popular albums" +msgstr "人気のアルバム" + +msgid "{} selected" +msgstr "{}選択中" + +msgid "Filter by category" +msgstr "カテゴリで絞り込む" + +msgid "All categories" +msgstr "すべてのカテゴリ" + +msgid "Customize" +msgstr "カスタマイズ" + +msgid "Enabling more sections increases load time." +msgstr "セクションを増やすと読み込み時間が長くなります。" + +msgid "{} of {} enabled" +msgstr "{}/{}有効" + +msgid "Reset to defaults" +msgstr "デフォルトに戻す" + +msgid "Radio Stations" +msgstr "ラジオステーション" + +msgid "RADIO" +msgstr "ラジオ" + +msgid "Your Top Artists" +msgstr "よく聴くアーティスト" + +msgid "Artists to Follow" +msgstr "フォローするアーティスト" + +msgid "Pinned" +msgstr "ピン留め" + +msgid "Pin" +msgstr "ピン留め" + +msgid "Unpin" +msgstr "ピン留めを解除" + +msgid "Showing {} of {} track." +msgid_plural "Showing {} of {} tracks." +msgstr[0] "{} / {} トラックを表示中" + +msgid "Spotlight" +msgstr "スポットライト" + +msgid "Shine a light on one of your favourite artists." +msgstr "お気に入りのアーティストにスポットライトを当てましょう。" + +msgid "ARTIST" +msgstr "アーティスト" + +msgid "Top tracks" +msgstr "トップトラック" + +msgid "By {}" +msgstr "{}による" + +msgid "TOP TRACKS" +msgstr "トップトラック" + +msgid "Qobuz Radio Station" +msgstr "Qobuzラジオステーション" + +msgid "Available offline" +msgstr "オフラインで利用可能" + +msgid "Tracks" +msgstr "トラック" + +msgid "Albums" +msgstr "アルバム" + +msgid "Artists" +msgstr "アーティスト" + +msgid "Playlists" +msgstr "プレイリスト" + +msgid "Labels" +msgstr "レーベル" + +msgid "Favorite" +msgstr "お気に入り" + +msgid "Library" +msgstr "ライブラリ" + +msgid "Search" +msgstr "検索" + +msgid "Group: Off" +msgstr "グループ:オフ" + +msgid "Group: Album" +msgstr "グループ:アルバム" + +msgid "Group: Artist" +msgstr "グループ:アーティスト" + +msgid "Group: Name" +msgstr "グループ:名前" + +msgid "Group: A-Z" +msgstr "グループ:A-Z" + +msgid "Title A-Z" +msgstr "タイトルA-Z" + +msgid "Title Z-A" +msgstr "タイトルZ-A" + +msgid "Artist A-Z" +msgstr "アーティストA-Z" + +msgid "Couldn't load your Library." +msgstr "ライブラリを読み込めませんでした。" + +msgid "Remove from Library" +msgstr "ライブラリから削除" + +msgid "No tracks in your Library yet." +msgstr "ライブラリにはまだトラックがありません。" + +msgid "No tracks match your search." +msgstr "検索に一致するトラックはありません。" + +msgid "No albums in your Library yet." +msgstr "ライブラリにはまだアルバムがありません。" + +msgid "No albums match your search." +msgstr "検索に一致するアルバムはありません。" + +msgid "No artists in your Library yet." +msgstr "ライブラリにはまだアーティストがありません。" + +msgid "No artists match your search." +msgstr "検索に一致するアーティストはありません。" + +msgid "No playlists match your search." +msgstr "検索に一致するプレイリストはありません。" + +msgid "No followed playlists yet." +msgstr "フォロー中のプレイリストはまだありません。" + +msgid "No playlists in your Library yet." +msgstr "ライブラリにはまだプレイリストがありません。" + +msgid "No labels in your Library yet." +msgstr "ライブラリにはまだレーベルがありません。" + +msgid "No labels match your search." +msgstr "検索に一致するレーベルはありません。" + +msgid "Select an artist to see their albums." +msgstr "アーティストを選択してアルバムを表示します。" + +msgid "Couldn't load albums." +msgstr "アルバムを読み込めませんでした。" + +msgid "No albums found." +msgstr "アルバムが見つかりません。" + +msgid "Go to artist page" +msgstr "アーティストページへ移動" + +msgid "albums" +msgstr "アルバム" + +msgid "Releases" +msgstr "リリース" + +msgid "Search this label…" +msgstr "このレーベルを検索…" + +msgid "No results" +msgstr "結果なし" + +msgid "More labels" +msgstr "その他のレーベル" + +msgid "About" +msgstr "概要" + +msgid "Folders" +msgstr "フォルダ" + +msgid "tracks" +msgstr "トラック" + +msgid "Play all" +msgstr "すべて再生" + +msgid "Clear session" +msgstr "セッションをクリア" + +msgid "No playable tracks in this folder." +msgstr "このフォルダに再生可能なトラックはありません。" + +msgid "Play this album" +msgstr "このアルバムを再生" + +msgid "Local Library" +msgstr "ローカルライブラリ" + +msgid "Artist Z-A" +msgstr "アーティストZ-A" + +msgid "Year (newest)" +msgstr "年(新しい順)" + +msgid "Year (oldest)" +msgstr "年(古い順)" + +msgid "No grouping" +msgstr "グループ化なし" + +msgid "Group by letter" +msgstr "文字でグループ化" + +msgid "Group by artist" +msgstr "アーティストでグループ化" + +msgid "By album" +msgstr "アルバム別" + +msgid "By artist" +msgstr "アーティスト別" + +msgid "By name" +msgstr "名前別" + +msgid "Search artists" +msgstr "アーティストを検索" + +msgid "Couldn't load your local albums." +msgstr "ローカルアルバムを読み込めませんでした。" + +msgid "No albums in your local library yet." +msgstr "ローカルライブラリにアルバムはまだありません。" + +msgid "Couldn't load your local tracks." +msgstr "ローカルトラックを読み込めませんでした。" + +msgid "No tracks in your local library yet." +msgstr "ローカルライブラリにトラックはまだありません。" + +msgid "Add to Mixtape/Collection" +msgstr "ミックステープ/コレクションに追加" + +msgid "No folders in your local library yet." +msgstr "ローカルライブラリにフォルダはまだありません。" + +msgid "No folders match your search." +msgstr "検索に一致するフォルダはありません。" + +msgid "Search folders" +msgstr "フォルダを検索" + +msgid "sel" +msgstr "選択" + +msgid "Check all" +msgstr "すべてチェック" + +msgid "Select a folder to see its contents." +msgstr "フォルダを選択して内容を表示します。" + +msgid "Search albums..." +msgstr "アルバムを検索..." + +msgid "This folder has no tracks." +msgstr "このフォルダにトラックはありません。" + +msgid "No artists in your local library yet." +msgstr "ローカルライブラリにアーティストはまだありません。" + +msgid "artists" +msgstr "アーティスト" + +msgid "Select an artist to view their albums" +msgstr "アーティストを選択してアルバムを表示" + +msgid "No albums found" +msgstr "アルバムが見つかりません" + +msgid "Local Library port in progress" +msgstr "ローカルライブラリの移植が進行中です" + +msgid "This tab is coming next." +msgstr "このタブは近日公開予定です。" + +msgid "Filter" +msgstr "フィルター" + +msgid "Hi-Res" +msgstr "ハイレゾ" + +msgid "CD" +msgstr "CD" + +msgid "Lossy" +msgstr "ロッシー" + +msgid "Format" +msgstr "フォーマット" + +msgid "Other" +msgstr "その他" + +msgid "Local" +msgstr "ローカル" + +msgid "Offline cache" +msgstr "オフラインキャッシュ" + +msgid "Scene" +msgstr "シーン" + +msgid "No artists found for this scene." +msgstr "このシーンのアーティストが見つかりません。" + +msgid "QBZ" +msgstr "QBZ" + +msgid "QOBUZ™ PLAYER" +msgstr "QOBUZ™ PLAYER" + +msgid "I have read the" +msgstr "以下を読みました" + +msgid "Qobuz™ Terms of Service" +msgstr "Qobuz™利用規約" + +msgid "Sign in via Browser" +msgstr "ブラウザでログイン" + +msgid "Use your system browser instead" +msgstr "代わりにシステムのブラウザを使う" + +msgid "A network sign-in page may be blocking the connection" +msgstr "ネットワークのサインインページが接続をブロックしている可能性があります" + +msgid "Could not restore your session" +msgstr "セッションを復元できませんでした" + +msgid "Start offline (no access to Qobuz™ services)" +msgstr "オフラインで開始(Qobuz™サービスは利用不可)" + +msgid "This application uses the Qobuz API but is not certified by Qobuz." +msgstr "このアプリケーションはQobuz APIを使用していますが、Qobuzの認定は受けていません。" + +msgid "No tracks for this mix." +msgstr "このミックスにトラックがありません。" + +msgid "Album" +msgstr "アルバム" + +msgid "Bands & Projects" +msgstr "バンド&プロジェクト" + +msgid "No album appearances found." +msgstr "アルバムへの参加が見つかりません。" + +msgid "You're offline" +msgstr "オフラインです" + +msgid "Offline mode is enabled. Disable it in Settings to use Qobuz." +msgstr "オフラインモードが有効です。Qobuzを使用するには設定で無効にしてください。" + +msgid "No internet connection. Your local library and downloads keep working." +msgstr "インターネット接続がありません。ローカルライブラリとダウンロードは引き続き利用できます。" + +msgid "Open Settings" +msgstr "設定を開く" + +msgid "Offline Cache" +msgstr "オフラインキャッシュ" + +msgid "{} track" +msgid_plural "{} tracks" +msgstr[0] "{}トラック" + +msgid "Sort: A-Z" +msgstr "並べ替え:A-Z" + +msgid "Sort: Recent" +msgstr "並べ替え:最近" + +msgid "Sort: Largest" +msgstr "並べ替え:サイズ大" + +msgid "Sort: Smallest" +msgstr "並べ替え:サイズ小" + +msgid "Failed only" +msgstr "失敗のみ" + +msgid " selected" +msgstr "選択中" + +msgid "Re-download" +msgstr "再ダウンロード" + +msgid "Remove" +msgstr "削除" + +msgid "All artists" +msgstr "すべてのアーティスト" + +msgid "No offline tracks yet" +msgstr "オフライントラックはまだありません" + +msgid "Search playlists…" +msgstr "プレイリストを検索…" + +msgid "1 playlist" +msgstr "1プレイリスト" + +msgid "{} playlists" +msgstr "{}プレイリスト" + +msgid "Move to folder" +msgstr "フォルダに移動" + +msgid "No folder" +msgstr "フォルダなし" + +msgid "Playlist manager" +msgstr "プレイリストマネージャー" + +msgid "Visible" +msgstr "表示" + +msgid "Hidden" +msgstr "非表示" + +msgid "All" +msgstr "すべて" + +msgid "Recent" +msgstr "最近" + +msgid "Play Count" +msgstr "再生回数" + +msgid "Track Count" +msgstr "トラック数" + +msgid "Name A-Z" +msgstr "名前A-Z" + +msgid "New folder" +msgstr "新規フォルダ" + +msgid "{} folders, {} playlists" +msgstr "{}フォルダ、{}プレイリスト" + +msgid "Use the arrows to reorder playlists" +msgstr "矢印でプレイリストを並び替え" + +msgid "Folders ({})" +msgstr "フォルダ({})" + +msgid "Playlists ({})" +msgstr "プレイリスト({})" + +msgid "No playlists found." +msgstr "プレイリストが見つかりません。" + +msgid "Artist" +msgstr "アーティスト" + +msgid "Playlist" +msgstr "プレイリスト" + +msgid "Search tracks" +msgstr "トラックを検索" + +msgid "Sort" +msgstr "並べ替え" + +msgid "This playlist is empty." +msgstr "このプレイリストは空です。" + +msgid "Upload to Qobuz?" +msgstr "Qobuzにアップロードしますか?" + +msgid "Cancel" +msgstr "キャンセル" + +msgid "Upload" +msgstr "アップロード" + +msgid "About this playlist" +msgstr "このプレイリストについて" + +msgid "ITEM" +msgstr "項目" + +msgid "TYPE" +msgstr "タイプ" + +msgid "SOURCE" +msgstr "ソース" + +msgid "QUALITY" +msgstr "音質" + +msgid "TRACKS" +msgstr "トラック" + +msgid "YEAR" +msgstr "年" + +msgid "Folder name" +msgstr "フォルダ名" + +msgid "Creating..." +msgstr "作成中..." + +msgid "Create" +msgstr "作成" + +msgid "Edit folder" +msgstr "フォルダを編集" + +msgid "Enter folder name" +msgstr "フォルダ名を入力" + +msgid "Icon" +msgstr "アイコン" + +msgid "Hide from sidebar" +msgstr "サイドバーから非表示" + +msgid "Delete" +msgstr "削除" + +msgid "Save" +msgstr "保存" + +msgid "JUMP TO" +msgstr "ジャンプ" + +msgid "Search this page…" +msgstr "このページを検索…" + +msgid "Something is already playing" +msgstr "すでに再生中です" + +msgid "Clear queue and play" +msgstr "キューをクリアして再生" + +msgid "Edit metadata" +msgstr "メタデータを編集" + +msgid "Album title" +msgstr "アルバムタイトル" + +msgid "Album artist" +msgstr "アルバムアーティスト" + +msgid "Year" +msgstr "年" + +msgid "Genre" +msgstr "ジャンル" + +msgid "Catalog number" +msgstr "カタログ番号" + +msgid "Total discs" +msgstr "ディスク総数" + +msgid "Searching…" +msgstr "検索中…" + +msgid "No results, try a different search" +msgstr "結果がありません。別のキーワードで検索してください" + +msgid "Apply" +msgstr "適用" + +msgid "Album folder" +msgstr "アルバムフォルダ" + +msgid "Sidecar file (recommended)" +msgstr "サイドカーファイル(推奨)" + +msgid "Write to audio files" +msgstr "オーディオファイルに書き込む" + +msgid "This writes tags directly into your audio files." +msgstr "タグをオーディオファイルに直接書き込みます。" + +msgid "Direct write is unavailable for CUE-based albums." +msgstr "CUEベースのアルバムでは直接書き込みは利用できません。" + +msgid "Writing" +msgstr "書き込み中" + +msgid "Saving…" +msgstr "保存中…" + +msgid "Edit playlist" +msgstr "プレイリストを編集" + +msgid "Playlist name" +msgstr "プレイリスト名" + +msgid "Description" +msgstr "説明" + +msgid "Offline-only playlist" +msgstr "オフライン専用プレイリスト" + +msgid "New playlist" +msgstr "新規プレイリスト" + +msgid "Name" +msgstr "名前" + +msgid "My Playlist" +msgstr "マイプレイリスト" + +msgid "Description (optional)" +msgstr "説明(任意)" + +msgid "Folder" +msgstr "フォルダ" + +msgid "Make playlist public" +msgstr "プレイリストを公開する" + +msgid "Created locally — you're offline" +msgstr "ローカルで作成されました — オフラインです" + +msgid "Add to mixtape" +msgstr "ミックステープに追加" + +msgid "Share Qobuz link" +msgstr "Qobuzリンクを共有" + +msgid "Share Album.link" +msgstr "Album.linkを共有" + +msgid "Refresh offline copy" +msgstr "オフラインコピーを更新" + +msgid "Some of these tracks were already added" +msgstr "これらのトラックの一部はすでに追加されています" + +msgid "Adding..." +msgstr "追加中..." + +msgid "Add all tracks" +msgstr "すべてのトラックを追加" + +msgid "Add only new tracks" +msgstr "新しいトラックのみ追加" + +msgid "Add to Playlist" +msgstr "プレイリストに追加" + +msgid "Choose playlist" +msgstr "プレイリストを選択" + +msgid "Adding {} local track(s)" +msgstr "{}個のローカルトラックを追加中" + +msgid "Adding {} track(s)" +msgstr "{}個のトラックを追加中" + +msgid "+ Create new playlist" +msgstr "+新規プレイリストを作成" + +msgid "Search playlists..." +msgstr "プレイリストを検索..." + +msgid "Playlist Name" +msgstr "プレイリスト名" + +msgid "Saving..." +msgstr "保存中..." + +msgid "Create & Add" +msgstr "作成して追加" + +msgid "Add" +msgstr "追加" + +msgid "No playlists found" +msgstr "プレイリストが見つかりません" + +msgid "{} more playlist(s)..." +msgstr "他{}プレイリスト..." + +msgid "Import Playlist" +msgstr "プレイリストをインポート" + +msgid "This feature requires internet connection" +msgstr "この機能にはインターネット接続が必要です" + +msgid "Playlist URL" +msgstr "プレイリストURL" + +msgid "ALLOWED SOURCES" +msgstr "許可されたソース" + +msgid "Conversion progress" +msgstr "変換の進行状況" + +msgid "Summary" +msgstr "概要" + +msgid "Close" +msgstr "閉じる" + +msgid "Importing..." +msgstr "インポート中..." + +msgid "Import" +msgstr "インポート" + +msgid "Fetching..." +msgstr "取得中..." + +msgid "Fetch playlist" +msgstr "プレイリストを取得" + +msgid "Shuffle" +msgstr "シャッフル" + +msgid "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" +msgstr "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" + +msgid "24-bit / 44.1 kHz — Billie Eilish · LUNCH" +msgstr "24-bit / 44.1 kHz — Billie Eilish · LUNCH" + +msgid "24-bit / 96 kHz — Iron Maiden · Stratego" +msgstr "24-bit / 96 kHz — Iron Maiden · Stratego" + +msgid "24-bit / 192 kHz — Toto · Africa" +msgstr "24-bit / 192 kHz — Toto · Africa" + +msgid "Welcome" +msgstr "ようこそ" + +msgid "Check" +msgstr "チェック" + +msgid "DACs" +msgstr "DAC" + +msgid "Configure" +msgstr "設定" + +msgid "Test" +msgstr "テスト" + +msgid "Done" +msgstr "完了" + +msgid "HiFi Wizard — DAC Setup" +msgstr "HiFiウィザード — DACセットアップ" + +msgid "Guided DAC Setup" +msgstr "ガイド付きDACセットアップ" + +msgid "What to test with" +msgstr "テストに使用する音源" + +msgid "I understand and accept these terms" +msgstr "これらの規約を理解し、同意します" + +msgid "Check Audio Stack" +msgstr "オーディオスタックをチェック" + +msgid "Sandboxed — host check unavailable" +msgstr "サンドボックス環境 — ホストのチェックは利用できません" + +msgid "Audio stack ready" +msgstr "オーディオスタックの準備完了" + +msgid "Some packages are missing" +msgstr "一部のパッケージがありません" + +msgid "Your distribution" +msgstr "お使いのディストリビューション" + +msgid "Init system (service commands)" +msgstr "Initシステム(サービスコマンド)" + +msgid "Which DACs do you want to configure?" +msgstr "設定するDACを選択してください" + +msgid "Detecting your DACs…" +msgstr "DACを検出中…" + +msgid "default" +msgstr "デフォルト" + +msgid "Hide manual entry" +msgstr "手動入力を非表示" + +msgid "Can't see your DAC?" +msgstr "DACが見つかりませんか?" + +msgid "Find your DAC's PipeWire node name and paste it here:" +msgstr "DACのPipeWireノード名を見つけてここに貼り付けてください:" + +msgid "1. Find your DAC under 'Audio > Sinks' and note its number" +msgstr "1. 'Audio > Sinks'でDACを見つけて番号をメモします" + +msgid "2. Run with that number to read the node.name" +msgstr "2. その番号で実行してnode.nameを読み取ります" + +msgid "Valid node name" +msgstr "有効なノード名" + +msgid "Invalid — should contain alsa_output or alsa_input" +msgstr "無効 — alsa_outputまたはalsa_inputを含む必要があります" + +msgid "Review & Apply" +msgstr "確認して適用" + +msgid "QBZ never writes these files" +msgstr "QBZはこれらのファイルを書き込みません" + +msgid "1. Back up your current PipeWire/WirePlumber config" +msgstr "1. 現在のPipeWire/WirePlumber設定をバックアップします" + +msgid "I created a backup" +msgstr "バックアップを作成しました" + +msgid "Sample-rate switching (PipeWire)" +msgstr "サンプルレート切り替え(PipeWire)" + +msgid "Per-app bit-perfect (PipeWire client)" +msgstr "アプリごとのビットパーフェクト(PipeWireクライアント)" + +msgid "Pin the DAC + rates (WirePlumber)" +msgstr "DACとレートを固定(WirePlumber)" + +msgid "I created the configuration files" +msgstr "設定ファイルを作成しました" + +msgid "3. Restart the audio services to load the config" +msgstr "3. オーディオサービスを再起動して設定を読み込みます" + +msgid "I restarted the audio services" +msgstr "オーディオサービスを再起動しました" + +msgid "Test Playback" +msgstr "再生をテスト" + +msgid "Stop" +msgstr "停止" + +msgid "Play test" +msgstr "テストを再生" + +msgid "Use my current queue" +msgstr "現在のキューを使用" + +msgid "All set" +msgstr "準備完了" + +msgid "Config files you can create:" +msgstr "作成できる設定ファイル:" + +msgid "Back" +msgstr "戻る" + +msgid "Start" +msgstr "開始" + +msgid "Finish" +msgstr "完了" + +msgid "Next" +msgstr "次へ" + +msgid "Play now" +msgstr "今すぐ再生" + +msgid "Start playing now?" +msgstr "今すぐ再生を開始しますか?" + +msgid "Not now" +msgstr "今はしない" + +msgid "Show location" +msgstr "場所を表示" + +msgid "Remove from Local Library" +msgstr "ローカルライブラリから削除" + +msgid "Forget download record" +msgstr "ダウンロード記録を消去" + +msgid "Could not open that folder." +msgstr "このフォルダーを開けませんでした。" + +msgid "Could not remove that library folder." +msgstr "このフォルダーをライブラリから削除できませんでした。" + +msgid "Could not forget that download." +msgstr "このダウンロード記録を消去できませんでした。" + +msgid "Create radio" +msgstr "ラジオを作成" + +msgid "Add to library" +msgstr "ライブラリに追加" + +msgid "Remove from playlist" +msgstr "プレイリストから削除" + +msgid "Share Song.link" +msgstr "Song.linkを共有" + +msgid "Remove offline copy" +msgstr "オフラインコピーを削除" + +msgid "Go to album" +msgstr "アルバムへ移動" + +msgid "Go to artist" +msgstr "アーティストへ移動" + +msgid "Track info" +msgstr "トラック情報" + +msgid "selected" +msgstr "選択中" + +msgid "Artist is blacklisted" +msgstr "アーティストはブラックリストに登録されています" + +msgid "Main Artist" +msgstr "メインアーティスト" + +msgid "Performer" +msgstr "演奏者" + +msgid "Composer" +msgstr "作曲者" + +msgid "Release Name" +msgstr "リリース名" + +msgid "Most popular" +msgstr "人気順" + +msgid "No results." +msgstr "結果がありません。" + +msgid "No results for" +msgstr "次の結果がありません" + +msgid "Top result" +msgstr "トップ結果" + +msgid "View more" +msgstr "もっと見る" + +msgid "QOBUZ CONNECT" +msgstr "QOBUZ CONNECT" + +msgid "Connect diagnostics" +msgstr "Connect診断" + +msgid "Open diagnostics" +msgstr "診断を開く" + +msgid "PLAYBACK" +msgstr "再生" + +msgid "Continue playback after track ends" +msgstr "トラック終了後も再生を続ける" + +msgid "Keep playing the rest of the album or playlist instead of stopping." +msgstr "停止せずにアルバムまたはプレイリストの残りを再生し続けます。" + +msgid "Show track playing context" +msgstr "トラックの再生コンテキストを表示" + +msgid "Display the context-stack icon in the player." +msgstr "プレーヤーにコンテキストスタックアイコンを表示します。" + +msgid "Gapless playback" +msgstr "ギャップレス再生" + +msgid "Play consecutive same-format tracks without a gap." +msgstr "同じフォーマットの連続するトラックをギャップなしで再生します。" + +msgid "SESSION" +msgstr "セッション" + +msgid "Restore session on startup" +msgstr "起動時にセッションを復元" + +msgid "Restore the queue and current track on the next launch." +msgstr "次回起動時にキューと現在のトラックを復元します。" + +msgid "Resume playback position" +msgstr "再生位置を復元" + +msgid "Also seek back to where the saved track left off." +msgstr "保存されたトラックの中断位置にも戻ります。" + +msgid "STREAMING" +msgstr "ストリーミング" + +msgid "Stream uncached tracks" +msgstr "キャッシュされていないトラックをストリーミング" + +msgid "Initial buffer size" +msgstr "初期バッファサイズ" + +msgid "Seconds of audio buffered before streaming playback starts." +msgstr "ストリーミング再生開始前にバッファするオーディオの秒数。" + +msgid "s" +msgstr "秒" + +msgid "Streaming only" +msgstr "ストリーミングのみ" + +msgid "Skip writing tracks to the local cache while streaming." +msgstr "ストリーミング中はトラックをローカルキャッシュに書き込みません。" + +msgid "When quality retries fail" +msgstr "音質の再試行が失敗したとき" + +msgid "What to do when every quality tier for a track is unavailable." +msgstr "トラックのすべての音質ティアが利用できないときの動作。" + +msgid "Folder settings" +msgstr "フォルダ設定" + +msgid "Folder settings saved" +msgstr "フォルダー設定を保存しました" + +msgid "Could not save the folder settings." +msgstr "フォルダー設定を保存できませんでした。" + +msgid "Folder location" +msgstr "フォルダの場所" + +msgid "Change" +msgstr "変更" + +msgid "Checking..." +msgstr "確認中..." + +msgid "Folder is accessible" +msgstr "フォルダにアクセスできます" + +msgid "Folder is not accessible" +msgstr "フォルダにアクセスできません" + +msgid "Display name" +msgstr "表示名" + +msgid "Optional friendly name" +msgstr "任意の表示名" + +msgid "Shown instead of the full path in the folder list." +msgstr "フォルダリストでフルパスの代わりに表示されます。" + +msgid "Enabled" +msgstr "有効" + +msgid "Disabled folders are skipped during scans." +msgstr "無効なフォルダはスキャン中にスキップされます。" + +msgid "Network folder" +msgstr "ネットワークフォルダ" + +msgid "Mark this folder as a network share (excluded when offline)." +msgstr "このフォルダをネットワーク共有としてマーク(オフライン時は除外)。" + +msgid "Network filesystem type" +msgstr "ネットワークファイルシステムの種類" + +msgid "Auto-detect" +msgstr "自動検出" + +msgid "Last scanned" +msgstr "最終スキャン" + +msgid "Scan this folder" +msgstr "このフォルダをスキャン" + +msgid "FOLDER" +msgstr "フォルダ" + +msgid "LAST SCAN" +msgstr "最終スキャン" + +msgid "STATUS" +msgstr "ステータス" + +msgid "Unavailable" +msgstr "利用不可" + +msgid "Active" +msgstr "有効" + +msgid "LIBRARY FOLDERS" +msgstr "ライブラリフォルダ" + +msgid "Browse..." +msgstr "参照..." + +msgid "Choose a music folder" +msgstr "音楽フォルダーを選択" + +msgid "Browse for a music folder, or type the full path." +msgstr "音楽フォルダーを参照するか、フルパスを入力してください。" + +msgid "Filter folders..." +msgstr "フォルダを絞り込み..." + +msgid "No folders yet. Add a folder to build your local library." +msgstr "フォルダがまだありません。フォルダを追加してローカルライブラリを作成しましょう。" + +msgid "No folders match your filter." +msgstr "条件に一致するフォルダがありません。" + +msgid "Scanning" +msgstr "スキャン中" + +msgid "MAINTENANCE" +msgstr "メンテナンス" + +msgid "Cleanup missing files" +msgstr "見つからないファイルを整理" + +msgid "Remove tracks whose files no longer exist on disk." +msgstr "ディスク上にファイルが存在しないトラックを削除します。" + +msgid "Cleaning up..." +msgstr "整理中..." + +msgid "Cleanup" +msgstr "整理" + +msgid "DANGER ZONE" +msgstr "危険な操作" + +msgid "Clear library database" +msgstr "ライブラリデータベースを消去" + +msgid "Remove all indexed tracks. Your audio files are not deleted." +msgstr "インデックス済みのトラックをすべて削除します。オーディオファイルは削除されません。" + +msgid "Clearing..." +msgstr "消去中..." + +msgid "Clear all" +msgstr "すべて消去" + +msgid "PLEX" +msgstr "Plex" + +msgid "Connect a Plex Media Server on your local network." +msgstr "ローカルネットワーク上のPlex Media Serverに接続します。" + +msgid "Server address" +msgstr "サーバーアドレス" + +msgid "Only local network servers are supported." +msgstr "ローカルネットワークのサーバーのみ対応しています。" + +msgid "Enter a valid server address." +msgstr "有効なサーバーアドレスを入力してください。" + +msgid "http://127.0.0.1:32400" +msgstr "http://127.0.0.1:32400" + +msgid "Authorize" +msgstr "認証" + +msgid "Generate a code and sign in to Plex in your browser." +msgstr "コードを生成し、ブラウザでPlexにログインします。" + +msgid "Working..." +msgstr "処理中..." + +msgid "Generate code" +msgstr "コードを生成" + +msgid "Link code" +msgstr "リンクコード" + +msgid "Enter this code at the Plex sign-in page." +msgstr "このコードをPlexのログインページで入力してください。" + +msgid "Copy code" +msgstr "コードをコピー" + +msgid "Open Plex sign-in" +msgstr "Plexのログインを開く" + +msgid "Connected to Plex" +msgstr "Plex に接続しました" + +msgid "The code expired. Generate a new one." +msgstr "コードの有効期限が切れました。新しいコードを生成してください。" + +msgid "Ping the saved server and report what answers." +msgstr "保存済みのサーバーに ping を送り、応答を表示します。" + +msgid "Connected to {}" +msgstr "{} に接続しました" + +msgid "Could not reach the Plex server." +msgstr "Plex サーバーに接続できませんでした。" + +msgid "Plex is not configured." +msgstr "Plex が設定されていません。" + +msgid "Enter token manually" +msgstr "トークンを手動で入力" + +msgid "Use an existing X-Plex-Token instead of the PIN flow." +msgstr "PINフローの代わりに既存のX-Plex-Tokenを使用します。" + +msgid "Token" +msgstr "トークン" + +msgid "X-Plex-Token" +msgstr "X-Plex-Token" + +msgid "Check connection" +msgstr "接続を確認" + +msgid "Verify the server is reachable." +msgstr "サーバーに到達できるか確認します。" + +msgid "Libraries" +msgstr "ライブラリ" + +msgid "Fetch your Plex music libraries." +msgstr "Plexの音楽ライブラリを取得します。" + +msgid "Get libraries" +msgstr "ライブラリを取得" + +msgid "MUSIC LIBRARIES" +msgstr "音楽ライブラリ" + +msgid "({} tracks)" +msgstr "({}トラック)" + +msgid "Write metadata to Plex (experimental)" +msgstr "Plexにメタデータを書き込む(実験的)" + +msgid "Allow QBZ to write track metadata back to your Plex server." +msgstr "QBZがトラックのメタデータをPlexサーバーに書き戻すことを許可します。" + +msgid "PLEX DANGER ZONE" +msgstr "Plexの危険な操作" + +msgid "Disconnect" +msgstr "接続を解除" + +msgid "Sign out of Plex and clear the local cache." +msgstr "Plexからログアウトし、ローカルキャッシュを消去します。" + +msgid "Clear cache" +msgstr "キャッシュを消去" + +msgid "Remove cached Plex libraries and tracks. Your sign-in is kept." +msgstr "キャッシュされたPlexのライブラリとトラックを削除します。ログイン状態は保持されます。" + +msgid "OFFLINE MODE" +msgstr "オフラインモード" + +msgid "Status" +msgstr "ステータス" + +msgid "Captive portal detected — sign in to the network to get online." +msgstr "キャプティブポータルを検出しました — ネットワークにログインしてオンラインにしてください。" + +msgid "Offline mode enabled" +msgstr "オフラインモードが有効です" + +msgid "No connection" +msgstr "接続なし" + +msgid "Online" +msgstr "オンライン" + +msgid "Check now" +msgstr "今すぐ確認" + +msgid "Enable Offline Mode" +msgstr "オフラインモードを有効にする" + +msgid "Test the connection now instead of waiting for the next automatic check." +msgstr "次の自動確認を待たずに、今すぐ接続をテストします。" + +msgid "Include sign-in credentials" +msgstr "サインイン資格情報を含める" + +msgid "The bundle will contain your tokens. Anyone who opens the file can sign in as you." +msgstr "バンドルにトークンが含まれます。ファイルを開いた人は誰でもあなたとしてサインインできます。" + +msgid "Manually switch to offline mode even with internet." +msgstr "インターネット接続があっても手動でオフラインモードに切り替えます。" + +msgid "OFFLINE CACHE" +msgstr "オフラインキャッシュ" + +msgid "Manage offline cache" +msgstr "オフラインキャッシュを管理" + +msgid "Browse and manage your downloaded tracks and albums." +msgstr "ダウンロードしたトラックとアルバムを閲覧・管理します。" + +msgid "Open manager" +msgstr "マネージャーを開く" + +msgid "Cache folder" +msgstr "キャッシュフォルダ" + +msgid "Open the folder where offline tracks are stored on disk." +msgstr "オフライントラックがディスクに保存されているフォルダを開きます。" + +msgid "Open folder" +msgstr "フォルダを開く" + +msgid "Clear offline cache" +msgstr "オフラインキャッシュを消去" + +msgid "Remove every downloaded track. This cannot be undone." +msgstr "ダウンロードしたトラックをすべて削除します。この操作は取り消せません。" + +msgid "LYRICS" +msgstr "歌詞" + +msgid "Clear lyrics cache" +msgstr "歌詞キャッシュを消去" + +msgid "{} entries using {}" +msgstr "{}件のエントリ、{}を使用" + +msgid "Manage" +msgstr "管理" + +msgid "Artist Blacklist" +msgstr "アーティストのブラックリスト" + +msgid "{} artist(s) blocked" +msgstr "{}人のアーティストをブロック中" + +msgid "{} artist(s) blocked (disabled)" +msgstr "{}人のアーティストをブロック中(無効)" + +msgid "Settings" +msgstr "設定" + +msgid "Audio" +msgstr "オーディオ" + +msgid "Playback" +msgstr "再生" + +msgid "Appearance" +msgstr "外観" + +msgid "Offline" +msgstr "オフライン" + +msgid "Blacklist" +msgstr "ブラックリスト" + +msgid "Integrations" +msgstr "連携" + +msgid "Developer" +msgstr "開発者" + +msgid "Loading settings..." +msgstr "設定を読み込み中..." + +msgid "Added {}" +msgstr "{}を追加しました" + +msgid "Clear All" +msgstr "すべて消去" + +msgid "{} artists" +msgstr "{}人のアーティスト" + +msgid "Loading blacklist…" +msgstr "ブラックリストを読み込み中…" + +msgid "No blacklisted artists" +msgstr "ブラックリストに登録されたアーティストはありません" + +msgid "To hide an artist, go to their page and use the menu." +msgstr "アーティストを非表示にするには、そのページでメニューを使用してください。" + +msgid "No results for \"{}\"" +msgstr "「{}」の結果がありません" + +msgid "Clear Blacklist?" +msgstr "ブラックリストを消去しますか?" + +msgid "This will remove all {} blacklisted artists. This cannot be undone." +msgstr "ブラックリストの{}人のアーティストをすべて削除します。この操作は取り消せません。" + +msgid "SCROBBLERS" +msgstr "スクロブラー" + +msgid "LAST.FM" +msgstr "Last.fm" + +msgid "Scrobble to Last.fm" +msgstr "Last.fmにスクロブル" + +msgid "Signed in as {}." +msgstr "{}としてログイン中。" + +msgid "Connect your Last.fm account to enable scrobbling." +msgstr "Last.fmアカウントを接続してスクロブルを有効にします。" + +msgid "Connect" +msgstr "接続" + +msgid "Authorize QBZ in your browser, then click Finish." +msgstr "ブラウザでQBZを認証し、「完了」をクリックしてください。" + +msgid "Connect Last.fm" +msgstr "Last.fmを接続" + +msgid "Open authorize page" +msgstr "認証ページを開く" + +msgid "Disconnect Last.fm" +msgstr "Last.fmの接続を解除" + +msgid "Sign out of Last.fm." +msgstr "Last.fmからログアウトします。" + +msgid "LISTENBRAINZ" +msgstr "ListenBrainz" + +msgid "Scrobble to ListenBrainz" +msgstr "ListenBrainzにスクロブル" + +msgid "Paste your ListenBrainz user token to enable scrobbling." +msgstr "ListenBrainzのユーザートークンを貼り付けてスクロブルを有効にします。" + +msgid "User token" +msgstr "ユーザートークン" + +msgid "From listenbrainz.org/settings." +msgstr "listenbrainz.org/settingsから取得できます。" + +msgid "ListenBrainz token" +msgstr "ListenBrainzトークン" + +msgid "Save token" +msgstr "トークンを保存" + +msgid "Validates the token against ListenBrainz." +msgstr "ListenBrainzに対してトークンを検証します。" + +msgid "Validating..." +msgstr "検証中..." + +msgid "Connect ListenBrainz" +msgstr "ListenBrainzを接続" + +msgid "Disconnect ListenBrainz" +msgstr "ListenBrainzの接続を解除" + +msgid "Sign out of ListenBrainz." +msgstr "ListenBrainzからログアウトします。" + +msgid "DISCORD" +msgstr "Discord" + +msgid "Discord Rich Presence" +msgstr "Discordリッチプレゼンス" + +msgid "Flatpak socket access" +msgstr "Flatpakソケットアクセス" + +msgid "Streaming quality" +msgstr "ストリーミング音質" + +msgid "The quality tier QBZ requests for playback." +msgstr "QBZが再生時にリクエストする音質ティアです。" + +msgid "Limit quality to device" +msgstr "音質をデバイスに合わせて制限" + +msgid "Cap streaming quality at the output device's maximum sample rate." +msgstr "ストリーミング音質を出力デバイスの最大サンプルレートに制限します。" + +msgid "Maximum sample rate" +msgstr "最大サンプルレート" + +msgid "The highest sample rate streamed tracks may use." +msgstr "ストリーミングトラックが使用できる最大のサンプルレートです。" + +msgid "OUTPUT" +msgstr "出力" + +msgid "Audio backend" +msgstr "オーディオバックエンド" + +msgid "The audio stack QBZ routes playback through." +msgstr "QBZが再生をルーティングするオーディオスタックです。" + +msgid "Output device" +msgstr "出力デバイス" + +msgid "The DAC or sound device that receives audio." +msgstr "オーディオを受け取るDACまたはサウンドデバイスです。" + +msgid "ALSA plugin" +msgstr "ALSAプラグイン" + +msgid "How ALSA opens the device — hw is bit-perfect, plughw converts." +msgstr "ALSAがデバイスを開く方法 — hwはビットパーフェクト、plughwは変換します。" + +msgid "Hardware volume control" +msgstr "ハードウェア音量コントロール" + +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "選択したALSAデバイスが提供するミキサーで音量を調整します。利用できない場合、ダイレクト再生の音量は100%に固定されます。" + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "このALSAデバイスには互換性のあるハードウェア音量コントロールがありません。ダイレクト再生の音量は100%に固定されます。" + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZは以前のオーディオデバイスを完全に解放できませんでした。詳細はオーディオログを確認してください。" + +msgid "BIT-PERFECT" +msgstr "ビットパーフェクト" + +msgid "Exclusive mode" +msgstr "排他モード" + +msgid "Lock the device so no other app can resample it." +msgstr "他のアプリがリサンプリングできないようにデバイスをロックします。" + +msgid "Reserve DAC while running" +msgstr "実行中はDACを予約" + +msgid "Hold the device reserved so other apps can't grab it." +msgstr "他のアプリが取得できないようにデバイスを予約しておきます。" + +msgid "DAC passthrough" +msgstr "DACパススルー" + +msgid "Send the bitstream untouched to the DAC." +msgstr "ビットストリームをそのままDACに送ります。" + +msgid "Force bit-perfect" +msgstr "ビットパーフェクトを強制" + +msgid "Pin the PipeWire quantum and rate for the active track." +msgstr "再生中のトラックに対してPipeWireのクォンタムとレートを固定します。" + +msgid "Allow quality fallback" +msgstr "音質フォールバックを許可" + +msgid "Drop to a lower tier when the requested one is unavailable." +msgstr "リクエストしたティアが利用できない場合は低いティアに下げます。" + +msgid "HiFi Wizard" +msgstr "HiFiウィザード" + +msgid "Auto-detect your DACs and set up bit-perfect playback, step by step." +msgstr "DACを自動検出し、ビットパーフェクト再生をステップごとに設定します。" + +msgid "Open Wizard" +msgstr "ウィザードを開く" + +msgid "STARTUP" +msgstr "起動" + +msgid "Sync audio settings on startup" +msgstr "起動時にオーディオ設定を同期" + +msgid "Reload saved audio settings into the player when QBZ launches." +msgstr "QBZの起動時に保存済みのオーディオ設定をプレーヤーに再読み込みします。" + +msgid "Lock output device" +msgstr "出力デバイスをロック" + +msgid "Keep external routing intact — skip switching the default sink." +msgstr "外部のルーティングをそのまま維持 — デフォルトのシンクの切り替えをスキップします。" + +msgid "THEME" +msgstr "テーマ" + +msgid "Theme" +msgstr "テーマ" + +msgid "Album header gradient" +msgstr "アルバムヘッダーのグラデーション" + +msgid "Intelligent Search" +msgstr "インテリジェント検索" + +msgid "Generate a color theme from your system wallpaper or a custom image" +msgstr "システムの壁紙またはカスタム画像からカラーテーマを生成します" + +msgid "Select Image..." +msgstr "画像を選択..." + +msgid "Detected: " +msgstr "検出: " + +msgid "Experimental: theme may not match your system exactly." +msgstr "実験的:テーマがシステムと完全に一致しない場合があります。" + +msgid "Regenerate" +msgstr "再生成" + +msgid "TYPOGRAPHY & LANGUAGE" +msgstr "タイポグラフィと言語" + +msgid "Language" +msgstr "言語" + +msgid "UI Scale" +msgstr "UIスケール" + +msgid "Immersive search" +msgstr "没入型検索" + +msgid "Immersive default view" +msgstr "没入型のデフォルト表示" + +msgid "LIBRARY & VISUALS" +msgstr "ライブラリとビジュアル" + +msgid "Show navigation in sidebar" +msgstr "サイドバーにナビゲーションを表示" + +msgid "My QBZ" +msgstr "マイQBZ" + +msgid "Show playlist cover collages in sidebar" +msgstr "サイドバーにプレイリストのカバーコラージュを表示" + +msgid "Show album artwork in Local Library track list" +msgstr "ローカルライブラリのトラックリストにアルバムアートワークを表示" + +msgid "NOTIFICATIONS" +msgstr "通知" + +msgid "In-app toasts notifications" +msgstr "アプリ内トースト通知" + +msgid "System Notifications" +msgstr "システム通知" + +msgid "WINDOW TITLE" +msgstr "ウィンドウタイトル" + +msgid "Show track in window title" +msgstr "ウィンドウタイトルにトラックを表示" + +msgid "Title template" +msgstr "タイトルテンプレート" + +msgid "Placeholders: {{artist}}, {{track}}, {{album}}" +msgstr "プレースホルダー:{{artist}}、{{track}}、{{album}}" + +msgid "TITLE BAR" +msgstr "タイトルバー" + +msgid "Use system title bar" +msgstr "システムのタイトルバーを使用" + +msgid "Hide title bar" +msgstr "タイトルバーを非表示" + +msgid "Hide the title bar completely (for tiling window manager users)" +msgstr "タイトルバーを完全に非表示にします(タイル型ウィンドウマネージャー利用者向け)" + +msgid "WINDOW CONTROLS" +msgstr "ウィンドウコントロール" + +msgid "Window controls position" +msgstr "ウィンドウコントロールの位置" + +msgid "Show window controls" +msgstr "ウィンドウコントロールを表示" + +msgid "PLAYER VIEWS" +msgstr "プレーヤー表示" + +msgid "Show volume +/- buttons" +msgstr "音量+/-ボタンを表示" + +msgid "Mini player default view" +msgstr "ミニプレーヤーのデフォルト表示" + +msgid "Startup page" +msgstr "起動ページ" + +msgid "Choose which page to show when the app starts" +msgstr "アプリ起動時に表示するページを選択します" + +msgid "Show Purchases" +msgstr "購入済みを表示" + +msgid "MENU BAR" +msgstr "メニューバー" + +msgid "SYSTEM TRAY" +msgstr "システムトレイ" + +msgid "Enable tray icon" +msgstr "トレイアイコンを有効にする" + +msgid "Show icon in system tray (requires restart)" +msgstr "システムトレイにアイコンを表示(再起動が必要)" + +msgid "Close to tray" +msgstr "閉じてトレイに格納" + +msgid "Keep playing in the tray instead of quitting when you close the window" +msgstr "ウィンドウを閉じても終了せず、トレイで再生を続けます" + +msgid "Hide Dock icon when closed to menu bar" +msgstr "メニューバーに格納したときにDockアイコンを非表示" + +msgid "Tray icon variant" +msgstr "トレイアイコンの種類" + +msgid "IMMERSIVE" +msgstr "没入モード" + +msgid "Background effect" +msgstr "背景エフェクト" + +msgid "Immersive Panels" +msgstr "没入型パネル" + +msgid "Enable or disable individual immersive view panels" +msgstr "個々の没入型ビューのパネルを有効または無効にします" + +msgid "Animation Frame Rate" +msgstr "アニメーションフレームレート" + +msgid "Per-animation FPS control for immersive mode" +msgstr "没入モードのアニメーションごとのFPSコントロール" + +msgid "PLAYLISTS" +msgstr "プレイリスト" + +msgid "No playlists yet." +msgstr "プレイリストがまだありません。" + +msgid "Manage playlists" +msgstr "プレイリストを管理" + +msgid "Remove from queue" +msgstr "キューから削除" + +msgid "Stop after this" +msgstr "この曲の後に停止" + +msgid "Remove all after" +msgstr "以降をすべて削除" + +msgid "History" +msgstr "履歴" + +msgid "NOW PLAYING" +msgstr "再生中" + +msgid "UP NEXT ({} of {}, {} left)" +msgstr "次の曲({}/{}、残り{})" + +msgid "Page {} of {}" +msgstr "{}/{}ページ" + +msgid "Your queue is empty" +msgstr "キューは空です" + +msgid "Play an album or track to get started" +msgstr "アルバムやトラックを再生して始めましょう" + +msgid "No tracks match your search" +msgstr "検索に一致するトラックがありません" + +msgid "RECENTLY PLAYED" +msgstr "最近再生した項目" + +msgid "Nothing played yet" +msgstr "まだ再生履歴がありません" + +msgid "Search queue" +msgstr "キューを検索" + +msgid "Auto-follow" +msgstr "自動追従" + +msgid "Font" +msgstr "フォント" + +msgid "System" +msgstr "システム" + +msgid "LINE Seed JP" +msgstr "LINE Seed JP" + +msgid "Montserrat" +msgstr "Montserrat" + +msgid "Noto Sans" +msgstr "Noto Sans" + +msgid "Source Sans 3" +msgstr "Source Sans 3" + +msgid "Size" +msgstr "サイズ" + +msgid "S" +msgstr "S" + +msgid "M" +msgstr "M" + +msgid "L" +msgstr "L" + +msgid "XL" +msgstr "XL" + +msgid "Active color" +msgstr "アクティブカラー" + +msgid "Uppercase" +msgstr "大文字" + +msgid "Dimming" +msgstr "減光" + +msgid "Soft" +msgstr "弱" + +msgid "Strong" +msgstr "強" + +msgid "Copy lyrics" +msgstr "歌詞をコピー" + +msgid "Reset" +msgstr "リセット" + +msgid "Previous" +msgstr "前へ" + +msgid "Pause" +msgstr "一時停止" + +msgid "Repeat" +msgstr "リピート" + +msgid "Add to…" +msgstr "追加先…" + +msgid "Add album to collection" +msgstr "アルバムをコレクションに追加" + +msgid "Remove album from collection" +msgstr "アルバムをコレクションから削除" + +msgid "Remove from library" +msgstr "ライブラリから削除" + +msgid "{} (not playable)" +msgstr "{}(再生不可)" + +msgid "Cast" +msgstr "キャスト" + +msgid "Connected to" +msgstr "接続先" + +msgid "Streaming {} · {}" +msgstr "ストリーミング中 {} · {}" + +msgid "No devices found" +msgstr "デバイスが見つかりません" + +msgid "Looking for Chromecast & DLNA on your network" +msgstr "ネットワーク上のChromecastとDLNAを検索中" + +msgid "Display settings" +msgstr "表示設定" + +msgid "Fetching lyrics..." +msgstr "歌詞を取得中..." + +msgid "No lyrics available" +msgstr "利用できる歌詞がありません" + +msgid "Missing lyrics? You can contribute them to LRCLIB:" +msgstr "歌詞がありませんか?LRCLIBに投稿できます:" + +msgid "Lyrics unavailable offline" +msgstr "歌詞はオフラインでは利用できません" + +msgid "Lyrics source" +msgstr "歌詞のソース" + +msgid "Qobuz Connect" +msgstr "Qobuz Connect" + +msgid "Connected" +msgstr "接続済み" + +msgid "Disconnected" +msgstr "切断済み" + +msgid "Playing on: {}" +msgstr "再生先:{}" + +msgid "Looking for devices…" +msgstr "デバイスを検索中…" + +msgid "Make sure your renderer is on the same network" +msgstr "レンダラーが同じネットワーク上にあることを確認してください" + +msgid "Play here" +msgstr "ここで再生" + +msgid "Audio settings" +msgstr "オーディオ設定" + +msgid "Normalization" +msgstr "ノーマライズ" + +msgid "Level loudness across tracks" +msgstr "トラック間の音量を均一化" + +msgid "Gapless" +msgstr "ギャップレス" + +msgid "Seamless track transitions" +msgstr "シームレスなトラック切り替え" + +msgid "Now Playing view" +msgstr "再生中ビュー" + +msgid "New" +msgstr "新規" + +msgid "Classic" +msgstr "クラシック" + +msgid "Miniplayer" +msgstr "ミニプレーヤー" + +msgid "Immersive" +msgstr "没入" + +msgid "Unmute" +msgstr "ミュート解除" + +msgid "Mute" +msgstr "ミュート" + +msgid "Volume" +msgstr "音量" + +msgid "Qobuz Connect: On" +msgstr "Qobuz Connect:オン" + +msgid "Qobuz Connect: Off" +msgstr "Qobuz Connect:オフ" + +msgid "Volume down" +msgstr "音量を下げる" + +msgid "Volume up" +msgstr "音量を上げる" + +msgid "Nothing playing" +msgstr "再生していません" + +msgid "Go to where this is playing from" +msgstr "再生元へ移動" + +msgid "Casting" +msgstr "キャスト中" + +msgid "Output mode" +msgstr "出力モード" + +msgid "Move to root" +msgstr "ルートへ移動" + +msgid "No folders yet." +msgstr "フォルダがまだありません。" + +msgid "Discover" +msgstr "見つける" + +msgid "Mixtapes" +msgstr "ミックステープ" + +msgid "Collections" +msgstr "コレクション" + +msgid "Search playlists" +msgstr "プレイリストを検索" + +msgid "Sort by" +msgstr "並べ替え" + +msgid "Name (A-Z)" +msgstr "名前(A-Z)" + +msgid "# of tracks" +msgstr "トラック数" + +msgid "Refresh" +msgstr "更新" + +msgid "Logged out" +msgstr "ログアウト済み" + +msgid "Offline (manual)" +msgstr "オフライン(手動)" + +msgid "Offline (hard)" +msgstr "オフライン(強制)" + +msgid "You're signed out. Sign-in needs a connection." +msgstr "ログアウトしています。ログインには接続が必要です。" + +msgid "Connection available — sign back in to Qobuz." +msgstr "接続が利用可能です — Qobuzに再ログインしてください。" + +msgid "You're signed out — sign back in to Qobuz." +msgstr "ログアウトしています — Qobuzに再ログインしてください。" + +msgid "Sign in" +msgstr "ログイン" + +msgid "Offline mode" +msgstr "オフラインモード" + +msgid "Enter" +msgstr "入る" + +msgid "Guest" +msgstr "ゲスト" + +msgid "Not signed in" +msgstr "未ログイン" + +msgid "Keyboard Shortcuts" +msgstr "キーボードショートカット" + +msgid "Documentation" +msgstr "ドキュメント" + +msgid "Report an Issue" +msgstr "問題を報告" + +msgid "Log Out" +msgstr "ログアウト" + +msgid "Create Mixtape" +msgstr "ミックステープを作成" + +msgid "Create Collection" +msgstr "コレクションを作成" + +msgid "NAME" +msgstr "名前" + +msgid "KIND" +msgstr "種類" + +msgid "+ New Mixtape" +msgstr "+ 新規ミックステープ" + +msgid "+ New Collection" +msgstr "+ 新規コレクション" + +msgid "Rename" +msgstr "名前を変更" + +msgid "Edit description" +msgstr "説明を編集" + +msgid "Delete \"{}\"? This cannot be undone." +msgstr "「{}」を削除しますか?この操作は取り消せません。" + +msgid "All ({})" +msgstr "すべて({})" + +msgid "Random queue" +msgstr "ランダムキュー" + +msgid "Number of songs" +msgstr "曲数" + +msgid "{} songs available" +msgstr "{}曲が利用可能" + +msgid "No mixtapes yet" +msgstr "ミックステープがまだありません" + +msgid "Position" +msgstr "位置" + +msgid "Items" +msgstr "項目" + +msgid "Recently updated" +msgstr "最近更新" + +msgid "No results found" +msgstr "結果が見つかりません" + +msgid "No collections yet" +msgstr "コレクションがまだありません" + +msgid "All kinds" +msgstr "すべての種類" + +msgid "Artist Collections" +msgstr "アーティストコレクション" + +msgid "Remove from collection" +msgstr "コレクションから削除" + +msgid "Not found" +msgstr "見つかりません" + +msgid "Upload cover" +msgstr "カバーをアップロード" + +msgid "Clear custom cover" +msgstr "カスタムカバーをクリア" + +msgid "Album shuffle" +msgstr "アルバムシャッフル" + +msgid "In order" +msgstr "順番どおり" + +msgid "Convert to Collection" +msgstr "コレクションに変換" + +msgid "Convert to Mixtape" +msgstr "ミックステープに変換" + +msgid "1 filter" +msgstr "フィルター1件" + +msgid "{} filters" +msgstr "{}件のフィルター" + +msgid "Type" +msgstr "種類" + +msgid "All types" +msgstr "すべての種類" + +msgid "Track" +msgstr "トラック" + +msgid "Qobuz" +msgstr "Qobuz" + +msgid "Plex" +msgstr "Plex" + +msgid "Item" +msgstr "項目" + +msgid "Already added" +msgstr "追加済み" + +msgid "ADD TO MIXTAPE/COLLECTION" +msgstr "ミックステープ/コレクションに追加" + +msgid "No matches." +msgstr "一致するものがありません。" + +msgid "No mixtapes or collections yet." +msgstr "ミックステープもコレクションもまだありません。" + +msgid "EP" +msgstr "EP" + +msgid "Single" +msgstr "シングル" + +msgid "Live" +msgstr "ライブ" + +msgid "Compilation" +msgstr "コンピレーション" + +msgid "Use auto-detected" +msgstr "自動検出を使用" + +msgid "alternate" +msgstr "別バージョン" + +msgid "compilation" +msgstr "コンピレーション" + +msgid "BUILD ARTIST COLLECTION" +msgstr "アーティストコレクションを作成" + +msgid "ORDER BY" +msgstr "並べ替え基準" + +msgid "Release date" +msgstr "リリース日" + +msgid "Manual" +msgstr "手動" + +msgid "Loading..." +msgstr "読み込み中..." + +msgid "ALBUM" +msgstr "アルバム" + +msgid "{} of {} album selected" +msgid_plural "{} of {} albums selected" +msgstr[0] "{}/{}アルバムを選択中" + +msgid "Now Playing" +msgstr "再生中" + +msgid "No lyrics" +msgstr "歌詞なし" + +msgid "No track selected" +msgstr "トラックが選択されていません" + +msgid "Loading suggestions..." +msgstr "おすすめを読み込み中..." + +msgid "Failed to load suggestions" +msgstr "おすすめの読み込みに失敗しました" + +msgid "Recommended tracks" +msgstr "おすすめのトラック" + +msgid "Exit Immersive" +msgstr "没入を終了" + +msgid "FOCUS" +msgstr "フォーカス" + +msgid "Plasma" +msgstr "プラズマ" + +msgid "Aurora" +msgstr "オーロラ" + +msgid "SPLIT" +msgstr "分割" + +msgid "Track Info" +msgstr "トラック情報" + +msgid "Suggestions" +msgstr "おすすめ" + +msgid "UP NEXT" +msgstr "次に再生" + +msgid "Tracks you play will appear here" +msgstr "再生したトラックがここに表示されます" + +msgid "No track playing" +msgstr "再生中のトラックがありません" + +msgid "Queue is empty" +msgstr "キューが空です" + +msgid "Search albums & tracks…" +msgstr "アルバムとトラックを検索…" + +msgid "Acclaimed Releases" +msgstr "高評価のリリース" + +msgid "No award-winning releases yet." +msgstr "受賞リリースがまだありません。" + +msgid "Award" +msgstr "アワード" + +msgid "Unfollow award" +msgstr "アワードのフォローを解除" + +msgid "Follow award" +msgstr "アワードをフォロー" + +msgid "See All" +msgstr "すべて表示" + +msgid "Other awards" +msgstr "その他のアワード" + +msgid "{} item needs attention before bit-perfect playback will work." +msgid_plural "{} items need attention before bit-perfect playback will work." +msgstr[0] "ビットパーフェクト再生を有効にするには{}項目の対応が必要です。" + +msgid "Removed {} artist from blacklist" +msgid_plural "Removed {} artists from blacklist" +msgstr[0] "ブラックリストから{}人のアーティストを削除しました" + +msgid "Removed {} folder" +msgid_plural "Removed {} folders" +msgstr[0] "{}フォルダを削除しました" + +msgid "Scan complete ({} file skipped)" +msgid_plural "Scan complete ({} files skipped)" +msgstr[0] "スキャン完了({}ファイルをスキップ)" + +msgid "{} album" +msgid_plural "{} albums" +msgstr[0] "{}アルバム" + +msgid "{} item" +msgid_plural "{} items" +msgstr[0] "{}項目" + +msgid "{} duplicate skipped" +msgid_plural "{} duplicates skipped" +msgstr[0] "{}件の重複をスキップ" + +msgid "Removed {} item" +msgid_plural "Removed {} items" +msgstr[0] "{}項目を削除しました" + +msgid "Caching {} track offline…" +msgid_plural "Caching {} tracks offline…" +msgstr[0] "{}トラックをオフラインにキャッシュ中…" + +msgid "(Re)start the PipeWire audio services" +msgstr "PipeWireオーディオサービスを(再)起動" + +msgid "({} local)" +msgstr "({}ローカル)" + +msgid "176.4 kHz" +msgstr "176.4 kHz" + +msgid "192 kHz" +msgstr "192 kHz" + +msgid "352.8 kHz" +msgstr "352.8 kHz" + +msgid "384 kHz" +msgstr "384 kHz" + +msgid "44.1 kHz" +msgstr "44.1 kHz" + +msgid "48 kHz" +msgstr "48 kHz" + +msgid "88.2 kHz" +msgstr "88.2 kHz" + +msgid "96 kHz" +msgstr "96 kHz" + +msgid "A fresh mix every week." +msgstr "毎週新しいミックスを。" + +msgid "Add a folder before scanning" +msgstr "スキャン前にフォルダを追加してください" + +msgid "Added to queue" +msgstr "キューに追加しました" + +msgid "Added {} to {}" +msgstr "{}を{}に追加しました" + +msgid "Added {} to {} ({})" +msgstr "{}を{}に追加しました({})" + +msgid "Adding tracks to playlist..." +msgstr "プレイリストにトラックを追加中..." + +msgid "Adding tracks: {} / {}" +msgstr "トラックを追加中:{}/{}" + +msgid "Album folder not found on disk" +msgstr "アルバムフォルダがディスク上に見つかりません" + +msgid "Album metadata saved" +msgstr "アルバムのメタデータを保存しました" + +msgid "Album title is required" +msgstr "アルバムタイトルは必須です" + +msgid "Albums of the Week" +msgstr "今週のアルバム" + +msgid "Albums on Local Library" +msgstr "ローカルライブラリのアルバム" + +msgid "Already in {}" +msgstr "すでに{}にあります" + +msgid "Always skip track" +msgstr "常にトラックをスキップ" + +msgid "Always try lowest quality" +msgstr "常に最低音質を試す" + +msgid "April" +msgstr "4月" + +msgid "Are you absolutely sure?" +msgstr "本当によろしいですか?" + +msgid "Artist Spotlight" +msgstr "アーティストスポットライト" + +msgid "Artists on Local Library" +msgstr "ローカルライブラリのアーティスト" + +msgid "Ask me" +msgstr "確認する" + +msgid "August" +msgstr "8月" + +msgid "Authorize QBZ in your browser, then click \"Finish\"" +msgstr "ブラウザでQBZを認証してから「完了」をクリックしてください" + +msgid "Available tracks only — offline" +msgstr "利用可能なトラックのみ — オフライン" + +msgid "Bit-perfect (Hardware / Digital)" +msgstr "ビットパーフェクト(ハードウェア/デジタル)" + +msgid "Blacklist disabled" +msgstr "ブラックリストを無効にしました" + +msgid "Blacklist enabled" +msgstr "ブラックリストを有効にしました" + +msgid "Booklet open task failed: {}" +msgstr "ブックレットを開くタスクに失敗しました:{}" + +msgid "COLLECTION" +msgstr "コレクション" + +msgid "Cache cleared" +msgstr "キャッシュをクリアしました" + +msgid "Cached for offline" +msgstr "オフライン用にキャッシュ済み" + +msgid "Cannot convert this kind" +msgstr "この種類は変換できません" + +msgid "Checked {} tracks - all OK" +msgstr "{}トラックをチェックしました - すべて問題なし" + +msgid "Checking playlist link..." +msgstr "プレイリストのリンクを確認中..." + +msgid "Checking your audio stack…" +msgstr "オーディオスタックを確認中…" + +msgid "Couldn't load the test tracks (offline?)" +msgstr "テストトラックを読み込めませんでした(オフライン?)" + +msgid "Choose a cover image" +msgstr "カバー画像を選択" + +msgid "Choose a folder to play" +msgstr "再生するフォルダを選択" + +msgid "Choose a {} icon" +msgstr "{}アイコンを選択" + +msgid "Cleaning up missing files..." +msgstr "欠落ファイルを整理中..." + +msgid "Cleanup failed" +msgstr "整理に失敗しました" + +msgid "Clear Plex cache?" +msgstr "Plexキャッシュをクリアしますか?" + +msgid "Clear library database?" +msgstr "ライブラリデータベースをクリアしますか?" + +msgid "Code copied" +msgstr "コードをコピーしました" + +msgid "Compilations" +msgstr "コンピレーション" + +msgid "Connected as {}" +msgstr "{}として接続済み" + +msgid "Connected to {} (v{})" +msgstr "{}に接続済み(v{})" + +msgid "Continue Listening" +msgstr "続けて再生" + +msgid "Converted" +msgstr "変換しました" + +msgid "Couldn't add folder" +msgstr "フォルダを追加できませんでした" + +msgid "Couldn't change folder location (path may already exist)" +msgstr "フォルダの場所を変更できませんでした(パスがすでに存在している可能性があります)" + +msgid "Couldn't clean up missing files" +msgstr "欠落ファイルを整理できませんでした" + +msgid "Couldn't clear the Plex cache" +msgstr "Plexキャッシュをクリアできませんでした" + +msgid "Couldn't clear the library database" +msgstr "ライブラリデータベースをクリアできませんでした" + +msgid "Couldn't clear the offline cache" +msgstr "オフラインキャッシュをクリアできませんでした" + +msgid "Couldn't create the Qobuz playlist" +msgstr "Qobuzプレイリストを作成できませんでした" + +msgid "Couldn't load library folders" +msgstr "ライブラリフォルダを読み込めませんでした" + +msgid "Couldn't load that album" +msgstr "そのアルバムを読み込めませんでした" + +msgid "Couldn't load that playlist" +msgstr "そのプレイリストを読み込めませんでした" + +msgid "Couldn't load that track" +msgstr "そのトラックを読み込めませんでした" + +msgid "Couldn't load this album" +msgstr "このアルバムを読み込めませんでした" + +msgid "Couldn't load this artist" +msgstr "このアーティストを読み込めませんでした" + +msgid "Couldn't load this collection" +msgstr "このコレクションを読み込めませんでした" + +msgid "Couldn't load this playlist" +msgstr "このプレイリストを読み込めませんでした" + +msgid "Couldn't open that folder" +msgstr "そのフォルダを開けませんでした" + +msgid "Couldn't save folder settings" +msgstr "フォルダ設定を保存できませんでした" + +msgid "Couldn't save metadata: {}" +msgstr "メタデータを保存できませんでした:{}" + +msgid "Cover removed" +msgstr "カバーを削除しました" + +msgid "Cover updated" +msgstr "カバーを更新しました" + +msgid "Creating playlist..." +msgstr "プレイリストを作成中..." + +msgid "Critics' Picks" +msgstr "評論家の選曲" + +msgid "DAC: {} · {} · {} ch" +msgstr "DAC:{} · {} · {} ch" + +msgid "December" +msgstr "12月" + +msgid "Defaults" +msgstr "デフォルト" + +msgid "Disconnect from Plex?" +msgstr "Plexから切断しますか?" + +msgid "EPs & Singles" +msgstr "EPとシングル" + +msgid "Enable PipeWire in your NixOS configuration" +msgstr "NixOS構成でPipeWireを有効にしてください" + +msgid "Enter a title or artist to search" +msgstr "検索するタイトルまたはアーティストを入力してください" + +msgid "Enter code {} at the Plex sign-in page" +msgstr "Plexのログインページでコード{}を入力してください" + +msgid "Error: {}" +msgstr "エラー:{}" + +msgid "Error: {} (did you authorize in the browser?)" +msgstr "エラー:{}(ブラウザで認証しましたか?)" + +msgid "Essentials by Genre" +msgstr "ジャンル別の定番" + +msgid "Every track needs a title" +msgstr "すべてのトラックにタイトルが必要です" + +msgid "Failed to cast track" +msgstr "トラックのキャストに失敗しました" + +msgid "Failed to change play mode" +msgstr "再生モードの変更に失敗しました" + +msgid "Failed to clear blacklist" +msgstr "ブラックリストのクリアに失敗しました" + +msgid "Failed to clear lyrics cache" +msgstr "歌詞キャッシュのクリアに失敗しました" + +msgid "Failed to create temp dir: {}" +msgstr "一時ディレクトリの作成に失敗しました:{}" + +msgid "Failed to decode booklet page: {}" +msgstr "ブックレットページのデコードに失敗しました:{}" + +msgid "Failed to delete" +msgstr "削除に失敗しました" + +msgid "Failed to fetch booklet: {}" +msgstr "ブックレットの取得に失敗しました:{}" + +msgid "Failed to fetch metadata" +msgstr "メタデータの取得に失敗しました" + +msgid "Failed to read booklet: {}" +msgstr "ブックレットの読み込みに失敗しました:{}" + +msgid "Failed to remove artist" +msgstr "アーティストの削除に失敗しました" + +msgid "Failed to remove cover" +msgstr "カバーの削除に失敗しました" + +msgid "Failed to remove items" +msgstr "項目の削除に失敗しました" + +msgid "Failed to rename" +msgstr "名前の変更に失敗しました" + +msgid "Failed to save description" +msgstr "説明の保存に失敗しました" + +msgid "Failed to toggle blacklist" +msgstr "ブラックリストの切り替えに失敗しました" + +msgid "Failed to upload cover" +msgstr "カバーのアップロードに失敗しました" + +msgid "Failed to write booklet: {}" +msgstr "ブックレットの書き込みに失敗しました:{}" + +msgid "Library Albums" +msgstr "ライブラリのアルバム" + +msgid "February" +msgstr "2月" + +msgid "File not available — is the drive mounted?" +msgstr "ファイルが利用できません — ドライブはマウントされていますか?" + +msgid "Files not available — is the drive mounted?" +msgstr "ファイルが利用できません — ドライブはマウントされていますか?" + +msgid "Folder opened" +msgstr "フォルダを開きました" + +msgid "Found {} tracks from {}." +msgstr "{}トラックが{}から見つかりました。" + +msgid "From the same artist" +msgstr "同じアーティストから" + +msgid "From your most-played playlists." +msgstr "よく再生するプレイリストから。" + +msgid "Ideal Discography" +msgstr "理想のディスコグラフィー" + +msgid "Image" +msgstr "画像" + +msgid "Import failed: {}" +msgstr "インポートに失敗しました:{}" + +msgid "Imported {} of {} tracks into QBZ." +msgstr "{}/{}トラックをQBZにインポートしました。" + +msgid "Install the ALSA bridge so playback can reach PipeWire" +msgstr "再生がPipeWireに届くようにALSAブリッジをインストール" + +msgid "Install the PipeWire audio stack" +msgstr "PipeWireオーディオスタックをインストール" + +msgid "Install the PipeWire tools (pw-dump)" +msgstr "PipeWireツール(pw-dump)をインストール" + +msgid "Install the Pulse compatibility tools (optional fallback)" +msgstr "Pulse互換ツールをインストール(任意のフォールバック)" + +msgid "January" +msgstr "1月" + +msgid "July" +msgstr "7月" + +msgid "June" +msgstr "6月" + +msgid "Last.fm disconnected" +msgstr "Last.fmを切断しました" + +msgid "Last.fm sign-in failed to start" +msgstr "Last.fmのログインを開始できませんでした" + +msgid "Library database cleared" +msgstr "ライブラリデータベースを消去しました" + +msgid "ListenBrainz disconnected" +msgstr "ListenBrainzを切断しました" + +msgid "Listening suggestions" +msgstr "リスニングの提案" + +msgid "Loaded {} cached tracks" +msgstr "キャッシュ済み{}トラックを読み込みました" + +msgid "Log in to cache tracks offline" +msgstr "オフラインでトラックをキャッシュするにはログイン" + +msgid "Lossless: {}" +msgstr "ロスレス:{}" + +msgid "Lyrics cache cleared" +msgstr "歌詞キャッシュを消去しました" + +msgid "Lyrics copied" +msgstr "歌詞をコピーしました" + +msgid "MIXTAPE" +msgstr "ミックステープ" + +msgid "March" +msgstr "3月" + +msgid "Matching tracks: {} / {} ({} found)" +msgstr "一致したトラック:{} / {}({}件見つかりました)" + +msgid "May" +msgstr "5月" + +msgid "Middle-click to pause" +msgstr "中クリックで一時停止" + +msgid "Middle-click to play" +msgstr "中クリックで再生" + +msgid "Mixed queue — not cast to Qobuz Connect" +msgstr "混在キュー — Qobuz Connectにキャストしません" + +msgid "More From Your Library" +msgstr "ライブラリからもっと" + +msgid "More Labels" +msgstr "その他のレーベル" + +msgid "Most Streamed" +msgstr "最も再生された" + +msgid "Network folder detected" +msgstr "ネットワークフォルダを検出しました" + +msgid "Never" +msgstr "なし" + +msgid "New Releases" +msgstr "新着リリース" + +msgid "Next Track" +msgstr "次のトラック" + +msgid "No limit" +msgstr "制限なし" + +msgid "No matching tracks found." +msgstr "一致するトラックが見つかりません。" + +msgid "No sinks detected — reinstall the ALSA UCM profiles, then reboot" +msgstr "シンクが検出されません — ALSA UCMプロファイルを再インストールして再起動してください" + +msgid "No top tracks available for this artist" +msgstr "このアーティストの人気トラックはありません" + +msgid "No tracks available offline" +msgstr "オフラインで利用できるトラックはありません" + +msgid "Not available offline" +msgstr "オフラインでは利用できません" + +msgid "Nothing playable in this playlist right now" +msgstr "現在このプレイリストに再生できるものがありません" + +msgid "November" +msgstr "11月" + +msgid "October" +msgstr "10月" + +msgid "Offline cache cleared" +msgstr "オフラインキャッシュを消去しました" + +msgid "Offline cache is full — free space or raise the limit" +msgstr "オフラインキャッシュがいっぱいです — 空き容量を作るか上限を上げてください" + +msgid "Offline caching failed" +msgstr "オフラインキャッシュに失敗しました" + +msgid "On Local Library" +msgstr "ローカルライブラリにあり" + +msgid "Opened folder ({} files skipped)" +msgstr "フォルダを開きました({}ファイルをスキップ)" + +msgid "Other Outputs" +msgstr "その他の出力" + +msgid "PLAYLIST" +msgstr "プレイリスト" + +msgid "Paste your ListenBrainz user token first" +msgstr "まずListenBrainzのユーザートークンを貼り付けてください" + +msgid "Play/Pause" +msgstr "再生/一時停止" + +msgid "Playing next" +msgstr "次に再生" + +msgid "Playing {} of {}" +msgstr "{}/{}を再生中" + +msgid "Playlist ({} local)" +msgstr "プレイリスト({}件ローカル)" + +msgid "Playlist created in Qobuz™." +msgstr "Qobuz™にプレイリストを作成しました。" + +msgid "Playlist uploaded to Qobuz" +msgstr "プレイリストをQobuzにアップロードしました" + +msgid "Playlist: {}" +msgstr "プレイリスト:{}" + +msgid "Plex link failed to start" +msgstr "Plexの連携を開始できませんでした" + +msgid "Plugin Hardware" +msgstr "プラグインハードウェア" + +msgid "Press Accolades" +msgstr "プレス称賛" + +msgid "Previous Track" +msgstr "前のトラック" + +msgid "Purchase Only" +msgstr "購入のみ" + +msgid "QBZ requesting {} · {}-bit" +msgstr "QBZが{} · {}ビットを要求中" + +msgid "Qobuz Connect is unavailable while offline" +msgstr "オフライン中はQobuz Connectを利用できません" + +msgid "Qobuz Connect renderer disconnected" +msgstr "Qobuz Connectレンダラーが切断されました" + +msgid "Qobuz Connect renderer unreachable" +msgstr "Qobuz Connectレンダラーに接続できません" + +msgid "Qobuzissimes" +msgstr "Qobuzissimes" + +msgid "Quit QBZ" +msgstr "QBZを終了" + +msgid "Rate limited, try again shortly" +msgstr "レート制限中です。しばらくしてからもう一度お試しください" + +msgid "Recently Played" +msgstr "最近再生した項目" + +msgid "Rediscover Your Library" +msgstr "ライブラリを再発見" + +msgid "Release Watch" +msgstr "リリースウォッチ" + +msgid "Removed \"{}\" ({})" +msgstr "「{}」を削除しました({})" + +msgid "Removed album from offline" +msgstr "アルバムをオフラインから削除しました" + +msgid "Removed from offline" +msgstr "オフラインから削除しました" + +msgid "Removed {} of {} tracks" +msgstr "{}/{}トラックを削除しました" + +msgid "Scan cancelled" +msgstr "スキャンをキャンセルしました" + +msgid "Scan complete" +msgstr "スキャンが完了しました" + +msgid "Scan failed" +msgstr "スキャンに失敗しました" + +msgid "Scanning track paths..." +msgstr "トラックパスをスキャン中..." + +msgid "Scroll to adjust volume" +msgstr "スクロールで音量を調整" + +msgid "Search failed: {}" +msgstr "検索に失敗しました:{}" + +msgid "Searching Qobuz catalog..." +msgstr "Qobuzカタログを検索中..." + +msgid "Select music folder" +msgstr "音楽フォルダを選択" + +msgid "September" +msgstr "9月" + +msgid "Show/Hide Window" +msgstr "ウィンドウを表示/非表示" + +msgid "Similar to {}" +msgstr "{}に似ている" + +msgid "Skipped: {}" +msgstr "スキップ:{}" + +msgid "Some tracks can't be cast to Qobuz Connect" +msgstr "一部のトラックはQobuz Connectにキャストできません" + +msgid "Song Radio" +msgstr "ソングラジオ" + +msgid "Split into {} playlists (Qobuz 2000-track limit)" +msgstr "{}個のプレイリストに分割(Qobuzの2000トラック上限)" + +msgid "Start the sign-in first" +msgstr "まずログインを開始してください" + +msgid "Starting…" +msgstr "開始中…" + +msgid "System default" +msgstr "システムの既定" + +msgid "TRACK" +msgstr "トラック" + +msgid "These items resolved to 0 playable tracks" +msgstr "これらの項目で再生できるトラックは0件でした" + +msgid "This action cannot be undone." +msgstr "この操作は取り消せません。" + +msgid "This album has no playable tracks" +msgstr "このアルバムに再生できるトラックはありません" + +msgid "This collection resolved to 0 playable tracks" +msgstr "このコレクションで再生できるトラックは0件でした" + +msgid "This item resolved to 0 playable tracks" +msgstr "この項目で再生できるトラックは0件でした" + +msgid "This modifies your audio files on disk and cannot be undone." +msgstr "これはディスク上のオーディオファイルを変更し、取り消せません。" + +msgid "This playlist has no playable tracks" +msgstr "このプレイリストに再生できるトラックはありません" + +msgid "This removes cached Plex libraries and tracks. Your sign-in is kept." +msgstr "これはキャッシュされたPlexのライブラリとトラックを削除します。ログイン状態は保持されます。" + +msgid "This track is no longer available" +msgstr "このトラックは利用できなくなりました" + +msgid "This track is not yet available" +msgstr "このトラックはまだ利用できません" + +msgid "Track count differs from the result; titles applied by position" +msgstr "トラック数が結果と異なります。タイトルは位置順に適用されました" + +msgid "Track not available offline" +msgstr "トラックはオフラインで利用できません" + +msgid "Track not castable to Qobuz Connect" +msgstr "トラックはQobuz Connectにキャストできません" + +msgid "Track unavailable on Qobuz Connect" +msgstr "トラックはQobuz Connectで利用できません" + +msgid "Tracks matched: {} / {}" +msgstr "一致したトラック:{} / {}" + +msgid "Unavailable Plex track" +msgstr "利用できないPlexトラック" + +msgid "Unavailable track" +msgstr "利用できないトラック" + +msgid "Unknown" +msgstr "不明" + +msgid "Unknown Artist" +msgstr "不明なアーティスト" + +msgid "Unknown source" +msgstr "不明なソース" + +msgid "Upcoming" +msgstr "近日公開" + +msgid "Upload incomplete — local playlist kept" +msgstr "アップロードが未完了です — ローカルプレイリストを保持しました" + +msgid "Various Artists" +msgstr "ヴァリアスアーティスト" + +msgid "Waiting for the DAC to start playing…" +msgstr "DACの再生開始を待っています…" + +msgid "Write tags to audio files?" +msgstr "オーディオファイルにタグを書き込みますか?" + +msgid "Writing tags to files isn't supported for CUE albums; use sidecar mode" +msgstr "CUEアルバムではファイルへのタグ書き込みに対応していません。サイドカーモードを使用してください" + +msgid "Year must be a number" +msgstr "年は数値で入力してください" + +msgid "You're offline — try again when connected" +msgstr "オフラインです — 接続後にもう一度お試しください" + +msgid "Your audio stack is ready for bit-perfect playback." +msgstr "オーディオスタックはビットパーフェクト再生の準備ができています。" + +msgid "Your queue is empty — add some tracks first, or press Play test." +msgstr "キューが空です — まずトラックを追加するか、テスト再生を押してください。" + +msgid "by {}" +msgstr "{}による" + +msgid "hw (Direct Hardware)" +msgstr "hw(ダイレクトハードウェア)" + +msgid "pcm (Most compatible)" +msgstr "pcm(最も互換性が高い)" + +msgid "plughw (Auto-convert)" +msgstr "plughw(自動変換)" + +msgid "{} + {} more" +msgstr "{} + 他{}件" + +msgid "{} albums · {} tracks" +msgstr "{}アルバム · {}トラック" + +msgid "{} h {} min" +msgstr "{}時間{}分" + +msgid "{} libraries found" +msgstr "{}個のライブラリが見つかりました" + +msgid "{} min" +msgstr "{}分" + +msgid "{} removed from blacklist" +msgstr "{}をブラックリストから削除しました" + +msgid "{} tracks loaded" +msgstr "{}トラックを読み込みました" + +msgid "{} tracks · {}" +msgstr "{}トラック · {}" + +msgid "{} {}" +msgstr "{} {}" + +msgid "{} {}, {}" +msgstr "{} {}、{}" + +msgid "{} — Complete Discography" +msgstr "{} — 全ディスコグラフィー" + +msgid "· Unlimited" +msgstr "· 無制限" + +msgid "· of {}" +msgstr "· {}中" + +# --- Purchases (Slice 13) --- +msgid "My Purchases" +msgstr "購入済み" + +msgid "Purchases are downloaded as DRM-free files. Availability depends on your Qobuz region." +msgstr "購入済みはDRMフリーのファイルとしてダウンロードされます。利用可否はQobuzの地域によります。" + +msgid "Search purchases…" +msgstr "購入済みを検索…" + +msgid "No purchases yet" +msgstr "まだ購入はありません" + +msgid "No purchased tracks yet" +msgstr "まだ購入したトラックはありません" + +msgid "Filters" +msgstr "フィルター" + +msgid "Availability" +msgstr "利用可否" + +msgid "Hide unavailable" +msgstr "利用不可を非表示" + +msgid "CD Quality" +msgstr "CD音質" + +msgid "MP3" +msgstr "MP3" + +msgid "Downloads" +msgstr "ダウンロード" + +msgid "Hide downloaded" +msgstr "ダウンロード済みを非表示" + +msgid "Group: A–Z" +msgstr "グループ:A–Z" + +msgid "Sort: Date" +msgstr "並べ替え:日付" + +msgid "Sort: Artist" +msgstr "並べ替え:アーティスト" + +msgid "Sort: Album" +msgstr "並べ替え:アルバム" + +msgid "Sort: Quality" +msgstr "並べ替え:音質" + +msgid "Date" +msgstr "日付" + +msgid "Download all" +msgstr "すべてダウンロード" + +msgid "Download complete" +msgstr "ダウンロードが完了しました" + +msgid "Download cancelled ({}/{})" +msgstr "ダウンロードをキャンセルしました({}/{})" + +msgid "Downloading {}/{}..." +msgstr "ダウンロード中{}/{}..." + +msgid "Select format" +msgstr "フォーマットを選択" + +msgid "Purchased on" +msgstr "購入日" + +msgid "Add these downloaded tracks to your local library." +msgstr "これらのダウンロードしたトラックをローカルライブラリに追加します。" + +msgid "{} tracks" +msgstr "{}トラック" + +msgid "Choose folder" +msgstr "フォルダを選択" + +msgid "Couldn't add to library" +msgstr "ライブラリに追加できませんでした" + +msgid "Added to library" +msgstr "ライブラリに追加しました" + +msgid "Purchased from Qobuz" +msgstr "Qobuzで購入済み" + +msgid "Couldn't load purchases. Check your connection." +msgstr "購入済みを読み込めませんでした。接続を確認してください。" + +msgid "This track has no album to download" +msgstr "このトラックにはダウンロードできるアルバムがありません" + +msgid "Failed to start download. Please try again." +msgstr "ダウンロードを開始できませんでした。もう一度お試しください。" + +msgid "No downloadable formats available" +msgstr "ダウンロード可能なフォーマットがありません" + +msgid "Download goodies" +msgstr "特典をダウンロード" + +# Playlist "Suggested Songs" section (T8) +msgid "Suggested songs" +msgstr "おすすめの曲" + +msgid "Suggest songs for this playlist" +msgstr "このプレイリストに曲を提案" + +msgid "Finding similar artists..." +msgstr "似たアーティストを検索中..." + +msgid "Refresh suggestions" +msgstr "提案を更新" + +msgid "Not interested" +msgstr "興味なし" + +# Slint polish batch (radio / playlist follow-copy / MusicBrainz / reco) +msgid "QBZ Radio" +msgstr "QBZラジオ" + +msgid "Qobuz Radio" +msgstr "Qobuzラジオ" + +msgid "Create QBZ radio" +msgstr "QBZラジオを作成" + +msgid "Create Qobuz radio" +msgstr "Qobuzラジオを作成" + +msgid "Follow on Qobuz" +msgstr "Qobuzでフォロー" + +msgid "Following on Qobuz" +msgstr "Qobuzでフォロー中" + +msgid "Copy to your library" +msgstr "ライブラリにコピー" + +msgid "METADATA" +msgstr "メタデータ" + +msgid "MusicBrainz" +msgstr "MusicBrainz" + +msgid "Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections." +msgstr "MusicBrainzからのアーティストの関連性と拡張メタデータを有効にします。テレメトリはありません — リリースを照合してアーティストページとプレイリストの曲の提案を充実させるだけです。オフにするとこれらのセクションを無効にします。" + +msgid "No tracks to add" +msgstr "追加するトラックがありません" + +msgid "Failed to add tracks" +msgstr "トラックの追加に失敗しました" + +msgid "Copied to your library" +msgstr "ライブラリにコピーしました" + +msgid "Failed to copy playlist" +msgstr "プレイリストのコピーに失敗しました" + +msgid "Playlist has no tracks to copy" +msgstr "プレイリストにコピーするトラックがありません" + +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{}トラックを追加しました" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{}トラックを{}に追加しました" + +msgid "Added {} track to queue" +msgid_plural "Added {} tracks to queue" +msgstr[0] "{}トラックをキューに追加しました" + +msgid "Unfollow on Qobuz" +msgstr "Qobuzでフォロー解除" + +msgid "Stops in" +msgstr "停止まで" + +msgid "Cancel Timer" +msgstr "タイマーをキャンセル" + +msgid "Stop playback after:" +msgstr "再生を停止する時間:" + +msgid "30 min" +msgstr "30分" + +msgid "1 hr" +msgstr "1時間" + +msgid "2 hr" +msgstr "2時間" + +msgid "3 hr" +msgstr "3時間" + +msgid "5 hr" +msgstr "5時間" + +msgid "Custom…" +msgstr "カスタム…" + +msgid "min" +msgstr "分" + +msgid "Set Timer" +msgstr "タイマーを設定" + +msgid "Cancel stop after this" +msgstr "この後の停止をキャンセル" + +msgid "LOGS" +msgstr "ログ" + +msgid "Application logs" +msgstr "アプリケーションログ" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "アプリ内のログを表示、コピー(機密情報は伏せ字)、またはアップロードしてGitHubのIssueで共有できます。" + +msgid "Open log viewer" +msgstr "ログビューアーを開く" + +msgid "Setting" +msgstr "設定項目" + +msgid "Saved" +msgstr "保存済み" + +msgid "Runtime" +msgstr "ランタイム" + +msgid "Diagnostics" +msgstr "診断" + +msgid "Runtime vs saved configuration snapshot." +msgstr "ランタイムと保存済み設定のスナップショット。" + +msgid "Exported" +msgstr "エクスポート済み" + +msgid "Export to clipboard" +msgstr "クリップボードにエクスポート" + +msgid "Cast Discovery" +msgstr "キャストの検出" + +msgid "Scanning…" +msgstr "スキャン中…" + +msgid "Scan for devices" +msgstr "デバイスをスキャン" + +msgid "Graphics" +msgstr "グラフィック" + +msgid "Environment" +msgstr "環境" + +msgid "Logs" +msgstr "ログ" + +msgid "View logs" +msgstr "ログを表示" + +msgid "Upload failed." +msgstr "アップロードに失敗しました。" + +msgid "All levels" +msgstr "すべてのレベル" + +msgid "Error" +msgstr "エラー" + +msgid "Warn" +msgstr "警告" + +msgid "Info" +msgstr "情報" + +msgid "Debug" +msgstr "デバッグ" + +msgid "Trace" +msgstr "トレース" + +msgid "Search logs…" +msgstr "ログを検索…" + +msgid "Auto-tail" +msgstr "自動追尾" + +msgid "Time" +msgstr "時刻" + +msgid "Level" +msgstr "レベル" + +msgid "Target" +msgstr "ターゲット" + +msgid "Message" +msgstr "メッセージ" + +msgid "No log entries." +msgstr "ログエントリはありません。" + +msgid "Copied" +msgstr "コピーしました" + +msgid "Copy all" +msgstr "すべてコピー" + +msgid "Copy diagnostics bundle" +msgstr "診断バンドルをコピー" + +msgid "Uploading…" +msgstr "アップロード中…" + +msgid "Upload (public)" +msgstr "アップロード(公開)" + +msgid "Open log file" +msgstr "ログファイルを開く" + +msgid "Report an issue" +msgstr "問題を報告" + +msgid "Go to logs" +msgstr "ログへ移動" + +msgid "Create issue report" +msgstr "Issueレポートを作成" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "バグや問題の解決を容易にするため、アプリケーションログの共有をおすすめします。送信するログは機密データが含まれないよう慎重に伏せ字にしています。処理は手動で行われ、自動で行われることはないため、共有される内容を確認できます。" + +msgid "Cache window" +msgstr "キャッシュ期間" + +msgid "24 hours" +msgstr "24時間" + +msgid "36 hours" +msgstr "36時間" + +msgid "48 hours" +msgstr "48時間" + +msgid "72 hours" +msgstr "72時間" + +msgid "Refresh now" +msgstr "今すぐ更新" + +msgid "No internet connection — you can start in offline mode with your local library and downloads" +msgstr "インターネット接続がありません — ローカルライブラリとダウンロードでオフラインモードを開始できます" + +msgid "QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™." +msgstr "QBZには有効なQobuz™サブスクリプションが必要です。認証情報はQobuz™に直接送信されます。" + +msgid "Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz." +msgstr "Qobuz™はQobuzの商標です。QBZはMIT Licenseの下でライセンスされたオープンソースアプリケーションであり、Qobuzと提携、推奨、認定の関係はありません。" + +msgid "This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy." +msgstr "これはQobuzトラックを含むプレイリストをQobuz上に作成し、ローカルファイルを関連付けたまま保持し、ローカルのみのコピーを削除します。" + +msgid "Add this to the current queue, or clear the queue and start playing it?" +msgstr "現在のキューに追加しますか、それともキューをクリアして再生を開始しますか?" + +msgid "This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates." +msgstr "このプレイリストには、すでに{}件(追加する{}件のトラックのうち)が含まれています。新しいものだけを追加するか、重複を含めてすべて追加してください。" + +msgid "This wizard helps you configure your DAC for bit-perfect playback on Linux. QBZ never changes your system automatically — it auto-detects your DACs, shows you the exact commands and config to copy, and lets you test playback right here." +msgstr "このウィザードはLinuxでビットパーフェクト再生のためにDACを設定する手助けをします。QBZがシステムを自動的に変更することはありません。DACを自動検出し、コピーすべき正確なコマンドと設定を表示し、ここで再生をテストできるようにします。" + +msgid "You are responsible for your own backups. This is a helper, not a system recovery tool." +msgstr "バックアップはご自身の責任で行ってください。これは補助ツールであり、システム復旧ツールではありません。" + +msgid "The built-in test plays four tracks spanning different bit-depths and sample rates (16/44.1, 24/44.1, 24/96, 24/192) so you can see your DAC switch. If you'd rather use your own queue, add one track of each kind first so every rate is exercised." +msgstr "内蔵テストは異なるビット深度とサンプルレート(16/44.1、24/44.1、24/96、24/192)にまたがる4つのトラックを再生し、DACの切り替えを確認できます。独自のキューを使いたい場合は、すべてのレートが試されるよう、各種類のトラックを1つずつ先に追加してください。" + +msgid "QBZ is packaged as Flatpak/Snap and can't inspect your host audio stack. If playback already works, you're set. Pick your distribution and init system below for the right setup commands." +msgstr "QBZはFlatpak/Snapとしてパッケージ化されており、ホストのオーディオスタックを検査できません。すでに再生が機能している場合は設定完了です。適切なセットアップコマンドのために、以下でディストリビューションとinitシステムを選択してください。" + +msgid "QBZ already pins rate, sink and exclusive mode at runtime. These files only change system-wide behavior — copy and run them yourself if you want them. Back up first." +msgstr "QBZはすでに実行時にレート、シンク、排他モードを固定します。これらのファイルはシステム全体の動作のみを変更します。必要な場合はご自身でコピーして実行してください。先にバックアップを取ってください。" + +msgid "Plays four tracks at different bit-depths and sample rates through the selected DAC. Watch your DAC's display switch rates — and confirm it below." +msgstr "選択したDACを通じて異なるビット深度とサンプルレートで4つのトラックを再生します。DACのディスプレイでレートが切り替わるのを確認し、以下で確認してください。" + +msgid "Your DAC is ready for bit-perfect playback. If you applied the config files, restart the audio services (or log out and back in) for them to take effect." +msgstr "DACはビットパーフェクト再生の準備ができています。設定ファイルを適用した場合は、有効にするためにオーディオサービスを再起動(またはログアウトして再ログイン)してください。" + +msgid "Live session topology and a rolling event log for debugging Qobuz Connect at runtime." +msgstr "Qobuz Connectを実行時にデバッグするための、ライブセッショントポロジーと随時更新されるイベントログ。" + +msgid "Start uncached tracks via streaming instead of waiting for the full download." +msgstr "完全なダウンロードを待つ代わりに、キャッシュされていないトラックをストリーミングで開始します。" + +msgid "Blacklisted artists are hidden from search results, radio, suggestions, and similar artists." +msgstr "ブラックリストに登録されたアーティストは、検索結果、ラジオ、提案、類似アーティストから非表示になります。" + +msgid "Blacklist filtering is disabled. Blacklisted artists are shown everywhere until you re-enable it." +msgstr "ブラックリストフィルタリングは無効です。再度有効にするまで、ブラックリストに登録されたアーティストはあらゆる場所に表示されます。" + +msgid "Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services." +msgstr "再生履歴をLast.fmとListenBrainzに送信します。Qobuz、ローカル、Plexのトラックに対応しています。このスイッチは、おすすめがそれらのサービスから再生履歴を読み取れるかどうかも決めます。" + +msgid "Show what you're listening to as your Discord status. Opt-in — Discord must be running." +msgstr "聴いている曲をDiscordのステータスとして表示します。オプトイン制で、Discordが起動している必要があります。" + +msgid "" +"Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" +msgstr "" +"Flatpakインストールでプレゼンスが表示されませんか?DiscordのIPCソケットへのアクセスを許可してから、QBZを再起動してください:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" + +msgid "Use artwork-derived blur as a backdrop in album and artist detail views." +msgstr "アルバムとアーティストの詳細ビューで、アートワークから生成したぼかしを背景として使用します。" + +msgid "Smart search cache, ranking, and the search preview dropdown. On by default." +msgstr "スマート検索キャッシュ、ランキング、検索プレビューのドロップダウン。デフォルトでオンです。" + +msgid "What selecting a result in the Immersive search does. Disabled turns the in-immersive search off." +msgstr "没入型検索で結果を選択したときの動作。無効にすると没入内検索がオフになります。" + +msgid "Which immersive view opens by default. 'Remember last' restores your last view." +msgstr "デフォルトで開く没入ビュー。「前回を記憶」は最後に表示していたビューを復元します。" + +msgid "Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar." +msgstr "見つける、ライブラリ、ローカルライブラリ、マイQBZのセクションをヘッダーからサイドバーに移動します。" + +msgid "Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default." +msgstr "マイQBZハブの名前を変更します。名前を空白にする(またはリセットを押す)とデフォルトに戻ります。" + +msgid "Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images." +msgstr "各プレイリストの横にトラックカバーの2×2サムネイルを表示します。低スペックのマシンでは無効にして追加の画像を省略してください。" + +msgid "Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost." +msgstr "トラックとフォルダのビューで、各トラックの横にアルバムカバーのサムネイルを表示します。デフォルトではオフです。大規模なライブラリでは行ごとに画像デコードのコストがかかります。" + +msgid "Replace QBZ custom title bar with your system's native window decorations. Changing this will restart the app." +msgstr "QBZのカスタムタイトルバーをシステムのネイティブなウィンドウ装飾に置き換えます。これを変更するとアプリが再起動します。" + +msgid "Place the window control buttons on the left or right side of the title bar" +msgstr "ウィンドウ操作ボタンをタイトルバーの左側または右側に配置します" + +msgid "Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these." +msgstr "最小化、最大化、閉じるボタンをタイトルバーに表示します。ウィンドウマネージャーがこれらを処理する場合は無効にしてください。" + +msgid "Add discrete plus and minus buttons next to the volume slider in the player bar." +msgstr "プレーヤーバーの音量スライダーの横に、独立したプラスとマイナスのボタンを追加します。" + +msgid "Show the Purchases section in the sidebar for browsing and downloading your purchased music" +msgstr "購入した音楽の閲覧とダウンロードのために、サイドバーに購入済みセクションを表示します" + +msgid "Keep playing in the menu bar instead of quitting when you close the window" +msgstr "ウィンドウを閉じたときに終了せず、メニューバーで再生を続けます" + +msgid "Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)" +msgstr "ウィンドウを閉じている間、メニューバーのみのアプリとして実行します。オフにするとDockアイコンを保持します(Spotifyのように)" + +msgid "Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo" +msgstr "パネルに合わせたモノクログリフ(Plasma、GNOMEの常に暗いトップバー)またはフルカラーのレコードロゴを選択してください" + +msgid "Controls how the blurred artwork background is rendered in immersive mode." +msgstr "没入モードでぼかしたアートワーク背景がどのように描画されるかを制御します。" + +msgid "Disabled panels are hidden from the visualizer menu so you don't land on them by mistake." +msgstr "無効にしたパネルはビジュアライザーメニューから非表示になり、誤って表示してしまうことを防ぎます。" + +msgid "Configure frame rate per animation. Lower values save CPU. Requires re-entering immersive mode to apply." +msgstr "アニメーションごとにフレームレートを設定します。低い値ほどCPUを節約します。適用するには没入モードに入り直す必要があります。" + +msgid "No internet connection — your local library and downloads keep working." +msgstr "インターネット接続がありません — ローカルライブラリとダウンロードは引き続き機能します。" + +msgid "Create a random queue with songs from this collection. How many songs do you want?" +msgstr "このコレクションの曲でランダムなキューを作成します。曲数はいくつにしますか?" + +msgid "No items yet. Add albums, tracks, or playlists from their detail pages." +msgstr "まだ項目がありません。詳細ページからアルバム、トラック、またはプレイリストを追加してください。" + +msgid "" +"Music Player\n" +"Nothing playing" +msgstr "" +"ミュージックプレーヤー\n" +"再生中の項目はありません" + +msgid "Offline listening period expired — reconnect to verify your subscription" +msgstr "オフライン再生期間が終了しました — サブスクリプションを確認するために再接続してください" + +msgid "This removes ALL indexed tracks from the database. Your audio files are NOT deleted. You will need to re-scan your folders afterward." +msgstr "これはデータベースからインデックス済みのすべてのトラックを削除します。オーディオファイルは削除されません。後でフォルダを再スキャンする必要があります。" + +msgid "This signs out of Plex and clears the locally cached libraries and tracks." +msgstr "これはPlexからログアウトし、ローカルにキャッシュされたライブラリとトラックをクリアします。" + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback without browser sample rate limitations." +msgstr "ブラウザのサンプルレート制限にとらわれず、ビットパーフェクトなHi-Fi再生を求めるオーディオファイルのために設計された、Linux向けのネイティブ音楽ストリーミングクライアント。" + +msgid "Acknowledgments" +msgstr "謝辞" + +msgid "Animated background renderer" +msgstr "アニメーション背景レンダラー" + +msgid "Audio playback engine" +msgstr "オーディオ再生エンジン" + +msgid "Author" +msgstr "作者" + +msgid "Build" +msgstr "ビルド" + +msgid "Build Info" +msgstr "ビルド情報" + +msgid "Codename" +msgstr "コードネーム" + +msgid "Contributors" +msgstr "貢献者" + +msgid "Hatred towards all those companies that discriminate against the Linux community and don't provide us with a decent client for their product." +msgstr "Linuxコミュニティを差別し、自社製品向けのまともなクライアントを提供しないすべての企業への怒りを込めて。" + +msgid "Icon library" +msgstr "アイコンライブラリ" + +msgid "License" +msgstr "ライセンス" + +msgid "Lyrics API" +msgstr "歌詞API" + +msgid "Made with" +msgstr "使用技術" + +msgid "Music link aggregation API" +msgstr "音楽リンク集約API" + +msgid "Music metadata database" +msgstr "音楽メタデータベース" + +msgid "Music streaming service and API" +msgstr "音楽ストリーミングサービスおよびAPI" + +msgid "Platform" +msgstr "プラットフォーム" + +msgid "Release" +msgstr "リリース" + +msgid "Release notes are not available." +msgstr "リリースノートはありません。" + +msgid "Synced lyrics provider" +msgstr "同期歌詞プロバイダー" + +msgid "UI framework" +msgstr "UIフレームワーク" + +msgid "Version" +msgstr "バージョン" + +msgid "Website" +msgstr "ウェブサイト" + +msgid "What's new" +msgstr "新着情報" + +msgid "What's new in v{}" +msgstr "v{}の新機能" + +msgid "in" +msgstr "制作地" + +msgid "QBZ uses your Last.fm and ListenBrainz account data when they're connected (if they aren't, we recommend connecting them to enrich this section). Here you set how often that data is fetched — it defaults to 48 hours, or you can request an update right now." +msgstr "QBZは、Last.fmとListenBrainzのアカウントが接続されている場合、そのデータを利用します(未接続の場合は、このセクションを充実させるために接続することをおすすめします)。ここでは、そのデータを取得する頻度を設定できます — 既定は48時間ですが、今すぐ更新をリクエストすることもできます。" + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback." +msgstr "ビットパーフェクトなHi-Fi再生を求めるオーディオファイルのために設計された、Linux向けのネイティブ音楽ストリーミングクライアント。" + +msgid "Built with Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, Lucide, and Kawarp." +msgstr "Slintで構築し、RodioとSymphoniaを採用しています。Qobuz、MusicBrainz、Song.link/Odesli、LRCLIB、lyrics.ovh、Lucide、Kawarpに感謝します。" + +msgid "Extra large" +msgstr "特大" + +msgid "INTERFACE" +msgstr "インターフェース" + +msgid "Interface size" +msgstr "インターフェースのサイズ" + +msgid "Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)" +msgstr "ブラウザのズームのようにインターフェース全体を拡大縮小します。「小」は画面により多くのコンテンツを表示し、「大」と「特大」は読みやすさを向上させます(再起動が必要)" + +msgid "Interface size changed — restart QBZ to apply" +msgstr "インターフェースのサイズを変更しました — 適用するには QBZ を再起動してください" + +msgid "Extra small" +msgstr "極小" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "ブラウザでサインイン" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "ウェブブラウザで続行してください — QBZ はサインインの完了を待っています。" + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "ブラウザは前面に出ずにバックグラウンドで開くことがあります — 他のウィンドウを確認してください。" + +msgid "Signing you in…" +msgstr "サインインしています…" + +msgid "Sign-in failed" +msgstr "サインインに失敗しました" + +msgid "About QBZ" +msgstr "QBZについて" + +msgid "Already used by" +msgstr "すでに使用中:" + +msgid "Auto (recommended)" +msgstr "自動(推奨)" + +msgid "Discrete GPU (higher performance)" +msgstr "外部GPU(高パフォーマンス)" + +msgid "Integrated GPU (lower power)" +msgstr "内蔵GPU(低消費電力)" + +msgid "Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)" +msgstr "自動はグラフィックハードウェアに最適なレンダラーを選択します。動作が遅い、または表示が乱れる場合のみ変更してください(再起動が必要)" + +msgid "Customize Shortcuts" +msgstr "ショートカットをカスタマイズ" + +msgid "GPU" +msgstr "GPU" + +msgid "GPU (compatibility)" +msgstr "GPU(互換)" + +msgid "Go" +msgstr "移動" + +msgid "Line Bed" +msgstr "Line Bed" + +msgid "Tunnel Flow" +msgstr "Tunnel Flow" + +msgid "Open Music Link" +msgstr "音楽リンクを開く" + +msgid "Open Playlist Importer" +msgstr "プレイリストインポーターを開く" + +msgid "Open Qobuz Link" +msgstr "Qobuzリンクを開く" + +msgid "Paste a Qobuz, Spotify, Apple Music, Tidal, Deezer or song.link URL" +msgstr "Qobuz、Spotify、Apple Music、Tidal、Deezer、song.linkのURLを貼り付けてください" + +msgid "Place the Purchases entry in the custom title bar instead of the sidebar" +msgstr "購入をサイドバーではなくカスタムタイトルバーに表示します" + +msgid "Press keys…" +msgstr "キーを押してください…" + +msgid "Purchases" +msgstr "購入" + +msgid "Purchases in title bar" +msgstr "タイトルバーに購入を表示" + +msgid "RENDERER" +msgstr "レンダラー" + +msgid "Rendering backend" +msgstr "レンダリングバックエンド" + +msgid "Reset All to Defaults" +msgstr "すべてリセット" + +msgid "Resolving…" +msgstr "解決中…" + +msgid "Software" +msgstr "ソフトウェア" + +msgid "Spectral Ribbon" +msgstr "Spectral Ribbon" + +msgid "This looks like a playlist." +msgstr "これはプレイリストのようです。" + +msgid "Wave Bed" +msgstr "Wave Bed" + +msgid "What's New" +msgstr "新着情報" + +msgid "modified" +msgstr "変更済み" + +msgid "Block this album" +msgstr "このアルバムをブロック" + +msgid "Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed." +msgstr "ブロックしたアルバムは、アーティストが許可されていても、検索・ディスカバー・一覧に表示されません。" + +msgid "No blocked albums" +msgstr "ブロック中のアルバムはありません" + +msgid "This will remove all {} blocked albums. This cannot be undone." +msgstr "{}件のブロック中アルバムをすべて解除します。この操作は元に戻せません。" + +msgid "To block an album, open its menu and choose Block this album." +msgstr "アルバムをブロックするには、メニューから「このアルバムをブロック」を選択してください。" + +msgid "Unblock album" +msgstr "アルバムのブロックを解除" + +msgid "{} albums" +msgstr "{}枚のアルバム" + +msgid "{} artists, {} albums blocked" +msgstr "{}人のアーティスト、{}枚のアルバムをブロック中" + +msgid "{} artists, {} albums blocked (disabled)" +msgstr "{}人のアーティスト、{}枚のアルバムをブロック中(無効)" + +msgid "A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases." +msgstr "リスニング履歴から作られるパーソナライズされた「見つける」タブ。最良の結果を得るには、下のLast.fmとListenBrainzを接続してください。リリースの照合にはMusicBrainzが自動的に使用されます。" + +msgid "Based on what you've been into lately" +msgstr "最近よく聴いているものから" + +msgid "Built with Rust and Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, and Lucide." +msgstr "RustとSlintで開発され、RodioとSymphoniaを利用しています。Qobuz、MusicBrainz、Song.link/Odesli、LRCLIB、lyrics.ovh、Lucideに感謝します。" + +msgid "Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations." +msgstr "設定でLast.fmまたはListenBrainzを接続するか、もっと音楽を再生すると、パーソナライズされたおすすめが表示されます。" + +msgid "JACK is not bit-perfect" +msgstr "JACKはビットパーフェクトではありません" + +msgid "More like the artists you love" +msgstr "好きなアーティストに似たもの" + +msgid "No recommendations yet" +msgstr "おすすめはまだありません" + +msgid "Popular artists on Qobuz" +msgstr "Qobuzで人気のアーティスト" + +msgid "RECOMMENDATIONS" +msgstr "おすすめ" + +msgid "Recommendations" +msgstr "おすすめ" + +msgid "Show Recommendations in Discover" +msgstr "「見つける」におすすめを表示" + +msgid "The JACK backend routes audio through the JACK graph, which resamples to the graph's sample rate. For bit-perfect playback use ALSA (direct) or PipeWire with passthrough." +msgstr "JACKバックエンドはオーディオをJACKグラフ経由でルーティングするため、グラフのサンプルレートにリサンプリングされます。ビットパーフェクト再生にはALSA(ダイレクト)またはパススルー対応のPipeWireを使用してください。" + +msgid "Weekly Exploration" +msgstr "今週の発見" + +msgid "Weekly Jams" +msgstr "今週のお気に入り" + +msgid "Keep your system's native window decorations. Turn off to use the QBZ header as the title bar, with its own window controls and drag support. Takes effect after restarting QBZ." +msgstr "システムのネイティブなウィンドウ装飾を使用します。オフにすると、QBZのヘッダーがタイトルバーになり、独自のウィンドウコントロールとドラッグに対応します。QBZの再起動後に反映されます。" + +msgid "Frameless window without window controls or header drag (for tiling window manager users)" +msgstr "フレームなしウィンドウ。ウィンドウコントロールやヘッダーのドラッグなし(タイル型ウィンドウマネージャーのユーザー向け)" + +msgid "Title bar mode changed — restart QBZ to apply" +msgstr "タイトルバーモードを変更しました — 適用するにはQBZを再起動してください" + +# Compact header navigation (opt-in, 2026-07-03) +msgid "Compact header navigation" +msgstr "ヘッダーのコンパクトナビゲーション" + +msgid "Use the icon-only section navigation in the header even while the sidebar is open." +msgstr "サイドバーが開いていても、ヘッダーにアイコンのみのセクションナビゲーションを使用します。" + +# DSD support Phase 1 (2026-07-03) +msgid "DSD: {}" +msgstr "DSD: {}" + +msgid "Cannot play DSD file" +msgstr "DSDファイルを再生できません" + +# DSD Phases 2-3: delivery mode (2026-07-03) +msgid "DSD playback" +msgstr "DSD再生" + +msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC." +msgstr "DSDトラックをDACへ送る方法。警告:DACが対応している場合のみDoPまたはネイティブを選択してください。対応していないDACでは大きなノイズとして再生されます。DoP/ネイティブモード中は音量が固定されます。ネイティブはさらにDAC用のカーネルサポートが必要です。" + +msgid "Convert to PCM (works everywhere)" +msgstr "PCMに変換(すべての環境で動作)" + +msgid "DoP — DSD over PCM (bit-perfect)" +msgstr "DoP — PCM上のDSD(ビットパーフェクト)" + +msgid "Native DSD (kernel support required)" +msgstr "ネイティブDSD(カーネルサポートが必要)" + +msgid "Date added" +msgstr "追加日" + +msgid "Renderer setting reverted to Auto — the previous start didn't finish" +msgstr "レンダラー設定を自動に戻しました — 前回の起動が完了しませんでした" + +msgid "Renderer switched to GPU (compatibility) — the GPU renderer failed to start" +msgstr "レンダラーをGPU(互換)に切り替えました — GPUレンダラーを起動できませんでした" + +msgid "Audio output error" +msgstr "オーディオ出力エラー" + +msgid "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default" +msgstr "Flatpak/Snap 内では ALSA デバイスへの直接アクセスがブロックされます — オーディオバックエンドを PipeWire またはシステム既定に切り替えてください" + +msgid "Auto theme generation failed" +msgstr "自動テーマの生成に失敗しました" + +msgid "Play all tracks" +msgstr "すべてのトラックを再生" + +msgid "Shuffle all tracks" +msgstr "すべてのトラックをシャッフル" + +msgid "Select multiple tracks" +msgstr "複数のトラックを選択" + +msgid "Play a random album" +msgstr "ランダムなアルバムを再生" + +msgid "Open a random artist" +msgstr "ランダムなアーティストを開く" + +msgid "Play a random playlist" +msgstr "ランダムなプレイリストを再生" + +msgid "Open a random label" +msgstr "ランダムなレーベルを開く" + +msgid "Start from current theme" +msgstr "現在のテーマから開始" + +msgid "Copy the colors of the currently applied theme into the editor as a starting point." +msgstr "現在適用中のテーマの色を出発点としてエディターにコピーします。" + +msgid "Use current colors" +msgstr "現在の色を使用" + +msgid "Dark theme" +msgstr "ダークテーマ" + +msgid "Set the overall light or dark polarity. Affects derived shades, borders and overlays." +msgstr "全体の明暗の極性を設定します。派生する陰影、境界線、オーバーレイに影響します。" + +msgid "SURFACES" +msgstr "サーフェス" + +msgid "Background" +msgstr "背景" + +msgid "Surface" +msgstr "サーフェス" + +msgid "Elevated" +msgstr "浮き出し" + +msgid "TEXT" +msgstr "テキスト" + +msgid "Text" +msgstr "テキスト" + +msgid "Secondary text" +msgstr "セカンダリテキスト" + +msgid "ACCENT" +msgstr "アクセント" + +msgid "Accent" +msgstr "アクセント" + +msgid "Danger" +msgstr "危険" + +msgid "Warning" +msgstr "警告" + +msgid "Success" +msgstr "成功" + +msgid "OTHER" +msgstr "その他" + +msgid "Border" +msgstr "境界線" + +# Unavailable-track auto-skip (Tauri #467 parity) +msgid "No available tracks to play" +msgstr "再生できるトラックがありません" +# Always-visible log uploader entry (settings sub-nav footer) +msgid "Share logs" +msgstr "ログを共有" +# Guest local-only mode disclaimer line (login screen, #553) +msgid "QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library)." +msgstr "QBZはQobuz™アカウントなしでオフラインプレーヤーとして使用できます(Qobuz™ライブラリにはアクセスできません)。" +# Renderer degradation ladder toast (generic: GL or software rung) +msgid "Renderer switched automatically — the previous renderer failed to start" +msgstr "レンダラーを自動的に切り替えました — 前のレンダラーは起動できませんでした" + +# QConnect startup behavior (Settings > Playback, ported from Tauri) +msgid "Auto-connect Qobuz Connect on startup" +msgstr "起動時にQobuz Connectを自動接続" +msgid "Choose whether Qobuz Connect activates automatically when QBZ launches." +msgstr "QBZの起動時にQobuz Connectを自動的に有効にするかを選択します。" +msgid "Remember state" +msgstr "状態を記憶" +msgid "On by default" +msgstr "デフォルトでオン" +msgid "Off by default" +msgstr "デフォルトでオフ" + +msgid "QBZ recovered from repeated startup crashes — some restored state was reset" +msgstr "QBZ は起動時の連続クラッシュから復旧しました — 復元された状態の一部がリセットされました" + +# QConnect device name (Settings > Playback, ported from Tauri) +msgid "Qobuz Connect device name" +msgstr "Qobuz Connectのデバイス名" + +msgid "The name other Qobuz Connect apps see for this device. Applies on the next connection." +msgstr "他のQobuz Connectアプリに表示されるこのデバイスの名前です。次回の接続時に適用されます。" + +# Quality downgrade tooltip (#590 follow-up) +msgid "Source: {}" +msgstr "ソース: {}" + +msgid "Output: {}" +msgstr "出力: {}" + +# Local Library header Plex sync button (#573) +msgid "Sync Plex library" +msgstr "Plexライブラリを同期" + +# Library Albums sort options (#566) +msgid "Recently added" +msgstr "最近追加した順" + +msgid "First added" +msgstr "追加が古い順" + +msgid "Random" +msgstr "ランダム" + + +# Offline cache clear — communicate it does not remove downloads (#606) +msgid "Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above." +msgstr "キャッシュデータを解放します。ダウンロード済みのアルバムは保持されます。削除は上のオフラインマネージャーから行ってください。" + +msgid "Couldn't clear the cache" +msgstr "キャッシュを削除できませんでした" + + +# Task 18 — Settings > Developer > Export settings modal (daemon 04 §4.2) +msgid "SETTINGS PORTABILITY" +msgstr "設定の移行" + +msgid "Export settings…" +msgstr "設定をエクスポート…" + +msgid "Save a portable bundle of your settings to move to another machine or the qbzd daemon." +msgstr "設定を持ち運び可能なバンドルとして保存し、別のマシンや qbzd デーモンに移せます。" + +msgid "Export…" +msgstr "エクスポート…" + +msgid "Export settings" +msgstr "設定をエクスポート" + +msgid "Save your settings as a bundle to import on the qbzd daemon or another machine." +msgstr "設定をバンドルとして保存し、qbzd デーモンや別のマシンでインポートできます。" + +msgid "Include Qobuz login and integration tokens" +msgstr "Qobuz のログインと連携トークンを含める" + +msgid "Anyone with the file can use your accounts. It is saved readable only by you; delete it after importing." +msgstr "ファイルを読める人は誰でもあなたのアカウントを使用できます。自分だけが読み取れる形で保存されます。インポート後は削除してください。" + +msgid "Export" +msgstr "エクスポート" + +msgid "Bundle saved. On the daemon box:" +msgstr "バンドルを保存しました。デーモンのマシンで:" + +msgid "Could not export settings" +msgstr "設定をエクスポートできませんでした" + + +# Share menu Song.link/Album.link toasts (#514) +msgid "Fetching Song.link..." +msgstr "Song.linkを取得中..." + +msgid "Fetching Album.link..." +msgstr "Album.linkを取得中..." + +msgid "Link copied" +msgstr "リンクをコピーしました" + +msgid "Failed to copy link" +msgstr "リンクをコピーできませんでした" + +# Dynamic app background (Appearance settings) +msgid "Ambient" +msgstr "アンビエント" + +msgid "Blurred art" +msgstr "ぼかしアートワーク" + +msgid "Dynamic background" +msgstr "ダイナミック背景" + +msgid "Animated album-art background behind the whole app. High resource use — GPU accelerated." +msgstr "アプリ全体の背後にアルバムアートワークのアニメーション背景を表示します。リソース使用量が高く、GPUアクセラレーションを使用します。" + +# Preferred GPU (Renderer settings) +msgid "Preferred GPU" +msgstr "優先GPU" + +msgid "Which GPU renders the app. Only the GPUs actually present are listed. Choosing one other than the default also switches the renderer to Vulkan — the only backend Qt can select a GPU on (requires restart)" +msgstr "アプリを描画するGPU。実際に存在するGPUのみを表示します。既定以外を選ぶとレンダラーもVulkanに切り替わります — QtがGPUを選択できる唯一のバックエンドです(再起動が必要)" + +msgid "Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)" +msgstr "どのGPUでアプリをレンダリングするかを選びます。ハイブリッド構成のノートPCでは、ディスクリートGPUを選ぶとダイナミック背景の負荷を内蔵GPUから移せます(温度は下がりますが消費電力は増えます)。自動を推奨します(再起動が必要)" + +msgid "discrete" +msgstr "ディスクリート" + +msgid "integrated" +msgstr "内蔵" + +msgid "Preferred GPU changed — restart QBZ to apply" +msgstr "優先GPUを変更しました — 適用するには QBZ を再起動してください" + +msgid "Selected GPU is unavailable in this display session — using Auto" +msgstr "選択したGPUはこのディスプレイセッションでは利用できないため、自動設定を使用します" + +msgid "Renderer changed — restart QBZ to apply" +msgstr "レンダラーを変更しました — 適用するには QBZ を再起動してください" + +msgid "Search your library" +msgstr "ライブラリを検索" + +msgid "From catalog" +msgstr "カタログから" + +msgid "In library" +msgstr "ライブラリ内" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "お気に入りに追加" + +msgid "Remove from favorites" +msgstr "お気に入りから削除" + +msgid "Start radio" +msgstr "ラジオを開始" + +msgid "Booklet" +msgstr "ブックレット" + +msgid "Add to Mixtape" +msgstr "ミックステープに追加" + +msgid "Album info" +msgstr "アルバム情報" + +msgid "More options" +msgstr "その他のオプション" + +msgid "Follow artist" +msgstr "アーティストをフォロー" + +msgid "Unfollow" +msgstr "フォロー解除" + +msgid "Artist network" +msgstr "アーティストネットワーク" + +msgid "Upload to Qobuz" +msgstr "Qobuzにアップロード" + +msgid "Select tracks" +msgstr "トラックを選択" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuzが一時的にリクエストを拒否しています。少し待って再試行してください。" + + +msgid "Flatpak" +msgstr "Flatpak" + +msgid "Snap" +msgstr "Snap" + +msgid "FLATPAK SANDBOX" +msgstr "FLATPAK サンドボックス" + +msgid "SNAP SANDBOX" +msgstr "SNAP サンドボックス" + +msgid "QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates." +msgstr "QBZ は Flatpak サンドボックス内で動作しています。一部の機能には一度だけの権限付与が必要です。コマンドをターミナルで実行してから QBZ を再起動してください。権限はアップデート後も保持されます。" + +msgid "QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ." +msgstr "QBZ は Snap サンドボックス内で動作しています。一部のオーディオインターフェースは一度だけ接続する必要があります。これらのコマンドをターミナルで実行してから QBZ を再起動してください。" + +msgid "Exclusive DAC access (bit-perfect)" +msgstr "DAC 排他アクセス(ビットパーフェクト)" + +msgid "Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected." +msgstr "排他モードに推奨:QBZ が PipeWire に DAC の引き渡しを要求できます(D-Bus デバイス予約)。これがないと PipeWire が DAC を保持し続け、ALSA + hw + Exclusive を選択していても他のアプリの音が混ざり続けます。" + +msgid "Music on NAS or external drives" +msgstr "NAS や外部ドライブの音楽" + +msgid "Grants access to a folder outside the sandbox. Repeat the command for each folder you need." +msgstr "サンドボックス外のフォルダへのアクセスを許可します。必要なフォルダごとにコマンドを繰り返してください。" + +msgid "Required audio connections" +msgstr "必須のオーディオ接続" + +msgid "Optional — external drives & NAS" +msgstr "任意 — 外部ドライブと NAS" + +msgid "Copy" +msgstr "コピー" + +msgid "Cut" +msgstr "切り取り" + +msgid "Paste" +msgstr "貼り付け" + +msgid "Copied!" +msgstr "コピーしました!" + +msgid "Limited by your streaming quality setting" +msgstr "ストリーミング音質の設定により制限されています" + +msgid "Qobuz has no higher quality for this track" +msgstr "このトラックにはこれ以上高い音質は提供されていません" + +msgid "offline copy — cap not applied" +msgstr "オフラインコピー — 上限は適用されません" + +msgid "cached copy — cap not applied" +msgstr "キャッシュされたコピー — 上限は適用されません" + +msgid "Limited by your output device's limit" +msgstr "出力デバイスの上限により制限されています" + +msgid "Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting." +msgstr "リクエストするストリーミング音質を出力デバイスの上限に制限します。ローカル再生のみに適用され、キャストには適用されません。" + +msgid "Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly." +msgstr "このシステムではデバイスの正確な上限を読み取れなかったため、一般的なセットを想定しています。制限は引き続き適用されますが、実際のハードウェアと完全には一致しない場合があります。" + +msgid "Detected device limit" +msgstr "検出されたデバイスの上限" + +msgid "Quality for this device" +msgstr "このデバイスの音質" + +msgid "Follow app setting ({})" +msgstr "アプリの設定に従う({})" + +msgid "Hi-Res — 24-bit, up to 96 kHz" +msgstr "Hi-Res — 24-bit、最大96 kHz" + +msgid "CD — 16-bit / 44.1 kHz" +msgstr "CD — 16-bit / 44.1 kHz" + +msgid "MP3 320" +msgstr "MP3 320" + +msgid "Qobuz only offers these tiers — there is no 48 kHz option. The step below Hi-Res is CD 44.1 kHz." +msgstr "Qobuzが提供するのはこれらの段階のみで、48 kHzのオプションはありません。Hi-Resの一つ下はCD 44.1 kHzです。" + +msgid "Limited by the quality cap for {}" +msgstr "{}の音質上限により制限されています" + +msgid "capped for this device" +msgstr "このデバイス向けに上限適用中" + +msgid "Albums by folder" +msgstr "フォルダー別アルバム" + +msgid "Albums by metadata" +msgstr "メタデータ別アルバム" + +msgid "ALBUMS VIEW" +msgstr "アルバムビュー" + +msgid "Album grouping" +msgstr "アルバムのグループ化" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "フォルダー: アルバムフォルダーごとに1枚のカード — コンピレーションやボックスセットに最適。メタデータ: アルバム+アーティストのタグで分割。" + +msgid "+{} more artists" +msgstr "他 {} 人のアーティスト" + +msgid "Show less" +msgstr "表示を減らす" +# Most Played Albums rail title +msgid "Most Played Albums" +msgstr "最も再生したアルバム" + +# Most Played Albums View-all — per-card play count +msgid "{} plays" +msgstr "{} 回再生" + +# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast +msgid "Show lyrics translation" +msgstr "歌詞の翻訳を表示" + +msgid "Hide lyrics translation" +msgstr "歌詞の翻訳を隠す" + +msgid "Translation language" +msgstr "翻訳言語" + +msgid "Auto (account language)" +msgstr "自動(アカウントの言語)" + +msgid "Translation is not available right now. Please try again later." +msgstr "翻訳は現在利用できません。後でもう一度お試しください。" +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "除外したアーティストは「おすすめ」からのみ消えます — 検索やその他の場所には引き続き表示されます。" + +msgid "No dismissed artists" +msgstr "除外したアーティストはいません" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "おすすめにアーティストを表示しないようにするには、そのカードのメニューを開いて「興味なし」を選んでください。" + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} はおすすめに表示されなくなります" + +msgid "Artist dismissed from Recommendations" +msgstr "アーティストをおすすめから除外しました" + +msgid "{} restored to Recommendations" +msgstr "{} をおすすめに復元しました" + +msgid "LIBRARY › ALL" +msgstr "ライブラリ › すべて" + +msgid "Show local files & Plex" +msgstr "ローカルファイルと Plex を表示" + +msgid "Include your favorited local files and Plex items in the Library All feed." +msgstr "お気に入りのローカルファイルと Plex のアイテムをライブラリの「すべて」フィードに含めます。" + +msgid "Play indicator animation" +msgstr "再生インジケーターのアニメーション" + +msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU." +msgstr "再生中の行をイコライザーバーでアニメーション表示します。オフ(デフォルト)では静的な一時停止アイコンと行の端にアクセントマークを表示し、CPU負荷を抑えます。" + +msgid "Invert swipe navigation direction" +msgstr "スワイプナビゲーションの方向を反転" + +msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward." +msgstr "トラックパッドの2本指スワイプを入れ替えます:左で戻る、右で進む。" + +msgid "Local items in Library All" +msgstr "ライブラリ「すべて」のローカル項目" + +msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track." +msgstr "お気に入りのみ:ハートを付けた項目。ローカルライブラリ全体:フォルダと Plex のすべてのアルバム・アーティスト・トラック。" + +msgid "Favorited items only" +msgstr "お気に入りのみ" + +msgid "Entire local library" +msgstr "ローカルライブラリ全体" + +msgid "Select multiple albums" +msgstr "複数のアルバムを選択" + +msgid "Play later" +msgstr "後で再生" + +msgid "Added to play later" +msgstr "「後で再生」に追加しました" + +msgid "Next in queue" +msgstr "キューの次" + +msgid "Next up" +msgstr "次に再生" + +msgid "Removed {} tracks from favorites" +msgstr "{} トラックをお気に入りから削除しました" + +msgid "Removing {} tracks from favorites…" +msgstr "{} トラックをお気に入りから削除中…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "アルバム「{}」をブロックしました" + +msgid "Album \"{}\" unblocked" +msgstr "アルバム「{}」のブロックを解除しました" + +msgid "Failed to block album" +msgstr "アルバムのブロックに失敗しました" + +msgid "Failed to unblock album" +msgstr "アルバムのブロック解除に失敗しました" + +msgid "Failed to clear album blacklist" +msgstr "アルバムのブラックリストのクリアに失敗しました" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "ブラックリストから{}枚のアルバムを削除しました" + +msgid "Playlist imported" +msgstr "プレイリストをインポートしました" + +msgid "Playlist import failed" +msgstr "プレイリストのインポートに失敗しました" + +msgid "Not logged in to Qobuz" +msgstr "Qobuzにログインしていません" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "フォルダを削除" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "フォルダ「{}」を削除しますか?プレイリストはルートに戻ります。" + +msgid "Choose a folder image" +msgstr "フォルダ画像を選択" + +msgid "Images" +msgstr "画像" + +msgid "Failed to create folder" +msgstr "フォルダの作成に失敗しました" + +msgid "Failed to save folder" +msgstr "フォルダの保存に失敗しました" + +msgid "Failed to delete folder" +msgstr "フォルダの削除に失敗しました" + +msgid "Failed to rename playlist" +msgstr "プレイリストの名前変更に失敗しました" + +msgid "Failed to delete playlist" +msgstr "プレイリストの削除に失敗しました" + +msgid "Couldn't create the playlist" +msgstr "プレイリストを作成できませんでした" + +# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard, +# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. +msgid "Open playlist" +msgstr "プレイリストを開く" + +# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut +# action labels + category names for the cheatsheet and the customize editor. +# Slint's Rust-side labels were never extracted (the port inherits the gap); +# this block fixes it for both frontends. en is EXCLUDED on purpose +# (sparse-override convention, qbz-i18n/src/lib.rs:120-125). + +msgid "Play / Pause" +msgstr "再生 / 一時停止" + +msgid "Go Back" +msgstr "戻る" + +msgid "Go Forward" +msgstr "進む" + +msgid "Toggle Sidebar" +msgstr "サイドバーの表示切替" + +msgid "Immersive Mode" +msgstr "没入モード" + +msgid "Close / Dismiss" +msgstr "閉じる / 破棄" + +msgid "Show Shortcuts" +msgstr "ショートカットを表示" + +msgid "Toggle Mini Player" +msgstr "ミニプレーヤーの表示切替" + +msgid "Seek Forward (5s)" +msgstr "5秒進む" + +msgid "Seek Back (5s)" +msgstr "5秒戻る" + +msgid "Seek Forward (10s)" +msgstr "10秒進む" + +msgid "Seek Back (10s)" +msgstr "10秒戻る" + +msgid "Micro View" +msgstr "マイクロビュー" + +msgid "Compact View" +msgstr "コンパクトビュー" + +msgid "Artwork View" +msgstr "アートワークビュー" + +msgid "Listen list" +msgstr "再生リスト" + +msgid "Lyrics View" +msgstr "歌詞ビュー" + +msgid "Navigation" +msgstr "ナビゲーション" + +msgid "Interface" +msgstr "インターフェース" + +msgid "Mini Player" +msgstr "ミニプレーヤー" + + +# Kiosk shell (2026-08-02 kiosk-port contract) + +msgid "Cover" +msgstr "カバー" + +msgid "Radio" +msgstr "ラジオ" + +msgid "Up Next" +msgstr "次に再生" + +# Kiosk <-> Desktop profile toggle (view-mode menu) + +msgid "Kiosk mode" +msgstr "キオスクモード" + +msgid "Desktop mode" +msgstr "デスクトップモード" + +# AlbumView cover menu / lightbox (Qt port, 2026-08-10) + +msgid "View cover" +msgstr "カバーを表示" + +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "画像を表示" + +msgid "Could not load the image" +msgstr "画像を読み込めませんでした" + +# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's +# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen +# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter", +# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On", +# "Bands & Projects", "Load more", "Loading…", "Artist Scene", +# "No artists found for this scene.", "No album appearances found." and +# "Select an artist to see their albums." (period-only delta) already exist. +# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7, +# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them +# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every +# call site substitutes explicitly, e.g. .replace("{artist}", name). + +msgid "Based on {artist} · {genres}" +msgstr "{artist} · {genres} に基づく" + +msgid "Searching MusicBrainz for {genre} artists..." +msgstr "MusicBrainz で {genre} のアーティストを検索中..." + +msgid "Verifying availability on Qobuz..." +msgstr "Qobuz での配信状況を確認中..." + +msgid "Preparing results..." +msgstr "結果を準備中..." + +msgid "No matching artists from this scene are currently available on Qobuz." +msgstr "このシーンに該当するアーティストは現在 Qobuz では利用できません。" + +msgid "Loading albums..." +msgstr "アルバムを読み込み中..." + +msgid "Failed to load albums" +msgstr "アルバムを読み込めませんでした" + +msgid "Failed to load appearances" +msgstr "参加作品を読み込めませんでした" + +msgid "Looking up {name}..." +msgstr "{name} を検索中..." + +msgid "Failed to look up musician" +msgstr "ミュージシャンを検索できませんでした" + +msgid "Browse view" +msgstr "ブラウズ表示" + +msgid "Grid view" +msgstr "グリッド表示" + +msgid "Alphabetical (A-Z)" +msgstr "アルファベット順 (A-Z)" + +msgid "This musician appears in album credits but has limited catalog information available in Qobuz." +msgstr "このミュージシャンはアルバムのクレジットに含まれていますが、Qobuz で利用できるカタログ情報は限られています。" + +msgid "Unable to verify this musician's catalog. The information shown is based on album credits." +msgstr "このミュージシャンのカタログを確認できませんでした。表示されている情報はアルバムのクレジットに基づいています。" + +msgid "This musician appears in album credits." +msgstr "このミュージシャンはアルバムのクレジットに含まれています。" + +msgid "Discography" +msgstr "ディスコグラフィー" + +msgid "Live Albums" +msgstr "ライブアルバム" + +msgid "Select an artist to see their albums" +msgstr "アーティストを選択してアルバムを表示します" + +msgid "{} / {} artists" +msgstr "{} / {} 組のアーティスト" + +# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17 +# §8-6). "Unavailable" and "This track is no longer available" already +# exist in this file and are REUSED by the render and the reactive skip toast +# — nothing below twins them. +# +# The skipped-tracks toast is a plural PAIR, per the convention already set by +# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build +# drops N rows at once, and a singular-only string reads wrong in every locale +# with plural rules. Russian carries all three forms. +# +# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled +# state (contract §5.3/F5): the track left the catalogue but the user owns a +# downloaded copy, so it still plays. It cannot fold into "This track is no +# longer available" — that sentence would be a lie on a row playing right now. +# +# Em dash is U+2014 with spaces on both sides, as everywhere else in this file. +# nplurals=1 — one form, like every other plural entry in this file. +msgid "Find available version" +msgstr "利用可能なバージョンを探す" + +msgid "Replace unavailable track" +msgstr "利用できないトラックを置き換え" + +msgid "No available version found" +msgstr "利用可能なバージョンが見つかりませんでした" + +msgid "Track replaced" +msgstr "トラックを置き換えました" + +msgid "Could not replace the track" +msgstr "トラックの置き換えに失敗しました" + +msgid "The replacement was added at the end of the playlist" +msgstr "代わりのトラックはプレイリストの最後に追加されました" + +msgid "{} track skipped — no longer available" +msgid_plural "{} tracks skipped — no longer available" +msgstr[0] "{}トラックをスキップしました — 利用できなくなりました" + +msgid "No longer on Qobuz — playing your downloaded copy" +msgstr "Qobuz にはもうありません — ダウンロード済みのコピーを再生します" + +msgid "Exact match" +msgstr "完全一致" + +msgid "Low confidence" +msgstr "確度が低い" + +msgid "Replace" +msgstr "置き換える" + +msgid "Replacing..." +msgstr "置き換え中…" + +msgid "The replacement takes the unavailable track's place in the playlist." +msgstr "置き換えた曲は、再生できない曲の位置に入ります。" + +# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19) +msgid "Items per carousel" +msgstr "カルーセルあたりの項目数" + +# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19) +msgid "Recently Played Playlists" +msgstr "最近再生したプレイリスト" + +msgid "Playlists you play will appear here." +msgstr "再生したプレイリストがここに表示されます。" + +# Applied-filters tooltip (2026-08-19) +msgid "+{} more" +msgstr "他 {} 件" + +msgid "+{} more filters" +msgstr "他 {} 件のフィルター" + +msgid "Applied filters" +msgstr "適用中のフィルター" + +# Applied-filters tooltip: group labels (2026-08-19) +msgid "Category" +msgstr "カテゴリー" + +# Applied-filters tooltip: Library source switches (2026-08-19) +msgid "Favorites" +msgstr "お気に入り" + +# Discover configurator: Refresh content (2026-08-19) +msgid "Refresh content" +msgstr "コンテンツを更新" + +# Appearance: click-on-section opens the first tab (2026-08-19) +msgid "Click on menu item navigates to first tab" +msgstr "メニュー項目のクリックで最初のタブを開く" + +msgid "Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu" +msgstr "サイドバーやタイトルバーのセクションをクリックすると、メニューを表示するだけでなく最初のタブ(Discover は Home、Library は All)が開きます" + +# Discover configurator: per-rail default item count (2026-08-19) +msgid "Default ({})" +msgstr "デフォルト ({})" + +# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources + +msgid "Streaming URL" +msgstr "ストリーミング URL" + +msgid "Playlist file" +msgstr "プレイリストファイル" + +msgid "JSON file" +msgstr "JSON ファイル" + +msgid "ListenBrainz" +msgstr "ListenBrainz" + +msgid "Last.fm" +msgstr "Last.fm" + +msgid "Choose file…" +msgstr "ファイルを選択…" + +msgid "No file selected" +msgstr "ファイルが選択されていません" + +msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog." +msgstr "読み込むのはトラック一覧だけです。参照されている音声ファイルを開いたり、コピーしたり、ローカルライブラリに追加することはありません。各項目は Qobuz のカタログと照合されます。" + +msgid "JSON is read best-effort — check the track count below before importing." +msgstr "JSON はベストエフォートで読み込まれます。インポート前にトラック数を確認してください。" + +msgid "Could not read the file." +msgstr "ファイルを読み込めませんでした。" + +msgid "No tracks found in the file." +msgstr "ファイルにトラックが見つかりませんでした。" + +msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON." +msgstr "ファイル形式を認識できません。XSPF、PLS、M3U/M3U8、JSON を使用してください。" + +msgid "This looks like a live-stream manifest, not a playlist." +msgstr "これはプレイリストではなくライブ配信のマニフェストのようです。" + +msgid "That file is too large to import." +msgstr "このファイルは大きすぎてインポートできません。" + +msgid "Could not recognize a track list in this JSON." +msgstr "この JSON からトラック一覧を認識できませんでした。" + +msgid "Username or profile URL" +msgstr "ユーザー名またはプロフィール URL" + +msgid "Username or playlist URL" +msgstr "ユーザー名またはプレイリスト URL" + +msgid "Play your library" +msgstr "ライブラリを再生" + +msgid "Play your mix" +msgstr "ミックスを再生" + +msgid "Play your recommendations" +msgstr "おすすめを再生" + +msgid "Station" +msgstr "ステーション" + +msgid "No playlists found for {}." +msgstr "{} のプレイリストが見つかりませんでした。" + +msgid "Enter a public username." +msgstr "公開ユーザー名を入力してください。" + +msgid "Duplicates: {}" +msgstr "重複: {}" + +msgid "Media" +msgstr "メディア" + +msgid "Open audio CD" +msgstr "オーディオCDを開く" + +msgid "Audio CD" +msgstr "オーディオCD" + +msgid "No optical drive found." +msgstr "光学ドライブが見つかりません。" + +msgid "No disc in the drive." +msgstr "ドライブにディスクがありません。" + +msgid "That disc has no audio tracks." +msgstr "このディスクにはオーディオトラックがありません。" + +msgid "Could not read the disc." +msgstr "ディスクを読み取れませんでした。" + +msgid "Track {}" +msgstr "トラック {}" + +msgid "Open SACD image…" +msgstr "SACDイメージを開く…" + +msgid "Choose a disc image" +msgstr "ディスクイメージを選択" + +msgid "That image has no stereo audio area." +msgstr "このイメージにはステレオ領域がありません。" + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "このディスクはDST圧縮のため、QBZではまだ再生できません。" + +msgid "That file is not a disc image." +msgstr "そのファイルはディスクイメージではありません。" + +msgid "That file is not a SACD image." +msgstr "そのファイルはSACDイメージではありません。" + +msgid "That SACD image is damaged or unsupported." +msgstr "そのSACDイメージは破損しているか、サポートされていません。" + +msgid "SACD" +msgstr "SACD" + +msgid "Library Explorer" +msgstr "ライブラリエクスプローラー" + +msgid "Genres" +msgstr "ジャンル" + +msgid "All genres" +msgstr "すべてのジャンル" + +msgid "All albums" +msgstr "すべてのアルバム" + +msgid "No albums match these filters" +msgstr "これらのフィルターに一致するアルバムはありません" + +msgid "Try a different genre, artist, album, source or quality." +msgstr "別のジャンル、アーティスト、アルバム、ソース、または音質をお試しください。" + +msgid "Favorites only" +msgstr "お気に入りのみ" + +msgid "Where should the ripped album go?" +msgstr "取り込んだアルバムの保存先は?" + +msgid "Ripping" +msgstr "取り込み中" + +msgid "Nothing to rip." +msgstr "取り込むものがありません。" + +msgid "Ripped {} track to your library" +msgid_plural "Ripped {} tracks to your library" +msgstr[0] "{} 曲をライブラリに取り込みました" + +# Discs: rip wizard, metadata lookup and the disc tooltips (2026-08-21) + +msgid "Play a folder, CD or disc image without adding it to your library" +msgstr "フォルダー・CD・ディスクイメージを、ライブラリに追加せずに再生します" + +msgid "Reading the disc…" +msgstr "ディスクを読み取り中…" + +msgid "Re-scan your folders and media servers for new music" +msgstr "フォルダーとメディアサーバーを再スキャンして新しい音楽を探します" + +msgid "Manage the folders your library is built from" +msgstr "ライブラリのもとになるフォルダーを管理します" + +msgid "Play the whole disc from the first track" +msgstr "1曲目からディスク全体を再生します" + +msgid "Play it in a shuffled order" +msgstr "シャッフル再生します" + +msgid "Look this disc up and correct its details" +msgstr "このディスクを検索して情報を修正します" + +msgid "Copy this CD to your computer as FLAC files" +msgstr "このCDをFLACファイルとしてパソコンにコピーします" + +msgid "Close this disc and go back to your library" +msgstr "このディスクを閉じてライブラリに戻ります" + +msgid "Correct disc details" +msgstr "ディスク情報を修正" + +msgid "SACD image" +msgstr "SACDイメージ" + +msgid "Close without changing anything" +msgstr "何も変更せずに閉じます" + +msgid "Community database. Knows discs by their table of contents." +msgstr "コミュニティのデータベース。ディスクを目次から識別します。" + +msgid "Collector database. Stronger on pressings, editions and catalogue numbers." +msgstr "コレクター向けデータベース。プレス・エディション・カタログ番号に強いです。" + +msgid "Search this provider" +msgstr "この提供元で検索します" + +msgid "The provider is rate-limiting right now. Try again in a moment." +msgstr "現在、提供元がリクエストを制限しています。少し待ってから再試行してください。" + +msgid "No releases matched. Try fewer words, or the other provider." +msgstr "一致するリリースがありません。語数を減らすか、別の提供元を試してください。" + +msgid "Search for the release this disc actually is." +msgstr "このディスクが実際にどのリリースなのかを検索します。" + +msgid "Remove my correction" +msgstr "自分の修正を削除" + +msgid "Forget the saved details for this disc and look it up fresh next time" +msgstr "このディスクの保存済み情報を破棄し、次回あらためて検索します" + +msgid "Applying..." +msgstr "適用中..." + +msgid "Use these details" +msgstr "この情報を使う" + +msgid "Rename this disc and remember it for next time" +msgstr "このディスクの名前を変更し、次回のために記憶します" + +msgid "Rip this CD" +msgstr "このCDを取り込む" + +msgid "What will happen" +msgstr "これから行うこと" + +msgid "Track names" +msgstr "トラック名" + +msgid "Your library" +msgstr "ライブラリ" + +msgid "Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags." +msgstr "ディスクの各トラックを読み取り、FLACファイルとして書き出します。アルバムごとに1フォルダー、トラックごとに1ファイル、タグ付きです。" + +msgid "Format: FLAC (lossless) — the only format QBZ writes" +msgstr "形式: FLAC(ロスレス)— QBZが書き出せる唯一の形式です" + +msgid "Where the album goes" +msgstr "アルバムの保存先" + +msgid "Choose folder…" +msgstr "フォルダーを選択…" + +msgid "Change…" +msgstr "変更…" + +msgid "Pick the folder the album folder will be created in" +msgstr "アルバムのフォルダーを作成する場所を選びます" + +msgid "Nothing chosen yet" +msgstr "まだ選択されていません" + +msgid "This folder is already part of your Local Library." +msgstr "このフォルダーはすでにローカルライブラリの一部です。" + +msgid "This folder is outside your Local Library, so the album will not appear there on its own." +msgstr "このフォルダーはローカルライブラリの外にあるため、アルバムは自動では表示されません。" + +msgid "Choose a destination first and this step will know what to offer." +msgstr "先に保存先を選ぶと、このステップで何を提案できるかが決まります。" + +msgid "Re-scan that folder when the rip finishes" +msgstr "取り込みが終わったら、そのフォルダーを再スキャンします" + +msgid "Only that folder, not your whole library." +msgstr "そのフォルダーだけで、ライブラリ全体ではありません。" + +msgid "Do nothing" +msgstr "何もしない" + +msgid "The album appears the next time you scan." +msgstr "次回スキャンしたときにアルバムが表示されます。" + +msgid "Add this folder to Local Library and scan it" +msgstr "このフォルダーをローカルライブラリに追加してスキャンします" + +msgid "Future rips into the same folder will be picked up too." +msgstr "同じフォルダーへの今後の取り込みも検出されます。" + +msgid "The files are written; the library will not know about them." +msgstr "ファイルは書き出されますが、ライブラリはそれを認識しません。" + +msgid "A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool." +msgstr "ライブラリにCDをすばやく取り込むための、シンプルな取り込み機能です。AccurateRipによる検証や高度な設定が必要な場合は、専用ソフトをお使いください。" + +msgid "Close without ripping anything" +msgstr "何も取り込まずに閉じます" + +msgid "Go back a step" +msgstr "1つ前の手順に戻ります" + +msgid "Start ripping" +msgstr "取り込みを開始" + +msgid "Go to the next step" +msgstr "次の手順へ進みます" + +msgid "Read the disc and write the FLAC files" +msgstr "ディスクを読み取り、FLACファイルを書き出します" + +msgid "Choose a destination folder first" +msgstr "先に保存先フォルダーを選んでください" + +msgid "No disc is open." +msgstr "ディスクが開かれていません。" + +msgid "Couldn't load that release." +msgstr "そのリリースを読み込めませんでした。" + +msgid "Disc details updated" +msgstr "ディスク情報を更新しました" + +msgid "Correction removed. Re-open the disc to look it up again." +msgstr "修正を削除しました。もう一度検索するには、ディスクを開き直してください。" + +msgid "Only a physical CD can be ripped." +msgstr "取り込めるのは物理CDだけです。" + +msgid "Choose where the album should go first." +msgstr "先にアルバムの保存先を選んでください。" + +msgid "Couldn't add that folder to the library" +msgstr "そのフォルダーをライブラリに追加できませんでした" + +# Rip progress panel, cancel and partial rips (2026-08-21) + +msgid "Writing FLAC files to" +msgstr "FLACファイルの書き出し先:" + +msgid "Hide this panel — the rip keeps going" +msgstr "このパネルを閉じます(取り込みは続きます)" + +msgid "Ripping — don't eject the disc until this finishes." +msgstr "取り込み中です。完了するまでディスクを取り出さないでください。" + +msgid "Finished" +msgstr "完了" + +msgid "Files already written stay on disk. Delete them yourself if you don't want them." +msgstr "すでに書き出されたファイルはディスクに残ります。不要な場合はご自身で削除してください。" + +msgid "Stop ripping" +msgstr "取り込みを停止" + +msgid "Stopping…" +msgstr "停止中…" + +msgid "Stop after the current track. Nothing is deleted." +msgstr "現在のトラックの後で停止します。何も削除されません。" + +msgid "Rip stopped. The files already written are still there." +msgstr "取り込みを停止しました。すでに書き出されたファイルはそのまま残っています。" + +msgid "See what the rip is doing, or stop it" +msgstr "取り込みの状況を見る、または停止します" + +msgid "Pick at least one track to rip." +msgstr "取り込むトラックを1つ以上選んでください。" + +# Ephemeral pane: per-disc blocks and the track filter (2026-08-21) + +msgid "Disc {}" +msgstr "ディスク {}" + +msgid "Filter tracks" +msgstr "トラックを絞り込む" + +msgid "No track matches that." +msgstr "一致するトラックがありません。" + +msgid "NAVIGATION" +msgstr "ナビゲーション" + +msgid "SEARCH" +msgstr "検索" + +msgid "PLAYER & VISUALS" +msgstr "プレーヤーと表示" + +msgid "WINDOW" +msgstr "ウィンドウ" + +msgid "SYSTEM INTEGRATION" +msgstr "システム連携" + +msgid "The typeface used across the app. System follows your operating system; the other choices are bundled with QBZ (requires restart)" +msgstr "アプリ全体で使う書体です。「システム」は OS のフォントを使用し、その他の選択肢は QBZ に同梱されています(再起動が必要)" + +msgid "Library folders" +msgstr "ライブラリフォルダー" + +msgid "{} folders" +msgstr "{} 個のフォルダー" + +msgid "No Qobuz account? You can still use QBZ as a player for your own music library" +msgstr "Qobuz アカウントがなくても、QBZ を自分の音楽ライブラリのプレーヤーとして使えます" + +msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working." +msgstr "Qobuz アカウントなしで QBZ を使っています。ローカルライブラリとダウンロードはそのまま使えます。" + +msgid "Continuous playback" +msgstr "連続再生" + +msgid "Open" +msgstr "開く" + +msgid "Folder view" +msgstr "フォルダー表示" + +msgid "Open media" +msgstr "メディアを開く" + +msgid "Open folder…" +msgstr "フォルダーを開く…" + +# ローカルライブラリのタブ順と未ログイン時の開始先 (2026-08-24) +msgid "LIBRARY TABS" +msgstr "ライブラリタブ" + +msgid "Tab order and default" +msgstr "タブの順序とデフォルト" + +msgid "Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session." +msgstr "ローカルライブラリのタブ順を選択します。Qobuz セッションなしで QBZ を起動した場合も、先頭のタブがデフォルトで開きます。" + +msgid "Local Library tabs" +msgstr "ローカルライブラリのタブ" + +msgid "Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session." +msgstr "ローカルライブラリのタブを並べ替えます。先頭のタブがデフォルトで開き、Qobuz セッションなしで起動したときに表示されます。" + +msgid "Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu" +msgstr "サイドバーまたはタイトルバーのセクションをクリックすると、メニューだけでなく、選択したローカルライブラリのデフォルトを含む先頭のタブが開きます" + +# ローカルライブラリのジャンルフィルター位置 (2026-08-24) +msgid "GENRES VIEW" +msgstr "ジャンル表示" + +msgid "Genre filters position" +msgstr "ジャンルフィルターの位置" + +msgid "Place the chained genre filters above, beside or below the album results." +msgstr "連動するジャンルフィルターをアルバム結果の上、横、または下に配置します。" + +msgid "Top" +msgstr "上" + +msgid "Bottom" +msgstr "下" + +msgid "Album metadata saved." +msgstr "アルバムのメタデータを保存しました。" + +msgid "Album title is required." +msgstr "アルバムタイトルは必須です。" + +msgid "Apply to form" +msgstr "フォームに適用" + +msgid "CUE-based albums can only be edited with a sidecar." +msgstr "CUE ベースのアルバムはサイドカーファイルでのみ編集できます。" + +msgid "CUE-based albums use sidecar metadata." +msgstr "CUE ベースのアルバムはサイドカーメタデータを使用します。" + +msgid "Canonical" +msgstr "正規" + +msgid "Close without saving" +msgstr "保存せずに閉じる" + +msgid "Couldn't inspect album metadata." +msgstr "アルバムのメタデータを検査できませんでした。" + +msgid "Couldn't load that metadata result." +msgstr "そのメタデータ結果を読み込めませんでした。" + +msgid "Couldn't open the metadata source." +msgstr "メタデータの情報源を開けませんでした。" + +msgid "Detected" +msgstr "検出済み" + +msgid "Disc number" +msgstr "ディスク番号" + +msgid "Editing the selected physical version" +msgstr "選択した物理バージョンを編集中" + +msgid "Enter an album title or artist to search." +msgstr "検索するアルバムタイトルまたはアーティストを入力してください。" + +msgid "Every track needs a title." +msgstr "すべてのトラックにタイトルが必要です。" + +msgid "File names are shown for reference and cannot be changed here." +msgstr "ファイル名は参照用に表示され、ここでは変更できません。" + +msgid "Find metadata" +msgstr "メタデータを検索" + +msgid "ID3 writing" +msgstr "ID3 書き込み" + +msgid "Metadata editing is available for local file versions only." +msgstr "メタデータを編集できるのはローカルファイル版のみです。" + +msgid "Metadata search failed." +msgstr "メタデータの検索に失敗しました。" + +msgid "No local album version is selected." +msgstr "ローカルのアルバムバージョンが選択されていません。" + +msgid "None" +msgstr "なし" + +msgid "One or more audio files are unavailable." +msgstr "1 つ以上のオーディオファイルを利用できません。" + +msgid "Open source" +msgstr "情報源を開く" + +msgid "QBZ will preflight every file, update its canonical tag layer, then read it back to verify the change. Keep a backup if these files cannot be replaced." +msgstr "QBZ はすべてのファイルを事前確認し、正規タグレイヤーを更新してから再読込して変更を検証します。置き換えられないファイルの場合はバックアップを保管してください。" + +msgid "Save changes to" +msgstr "変更の保存先" + +msgid "Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled." +msgstr "一部のファイルには競合するタグレイヤーがあります。同期を有効にしない限り、正規レイヤーが優先されます。" + +msgid "Synchronize compatible tag layers" +msgstr "互換性のあるタグレイヤーを同期" + +msgid "Tag layers" +msgstr "タグレイヤー" + +msgid "The canonical tag is not writable for every file." +msgstr "すべてのファイルで正規タグを書き込めるわけではありません。" + +msgid "The metadata editor is no longer open." +msgstr "メタデータエディターはすでに閉じています。" + +msgid "The metadata form could not be read." +msgstr "メタデータフォームを読み取れませんでした。" + +msgid "The track list changed; reopen the editor." +msgstr "トラックリストが変更されました。エディターを開き直してください。" + +msgid "The track list contains a duplicate row." +msgstr "トラックリストに重複した行があります。" + +msgid "Track number" +msgstr "トラック番号" + +msgid "Unknown metadata persistence mode." +msgstr "不明なメタデータ保存モードです。" + +msgid "Write and verify" +msgstr "書き込んで検証" + +msgid "Write metadata into audio files?" +msgstr "メタデータをオーディオファイルに書き込みますか?" + +msgid "Year must be a valid number." +msgstr "年には有効な数値を指定してください。" + +msgid "{} must be a positive number." +msgstr "{} は正の数である必要があります。" + +msgid "The ephemeral session changed; reopen the metadata editor." +msgstr "一時セッションが変更されました。メタデータエディターを開き直してください。" + +msgid "Album artists" +msgstr "アルバムアーティスト" + +msgid "Album artists (ordered; separate with semicolons)" +msgstr "アルバムアーティスト(順序付き、セミコロン区切り)" + +msgid "Artist credit" +msgstr "アーティストクレジット" + +msgid "Artists (ordered; semicolon separated)" +msgstr "アーティスト(順序付き、セミコロン区切り)" + +msgid "Artwork is larger than 25 MiB." +msgstr "アートワークが25 MiBを超えています。" + +msgid "Artwork search failed." +msgstr "アートワークの検索に失敗しました。" + +msgid "Artwork source did not provide a secure URL." +msgstr "アートワークの提供元から安全なURLが返されませんでした。" + +msgid "Choose album artwork" +msgstr "アルバムアートワークを選択" + +msgid "Choose another file" +msgstr "別のファイルを選択" + +msgid "Choose file" +msgstr "ファイルを選択" + +msgid "Compilation / Various Artists" +msgstr "コンピレーション / Various Artists" + +msgid "Composers" +msgstr "作曲者" + +msgid "Couldn't download that artwork." +msgstr "そのアートワークをダウンロードできませんでした。" + +msgid "Couldn't stage artwork" +msgstr "アートワークを準備できませんでした" + +msgid "Current artwork" +msgstr "現在のアートワーク" + +msgid "Discogs release ID" +msgstr "DiscogsリリースID" + +msgid "Enter an album title to find artwork." +msgstr "アートワークを検索するにはアルバム名を入力してください。" + +msgid "File" +msgstr "ファイル" + +msgid "Find artwork" +msgstr "アートワークを検索" + +msgid "Local file" +msgstr "ローカルファイル" + +msgid "MusicBrainz album artist IDs" +msgstr "MusicBrainzアルバムアーティストID" + +msgid "MusicBrainz artist IDs" +msgstr "MusicBrainzアーティストID" + +msgid "MusicBrainz recording ID" +msgstr "MusicBrainz録音ID" + +msgid "MusicBrainz release ID" +msgstr "MusicBrainzリリースID" + +msgid "MusicBrainz release-group ID" +msgstr "MusicBrainzリリースグループID" + +msgid "MusicBrainz track ID" +msgstr "MusicBrainzトラックID" + +msgid "Performers" +msgstr "演奏者" + +msgid "Revert selection" +msgstr "選択を元に戻す" + +msgid "Selected track details" +msgstr "選択したトラックの詳細" + +msgid "That artwork result is no longer available." +msgstr "そのアートワーク候補は利用できなくなりました。" + +msgid "The selected artwork changed; choose it again." +msgstr "選択したアートワークが変更されました。もう一度選択してください。" + +msgid "The selected artwork has unsupported dimensions." +msgstr "選択したアートワークの寸法はサポートされていません。" + +msgid "The selected file is not a supported image." +msgstr "選択したファイルはサポートされている画像ではありません。" + +msgid "Goniometer" +msgstr "ゴニオメーター" + +msgid "Oscilloscope" +msgstr "オシロスコープ" + +msgid "Track waveform" +msgstr "トラック波形" + +msgid "Show the full-track waveform in the player seek bar." +msgstr "プレーヤーのシークバーにトラック全体の波形を表示します。" + +msgid "Compact header" +msgstr "コンパクトヘッダー" + +msgid "Show less data in album headers." +msgstr "アルバムヘッダーに表示する情報を減らします。" + +msgid "Show full album header" +msgstr "アルバムヘッダーをすべて表示" + +msgid "Show compact album header" +msgstr "アルバムヘッダーをコンパクトに表示" + +msgid "Immediate scrobbling" +msgstr "即時スクロブル" + +msgid "Send scrobbles immediately while manual offline mode is enabled." +msgstr "手動オフラインモードが有効な間も、スクロブルをすぐに送信します。" + +msgid "Accumulated scrobbling" +msgstr "蓄積スクロブル" + +msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old." +msgstr "ネットワークが利用できない間はスクロブルをキューに保存し、復旧後に送信します。Last.fm が受け付けるのは2週間以内のスクロブルです。" + +msgid "Scrobble while logged out" +msgstr "ログアウト中もスクロブル" + +msgid "Continue sending plays to connected scrobblers without a Qobuz session." +msgstr "Qobuzセッションがなくても、接続済みのスクロブルサービスに再生履歴を送信します。" + +msgid "Focus on playing" +msgstr "再生中の曲を追従" + +msgid "Go to top" +msgstr "一番上へ" + +msgid "Quick view" +msgstr "クイックビュー" + +msgid "Could not load album" +msgstr "アルバムを読み込めませんでした" + +msgid "This album has no tracks." +msgstr "このアルバムには曲がありません。" + +msgid "Reactive Rings" +msgstr "リアクティブリング" + +msgid "Windows — yes, but read this first" +msgstr "Windows — はい、ただしまずこれをお読みください" + +msgid "Don't show this again" +msgstr "今後表示しない" + +msgid "I understand" +msgstr "理解しました" +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker + +msgid "Volume Up" +msgstr "音量を上げる" + +msgid "Volume Down" +msgstr "音量を下げる" + +msgid "Mute / Unmute" +msgstr "ミュート / 解除" + +msgid "Toggle Shuffle" +msgstr "シャッフル切り替え" + +msgid "Cycle Repeat" +msgstr "リピート切り替え" + +msgid "Like Track" +msgstr "お気に入りに追加" + +msgid "Seek Forward" +msgstr "早送り" + +msgid "Seek Back" +msgstr "巻き戻し" + +msgid "Keymap" +msgstr "キーマップ" + +msgid "Vim" +msgstr "Vim" + +# Local Library QoL: Explorer, scan progress, and per-track metadata editor +msgid "All years" +msgstr "すべての年" + +msgid "Explorer columns" +msgstr "エクスプローラーの列" + +msgid "Genre and year" +msgstr "ジャンルと年" + +msgid "Years" +msgstr "年" + +msgid "Edit track metadata" +msgstr "トラックのメタデータを編集" + +msgid "Building library index" +msgstr "ライブラリインデックスを作成中" + +msgid "Local folders" +msgstr "ローカルフォルダー" + +msgid "Next track" +msgstr "次のトラック" + +msgid "Overall" +msgstr "全体" + +msgid "Previous track" +msgstr "前のトラック" + +msgid "Scanning library" +msgstr "ライブラリをスキャン中" + +msgid "The track is no longer in the library." +msgstr "このトラックはライブラリに存在しません。" + +msgid "Updating library index" +msgstr "ライブラリインデックスを更新中" + +msgid "Edit album" +msgstr "アルバムを編集" + +msgid "Editing a local metadata sidecar" +msgstr "ローカルのメタデータサイドカーを編集中" + +msgid "Media-server metadata is stored in a local sidecar." +msgstr "メディアサーバーのメタデータはローカルのサイドカーファイルに保存されます。" + +msgid "Media-server metadata can only be edited with a sidecar." +msgstr "メディアサーバーのメタデータはサイドカーファイルでのみ編集できます。" + +msgid "Network file" +msgstr "ネットワークファイル" + +msgid "Offline download" +msgstr "オフラインダウンロード" + +msgid "File path" +msgstr "ファイルパス" + +msgid "Containing folder" +msgstr "保存フォルダー" + +msgid "Media item ID" +msgstr "メディア項目 ID" + +msgid "Album ID" +msgstr "アルバム ID" + +msgid "Server" +msgstr "サーバー" + +msgid "Duration" +msgstr "再生時間" + +msgid "Channels" +msgstr "チャンネル" + +msgid "File size" +msgstr "ファイルサイズ" + +msgid "Location" +msgstr "場所" + +msgid "The media item is no longer available." +msgstr "メディア項目は利用できなくなりました。" + +msgid "The local album folder is not currently available." +msgstr "ローカルのアルバムフォルダーは現在利用できません。" + +msgid "Everything is already available offline" +msgstr "すべてオフラインで利用できます" + +msgid "Some tracks are already available offline" +msgstr "一部のトラックはすでにオフラインで利用できます" + +msgid "{} of {} tracks in “{}” are already available offline." +msgstr "{} / {} 曲(“{}”)はすでにオフラインで利用できます。" + +msgid "Download the entire playlist again" +msgstr "プレイリスト全体を再ダウンロード" + +msgid "Download the entire album again" +msgstr "アルバム全体を再ダウンロード" + +msgid "Re-downloads every track and can repair corrupt offline copies." +msgstr "全トラックを再ダウンロードし、破損したオフラインコピーを修復できます。" + +msgid "Only missing tracks" +msgstr "不足しているトラックのみ" + +msgid "Keeps existing offline copies and downloads tracks that are not available yet." +msgstr "既存のオフラインコピーを保持し、まだ利用できないトラックだけをダウンロードします。" + +msgid "PRIVACY" +msgstr "プライバシー" + +msgid "Listening history" +msgstr "再生履歴" + +msgid "Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler." +msgstr "再生した内容(トラック、ソース、どれだけ聴いたか、いつ聴いたか)をローカルに記録します。この記録はアカウントごとにこのマシンに保存され、スクロブラーを有効にしない限り外部には送信されません。" + +msgid "Clear listening history" +msgstr "再生履歴を消去" + +msgid "Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected." +msgstr "このアカウントについてこのマシンに記録されたすべての再生を削除します。送信済みのスクロブルには影響しません。" + +msgid "Listening history cleared." +msgstr "再生履歴を消去しました。" + +msgid "Couldn't clear listening history." +msgstr "再生履歴を消去できませんでした。" + +msgid "Checking ALSA mixer controls…" +msgstr "ALSA ミキサーコントロールを確認しています…" + +msgid "This device exposes multiple playback controls. Choose which one QBZ should use." +msgstr "このデバイスには複数の再生コントロールがあります。QBZ が使用するものを選択してください。" + +msgid "The saved mixer control is no longer available. Choose another control." +msgstr "保存されたミキサーコントロールは利用できなくなりました。別のコントロールを選択してください。" + +msgid "QBZ does not have permission to read this device's ALSA mixer." +msgstr "QBZ にはこのデバイスの ALSA ミキサーを読み取る権限がありません。" + +msgid "The ALSA mixer is busy. Close the app using it and try again." +msgstr "ALSA ミキサーは使用中です。使用しているアプリを閉じて、もう一度お試しください。" + +msgid "The selected ALSA device is no longer available." +msgstr "選択した ALSA デバイスは利用できなくなりました。" + +msgid "This ALSA device does not expose a mixer." +msgstr "この ALSA デバイスにはミキサーがありません。" + +msgid "QBZ could not write the selected ALSA mixer control. Direct playback remains fixed at 100%." +msgstr "選択した ALSA ミキサーコントロールに書き込めませんでした。ダイレクト再生は 100% に固定されます。" + +msgid "Selected mixer control" +msgstr "選択中のミキサーコントロール" + +msgid "Choose mixer control" +msgstr "ミキサーコントロールを選択" + +msgid "Change mixer control" +msgstr "ミキサーコントロールを変更" + +msgid "Qobuz Connect could not shut down safely. Quit and reopen QBZ, then try logging out again." +msgstr "Qobuz Connect を安全に終了できませんでした。QBZ を終了して再度開き、もう一度ログアウトしてください。" + +msgid "Show filter panels" +msgstr "フィルターパネルを表示" + +msgid "Hide filter panels" +msgstr "フィルターパネルを非表示" + +msgid "Already in" +msgstr "追加済み" + +msgid "Playlist index updating..." +msgstr "プレイリストの索引を更新中..." + +msgid "{} of {}" +msgstr "{} / {}" + +msgid "Last playlist" +msgstr "前回のプレイリスト" + +msgid "OK" +msgstr "OK" + +msgid "Sponsors" +msgstr "スポンサー" + +msgid "And all those anonymous sponsors..." +msgstr "そして、すべての匿名スポンサーの皆さん…" + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect はローカルトラックを解決できません。{} 曲をスキップし、キューに追加しませんでした。再生するには Qobuz Connect をオフにしてください。" + +msgid "Qobuz Connect device" +msgstr "Qobuz Connect デバイス" + +msgid "Continue playback on %1" +msgstr "%1 で再生を続ける" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Qobuz Connect のキューを使用し、そのデバイスをアクティブのままにします。" + +msgid "Continue playback on this device" +msgstr "このデバイスで再生を続ける" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Qobuz Connect のキューを使用し、ローカルキューを置き換えます。" + +msgid "Continue this device's current playback" +msgstr "このデバイスの現在の再生を続ける" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Qobuz Connect のキューと再生状態を、このデバイスの現在のキューと再生位置に置き換えます。" + +msgid "Cancel Qobuz Connect" +msgstr "Qobuz Connect をキャンセル" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "両方のキューを変更せず、現在のローカル再生を続けます。" + +msgid "Choose how to continue" +msgstr "続行方法を選択" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "このデバイスと別の Qobuz Connect レンダラーの両方で再生中です。続行するキューとデバイスを選択してください。" + +msgid "Ask every time" +msgstr "毎回確認する" + +msgid "Continue on the active renderer" +msgstr "アクティブなレンダラーで再生を続ける" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Qobuz Connect のキューを使用してこのデバイスで続ける" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "ローカル再生を続け、Qobuz Connect のキューを置き換える" + +msgid "When playback conflicts" +msgstr "再生が競合した場合" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "毎回確認するか、続行するキューとデバイスを自動的に選択します。" + +msgid "Purchased" +msgstr "購入日" + +msgid "Sort: Release date" +msgstr "並び替え: 発売日" + +msgid "Until the purchased file is downloaded, Play streams the best quality Qobuz offers for this album. Once it is on disk, Play uses your copy." +msgstr "購入したファイルをダウンロードするまでは、このアルバムに Qobuz が提供する最高品質でストリーミング再生します。ディスクに保存されると、再生はあなたのコピーを使います。" + +msgid "Open in Local Library" +msgstr "ローカルライブラリで開く" + +msgid "Import / Export" +msgstr "インポート / エクスポート" + +msgid "This bundle holds the app's own settings: audio, playback, appearance, integrations. It does not contain your Qobuz favorites, playlists, blacklist or purchases." +msgstr "このバンドルにはアプリ自体の設定(オーディオ、再生、外観、連携)が含まれます。Qobuz のお気に入り、プレイリスト、ブラックリスト、購入は含まれません。" + +msgid "BLACKLIST" +msgstr "ブラックリスト" + +msgid "Export blacklist…" +msgstr "ブラックリストをエクスポート…" + +msgid "Save your blocked artists and albums as a file you can hand to another QBZ user." +msgstr "ブロック中のアーティストとアルバムを、他の QBZ ユーザーに渡せるファイルとして保存します。" + +msgid "Import blacklist…" +msgstr "ブラックリストをインポート…" + +msgid "Add blocked artists and albums from a file. Nothing already on your blacklist is removed." +msgstr "ファイルからブロック中のアーティストとアルバムを追加します。既にブラックリストにあるものは削除されません。" + +msgid "Import…" +msgstr "インポート…" + +msgid "Import failed." +msgstr "インポートに失敗しました。" + +msgid "Not a QBZ blacklist file." +msgstr "QBZ のブラックリストファイルではありません。" + +msgid "Choose a blacklist file" +msgstr "ブラックリストファイルを選択" + +msgid "Imported {} artists and {} albums ({} already blocked)." +msgstr "アーティスト {} 件とアルバム {} 件をインポートしました({} 件はブロック済み)。" + +msgid "Member" +msgstr "メンバー" + +msgid "QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™." +msgstr "QBZ には Qobuz™ アカウントが必要です。有効なサブスクリプションがない場合、再生は 30 秒のプレビューに制限されますが、お気に入り、プレイリスト、購入は引き続き利用できます。認証情報は Qobuz™ に直接送信されます。" + +msgid "ACCOUNT MIGRATION" +msgstr "アカウント移行" + +msgid "Moves favorites, playlists and followed playlists from one Qobuz account into another, adding only what is missing and deleting nothing on either side. It does not move subscriptions or purchases: those stay with the Qobuz account that holds them." +msgstr "お気に入り、プレイリスト、フォロー中のプレイリストを Qobuz アカウント間で移行します。足りないものだけを追加し、どちら側からも何も削除しません。サブスクリプションと購入は移行されません。それらは所有する Qobuz アカウントに残ります。" + +msgid "Create migration snapshot" +msgstr "移行用スナップショットを作成" + +msgid "Sign in with the account you are moving FROM, then save its favorites and playlists to a file in its QBZ profile." +msgstr "移行元のアカウントでサインインし、そのお気に入りとプレイリストを QBZ プロファイル内のファイルに保存します。" + +msgid "Create…" +msgstr "作成…" + +msgid "Migrate into this account" +msgstr "このアカウントに移行" + +msgid "Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing." +msgstr "移行先のアカウントでサインインし、下のスナップショットを選択します。繰り返しても安全です。2 回目の実行では何も追加されません。" + +msgid "No snapshots yet. Sign in with the old account and create one first." +msgstr "スナップショットはまだありません。まず古いアカウントでサインインして作成してください。" + +msgid "{} favorites · {} playlists · {} followed" +msgstr "お気に入り {} · プレイリスト {} · フォロー {}" + +msgid "this account" +msgstr "このアカウント" + +msgid "Migrate…" +msgstr "移行…" + +msgid "Account {} · {}" +msgstr "アカウント {} · {}" + +msgid "Reading favorites and playlists…" +msgstr "お気に入りとプレイリストを読み込み中…" + +msgid "Reading this account…" +msgstr "このアカウントを読み込み中…" + +msgid "Adding favorites…" +msgstr "お気に入りを追加中…" + +msgid "Adding playlists…" +msgstr "プレイリストを追加中…" + +msgid "Following playlists…" +msgstr "プレイリストをフォロー中…" + +msgid "{} / {} — {}" +msgstr "{} / {} — {}" + +msgid "Snapshot failed." +msgstr "スナップショットに失敗しました。" + +msgid "Migration failed." +msgstr "移行に失敗しました。" + +msgid "Saved {} favorites, {} playlists and {} followed playlists to {}" +msgstr "お気に入り {} 件、プレイリスト {} 件、フォロー中 {} 件を {} に保存しました" + +msgid "That snapshot was taken from this same account." +msgstr "そのスナップショットはこのアカウント自身のものです。" + +msgid "Nothing to migrate: everything is already in this account." +msgstr "移行するものはありません。すべてこのアカウントにあります。" + +msgid "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there." +msgstr "お気に入り {} 件、プレイリスト {} 件({} トラック)、フォロー {} 件を追加しました。{} 件は既にありました。" + +msgid "{} items failed; see the log." +msgstr "{} 件が失敗しました。ログを確認してください。" + +msgid "Also copy the local profile" +msgstr "ローカルプロファイルもコピー" + +msgid "Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer." +msgstr "このコンピューター上の古いアカウントの QBZ プロファイルにあるライブラリフォルダー、プレイリストのフォルダーと順序、ピン留め、ブラックリスト、設定。" + +msgid "Media server connections" +msgstr "メディアサーバー接続" + +msgid "Plex, Jellyfin and Subsonic settings, including their credentials." +msgstr "Plex、Jellyfin、Subsonic の設定(認証情報を含む)。" + +msgid "Scrobbler accounts" +msgstr "スクロブラーアカウント" + +msgid "Last.fm and ListenBrainz settings, including their credentials." +msgstr "Last.fm と ListenBrainz の設定(認証情報を含む)。" + +msgid "The listen log and the events behind offline recommendations." +msgstr "再生ログとオフラインおすすめの元になるイベント。" + +msgid "Local profile: {} rows copied; {} playlist rows had no match." +msgstr "ローカルプロファイル: {} 行をコピー。プレイリストの {} 行は対応がありませんでした。" + +msgid "The library already had folders: run a rescan to index the added ones." +msgstr "ライブラリには既にフォルダーがありました。追加分を索引化するには再スキャンしてください。" + +msgid "Restart QBZ to see the migrated local profile." +msgstr "移行したローカルプロファイルを表示するには QBZ を再起動してください。" + +msgid "The local profile could not be copied; see the log." +msgstr "ローカルプロファイルをコピーできませんでした。ログを確認してください。" +msgid "Delete…" +msgstr "削除…" + +msgid "Delete migration snapshot?" +msgstr "移行スナップショットを削除しますか?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "移行バンドルのみを削除します。以前のアカウントのQBZプロファイルとユーザーデータは保持されます。" + +msgid "Delete snapshot" +msgstr "スナップショットを削除" + +msgid "Migration snapshot deleted." +msgstr "移行スナップショットを削除しました。" + +msgid "Could not delete the migration snapshot." +msgstr "移行スナップショットを削除できませんでした。" + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} はライブラリを更新中です({} 件をスキャン済み)。完了してからもう一度同期してください。" + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "移行元プロファイルに Plex、Jellyfin、Subsonic の接続が保存されていなかったため、コピーできませんでした。" + +msgid "Media server connections copied: {} of {}." +msgstr "メディアサーバー接続のコピー:{} / {}。" + +msgid "Copying local profile…" +msgstr "ローカルプロファイルをコピーしています…" + +msgid "Refreshing migrated data…" +msgstr "移行したデータを更新しています…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "この処理は一度だけ行われます。完了するまで待ち、ウィンドウを閉じないでください。" + +msgid "You can close this panel; the migration will continue in the background." +msgstr "このパネルを閉じても、移行はバックグラウンドで続行されます。" + +msgid "From" +msgstr "移行元" + +msgid "To" +msgstr "移行先" + +msgid "Step {} of {}" +msgstr "ステップ {} / {}" + +msgid "Elapsed: {}" +msgstr "経過時間:{}" + +msgid "Remaining: {}" +msgstr "残り時間:{}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "保存済みの移行元プロファイルを確認し、コピーする項目を選択して、現在サインイン中の移行先アカウントを確認します。" + +msgid "Migrate account data" +msgstr "アカウントデータを移行" + +msgid "Start migration" +msgstr "移行を開始" + +msgid "What is a migration profile?" +msgstr "移行プロファイルとは?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "移行元アカウントから作成される一度限りのバンドルです。そのアカウントのQobuzのお気に入りとプレイリストを保存し、現在サインイン中のアカウントに追加できます。任意のローカルデータは、このコンピューター上の古いQBZプロファイルから直接コピーされます。バンドルを削除しても、古いプロファイルや各アカウントのデータは削除されません。" + +msgid "SAVED SOURCE PROFILES" +msgstr "保存済みの移行元プロファイル" + +msgid "No saved migration profiles are available." +msgstr "利用できる保存済み移行プロファイルはありません。" + +msgid "Selected" +msgstr "選択済み" + +msgid "Select" +msgstr "選択" + +msgid "MIGRATION DIRECTION" +msgstr "移行方向" + +msgid "Select a source profile above" +msgstr "上で移行元プロファイルを選択してください" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "サインイン中のQobuzアカウント · ID {}" + +msgid "WHAT TO COPY" +msgstr "コピーする項目" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "移行は追加のみです。どちらのアカウントからもお気に入りやプレイリストは削除されません。" + +msgid "That library folder is disabled." +msgstr "そのライブラリフォルダーは無効です。" + +msgid "That folder overlaps an existing library folder." +msgstr "そのフォルダーは既存のライブラリフォルダーと重複しています。" + +msgid "This purchase is not available for download." +msgstr "この購入作品はダウンロードできません。" + +msgid "Could not save playback preference." +msgstr "再生設定を保存できませんでした。" + +msgid "Qobuz · stream/offline copy" +msgstr "Qobuz · ストリーミング/オフラインコピー" + +msgid "Local purchase" +msgstr "ローカル購入作品" + +msgid "Play with" +msgstr "再生元" + +msgid "Download purchase" +msgstr "購入作品をダウンロード" + +msgid "1 track purchased" +msgstr "購入済み1曲" + +msgid "{} tracks purchased" +msgstr "購入済み{}曲" + +msgid "This copy cannot be continued right now." +msgstr "現在このコピーを続行できません。" + +msgid "Library folder refreshed." +msgstr "ライブラリフォルダーを更新しました。" + +msgid "Download another copy" +msgstr "別のコピーをダウンロード" + +msgid "Complete" +msgstr "完了" + +msgid "Partial" +msgstr "一部" + +msgid "Missing files" +msgstr "不足しているファイル" + +msgid "Changed files" +msgstr "変更されたファイル" + +msgid "Unreadable files" +msgstr "読み取れないファイル" + +msgid "Local purchase copies" +msgstr "購入済みローカルコピー" + +msgid "{} of {} tracks" +msgstr "{} / {} 曲" + +msgid "Continue copy" +msgstr "コピーを続行" + +msgid "Add to Local Library" +msgstr "ローカルライブラリに追加" + +msgid "In Local Library" +msgstr "ローカルライブラリ内" + +msgid "{} of {} tracks available in this folder." +msgstr "このフォルダーで {} / {} 曲を利用できます。" + +msgid "{} failed" +msgstr "{} 件失敗" + +msgid "{} cancelled" +msgstr "{} 件キャンセル" + +msgid "Download finished" +msgstr "ダウンロード完了" + +msgid "Shuffle: On" +msgstr "シャッフル:オン" + +msgid "Shuffle: Off" +msgstr "シャッフル:オフ" + +msgid "Repeat: Off" +msgstr "リピート:オフ" + +msgid "Repeat: All" +msgstr "リピート:すべて" + +msgid "Repeat: One" +msgstr "リピート:1曲" + +msgid "Show album artwork in Queue track list" +msgstr "キューのトラックリストにアルバムアートワークを表示" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "キューのトラック番号をアルバムカバーのサムネイルに置き換えます。デフォルトではオフです。" + +msgid "Show album artwork in Library track list" +msgstr "ライブラリのトラックリストにアルバムアートワークを表示" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "トラック番号とタイトルの間にアルバムカバーのサムネイルを表示します。デフォルトではオフです。" + +msgid "Play random" +msgstr "ランダム再生" + +msgid "Play selected" +msgstr "選択項目を再生" + +msgid "Cancel selection" +msgstr "選択を解除" + +msgid "QOBUZ LINKS" +msgstr "QOBUZ リンク" + +msgid "Open Qobuz links in QBZ" +msgstr "Qobuz のリンクを QBZ で開く" + +msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here." +msgstr "ブラウザーからの qobuzapp:// リンクを QBZ で処理します。オフの場合は公式の Qobuz アプリが処理します。QBZ 独自の qbz:// リンクは常にここで開きます。" + +msgid "Look for better replacement" +msgstr "より良い代替盤を探す" + +msgid "Remove all favorites from this release" +msgstr "このリリースのすべてのお気に入りを削除" + +msgid "Remove all favorites from this release?" +msgstr "このリリースのすべてのお気に入りを削除しますか?" + +msgid "Remove the album and every track from this release from your Library?" +msgstr "このリリースのアルバムとすべてのトラックをライブラリから削除しますか?" + +msgid "No replacement releases found" +msgstr "代替リリースが見つかりません" + +msgid "Choosing a result opens the release. Your existing favorites are not changed." +msgstr "結果を選ぶとリリースが開きます。現在のお気に入りは変更されません。" + +msgid "No favorites from this release to remove" +msgstr "このリリースには削除するお気に入りがありません" + +msgid "Favorites removed from this release" +msgstr "このリリースのお気に入りを削除しました" + +msgid "Could not remove favorites from this release" +msgstr "このリリースのお気に入りを削除できませんでした" + +msgid "Some favorites could not be removed" +msgstr "一部のお気に入りを削除できませんでした" + +msgid "Replace in Library" +msgstr "ライブラリ内で置き換え" + +msgid "Replacing favorite..." +msgstr "お気に入りを置き換えています..." + +msgid "The selected release replaces the unavailable album favorite." +msgstr "選択したリリースで、利用できないお気に入りのアルバムを置き換えます。" + +msgid "The matching track from the selected release replaces the unavailable favorite." +msgstr "選択したリリース内の一致する曲で、利用できないお気に入りを置き換えます。" + +msgid "Could not open the selected release" +msgstr "選択したリリースを開けませんでした" + +msgid "Could not find a matching track in this release" +msgstr "このリリース内に一致する曲が見つかりませんでした" + +msgid "Could not add the replacement favorite" +msgstr "置き換え用のお気に入りを追加できませんでした" + +msgid "Favorite replaced" +msgstr "お気に入りを置き換えました" + +msgid "The replacement was added, but the unavailable favorite could not be removed" +msgstr "置き換えを追加しましたが、利用できないお気に入りを削除できませんでした" + +msgid "Failed to send playback to the selected device" +msgstr "選択したデバイスに再生を転送できませんでした" + +msgid "Move up" +msgstr "上に移動" + +msgid "Move down" +msgstr "下に移動" + +msgid "Download" +msgstr "ダウンロード" + +msgid "Edit" +msgstr "編集" + +msgid "Grid" +msgstr "グリッド" + +msgid "List" +msgstr "リスト" + +msgid "Tree" +msgstr "ツリー" + +msgid "Forward" +msgstr "進む" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po new file mode 100644 index 000000000..72114d52e --- /dev/null +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -0,0 +1,6833 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: qbz-slint\n" +"PO-Revision-Date: 2026-09-05 19:52-0600\n" +"Last-Translator: QBZ contributors\n" +"Language-Team: QBZ nl translators\n" +"Language: nl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "System Colors" +msgstr "Systeemkleuren" + +msgid "Wallpaper Sync" +msgstr "Achtergrond-synchronisatie" + +msgid "Custom Image" +msgstr "Eigen afbeelding" + +msgid "Auto" +msgstr "Automatisch" + +msgid "Disabled" +msgstr "Uitgeschakeld" + +msgid "Replace current queue" +msgstr "Huidige wachtrij vervangen" + +msgid "Play next" +msgstr "Hierna afspelen" + +msgid "Add to queue" +msgstr "Aan wachtrij toevoegen" + +msgid "Remember last" +msgstr "Laatste onthouden" + +msgid "Album Reactive" +msgstr "Album-reactief" + +msgid "Static" +msgstr "Statisch" + +msgid "Coverflow" +msgstr "Coverflow" + +msgid "Spectrum" +msgstr "Spectrum" + +msgid "Lyrics" +msgstr "Songteksten" + +msgid "Queue" +msgstr "Wachtrij" + +msgid "Auto (opposite to window controls)" +msgstr "Automatisch (tegenover de vensterknoppen)" + +msgid "Left" +msgstr "Links" + +msgid "Right" +msgstr "Rechts" + +msgid "Rectangular" +msgstr "Rechthoekig" + +msgid "Full-height rounded" +msgstr "Volledige hoogte, afgerond" + +msgid "Circular" +msgstr "Rond" + +msgid "Square" +msgstr "Vierkant" + +msgid "Small" +msgstr "Klein" + +msgid "Normal" +msgstr "Normaal" + +msgid "Large" +msgstr "Groot" + +msgid "Default" +msgstr "Standaard" + +msgid "Monochrome" +msgstr "Monochroom" + +msgid "Custom" +msgstr "Aangepast" + +msgid "Remember last used" +msgstr "Laatst gebruikte onthouden" + +msgid "Micro" +msgstr "Micro" + +msgid "Compact" +msgstr "Compact" + +msgid "Artwork" +msgstr "Albumhoes" + +msgid "Home" +msgstr "Startpagina" + +msgid "Where you left off" +msgstr "Waar je gebleven was" + +msgid "Mono light" +msgstr "Mono licht" + +msgid "Mono dark" +msgstr "Mono donker" + +msgid "Color" +msgstr "Kleur" + +msgid "Vinyl" +msgstr "Vinyl" + +msgid "Visualizer" +msgstr "Visualizer" + +msgid "Neon Flow" +msgstr "Neon Flow" + +msgid "Tunnel" +msgstr "Tunnel" + +msgid "Comet" +msgstr "Komeet" + +msgid "Split: Lyrics" +msgstr "Gesplitst: songteksten" + +msgid "Split: Track Info" +msgstr "Gesplitst: nummerinformatie" + +msgid "Split: Suggestions" +msgstr "Gesplitst: suggesties" + +msgid "Split: Queue" +msgstr "Gesplitst: wachtrij" + +msgid "Full" +msgstr "Volledig" + +msgid "Lite" +msgstr "Licht" + +msgid "Off" +msgstr "Uit" + +msgid "versions" +msgstr "versies" + +msgid "Search tracks..." +msgstr "Nummers zoeken..." + +msgid "Title" +msgstr "Titel" + +msgid "Quality" +msgstr "Kwaliteit" + +msgid "Disc" +msgstr "Disc" + +msgid "Loading…" +msgstr "Laden…" + +msgid "Failed to load booklet" +msgstr "Boekje laden mislukt" + +msgid "Add cover" +msgstr "Hoes toevoegen" + +msgid "Change cover" +msgstr "Hoes wijzigen" + +msgid "Remove cover" +msgstr "Hoes verwijderen" + +msgid "Open in browser" +msgstr "Openen in browser" + +msgid "Save as…" +msgstr "Opslaan als…" + +msgid "Read more" +msgstr "Meer lezen" + +msgid "Loading album…" +msgstr "Album laden…" + +msgid "Select all" +msgstr "Alles selecteren" + +msgid "Add to playlist" +msgstr "Aan afspeellijst toevoegen" + +msgid "Add to Library" +msgstr "Aan bibliotheek toevoegen" + +msgid "Make available offline" +msgstr "Offline beschikbaar maken" + +msgid "Clear" +msgstr "Wissen" + +msgid "LABEL" +msgstr "Label" + +msgid "AWARDS" +msgstr "Prijzen" + +msgid "EXTERNAL LINKS" +msgstr "EXTERNE LINKS" + +msgid "About this album" +msgstr "Over dit album" + +msgid "Released by" +msgstr "Uitgebracht door" + +msgid "on" +msgstr "op" + +msgid "Credits" +msgstr "Credits" + +msgid "Review" +msgstr "Recensie" + +msgid "Failed to load album credits" +msgstr "Albumcredits laden mislukt" + +msgid "Loading credits..." +msgstr "Credits laden..." + +msgid "Loading track info..." +msgstr "Nummerinformatie laden..." + +msgid "Failed to load track info" +msgstr "Nummerinformatie laden mislukt" + +msgid "Label" +msgstr "Label" + +msgid "Newest" +msgstr "Nieuwste" + +msgid "Oldest" +msgstr "Oudste" + +msgid "A–Z" +msgstr "A–Z" + +msgid "Z–A" +msgstr "Z–A" + +msgid "Failed to load Library" +msgstr "Laden van bibliotheek mislukt" + +msgid "Retry" +msgstr "Opnieuw proberen" + +msgid "No releases found." +msgstr "Geen releases gevonden." + +msgid "See discography" +msgstr "Discografie bekijken" + +msgid "Load more" +msgstr "Meer laden" + +msgid "Add image" +msgstr "Afbeelding toevoegen" + +msgid "Change image" +msgstr "Afbeelding wijzigen" + +msgid "Remove image" +msgstr "Afbeelding verwijderen" + +msgid "Create Artist Collection" +msgstr "Artiestenverzameling aanmaken" + +msgid "Artist Scene" +msgstr "Artiestenscene" + +msgid "Share" +msgstr "Delen" + +msgid "Show artist" +msgstr "Artiest tonen" + +msgid "Blacklist artist" +msgstr "Artiest op zwarte lijst zetten" + +msgid "This artist is hidden from discovery" +msgstr "Deze artiest is verborgen in Ontdekken" + +msgid "Loading artist…" +msgstr "Artiest laden…" + +msgid "Popular Tracks" +msgstr "Populaire nummers" + +msgid "Play all next" +msgstr "Alles hierna afspelen" + +msgid "Play all later" +msgstr "Alles later afspelen" + +msgid "Add all to queue" +msgstr "Alles aan wachtrij toevoegen" + +msgid "Shuffle all" +msgstr "Alles shuffelen" + +msgid "Add all to playlist" +msgstr "Alles aan afspeellijst toevoegen" + +msgid "View less" +msgstr "Minder weergeven" + +msgid "Latest release" +msgstr "Nieuwste release" + +msgid "Appears On" +msgstr "Verschijnt op" + +msgid "Hide" +msgstr "Verbergen" + +msgid "Show" +msgstr "Toon" + +msgid "Network" +msgstr "Netwerk" + +msgid "Magazine" +msgstr "Magazine" + +msgid "Loading origin…" +msgstr "Herkomst laden…" + +msgid "BORN" +msgstr "GEBOREN" + +msgid "FOUNDED" +msgstr "OPGERICHT" + +msgid "BORN IN" +msgstr "GEBOREN IN" + +msgid "FOUNDED IN" +msgstr "OPGERICHT IN" + +msgid "DIED" +msgstr "OVERLEDEN" + +msgid "DISBANDED" +msgstr "OPGEHEVEN" + +msgid "LABELS" +msgstr "Labels" + +msgid "No label info" +msgstr "Geen labelinformatie" + +msgid "SIMILAR ARTISTS" +msgstr "Vergelijkbare artiesten" + +msgid "RELATIONSHIPS" +msgstr "RELATIES" + +msgid "Members & Former" +msgstr "Leden & voormalige leden" + +msgid "Member Of" +msgstr "Lid van" + +msgid "Collaborators" +msgstr "Samenwerkingen" + +msgid "YOU MAY ALSO LIKE" +msgstr "Dit vind je misschien ook leuk" + +msgid "No stories for this artist" +msgstr "Geen verhalen voor deze artiest" + +msgid "About this artist" +msgstr "Over deze artiest" + +msgid "Source" +msgstr "Bron" + +msgid "Following" +msgstr "Volgend" + +msgid "Follow" +msgstr "Volgen" + +msgid "View all" +msgstr "Alles weergeven" + +msgid "View all →" +msgstr "Alles weergeven →" + +msgid "Filter by genre" +msgstr "Filteren op genre" + +msgid "Remember selection" +msgstr "Selectie onthouden" + +msgid "Advanced view" +msgstr "Geavanceerde weergave" + +msgid "Search genres…" +msgstr "Genres zoeken…" + +msgid "Clear filter" +msgstr "Filter wissen" + +msgid "Qobuz Mixes" +msgstr "Qobuz Mixes" + +msgid "Elevate your day with a customized selection of music." +msgstr "Maak je dag beter met een gepersonaliseerde muziekselectie." + +msgid "Take a weekly journey with a fresh mix every Friday." +msgstr "Ga elke vrijdag op reis met een verse mix." + +msgid "A fresh shuffle from your personal library." +msgstr "Een frisse shuffle uit je persoonlijke bibliotheek." + +msgid "Discover new music from your most-played playlists." +msgstr "Ontdek nieuwe muziek op basis van je meest afgespeelde afspeellijsten." + +msgid "Open album" +msgstr "Album openen" + +msgid "Open containing album" +msgstr "Bijbehorend album openen" + +msgid "Play" +msgstr "Afspelen" + +msgid "Search…" +msgstr "Zoeken…" + +msgid "1 genre" +msgstr "1 genre" + +msgid "{} genres" +msgstr "{} genres" + +msgid "No results found." +msgstr "Geen resultaten gevonden." + +msgid "Editor's Picks" +msgstr "Keuze van de redactie" + +msgid "For You" +msgstr "Voor jou" + +msgid "Qobuz Playlists" +msgstr "Qobuz-afspeellijsten" + +msgid "No playlists match the selected categories." +msgstr "Geen afspeellijsten komen overeen met de geselecteerde categorieën." + +msgid "Recently Played Albums" +msgstr "Onlangs afgespeelde albums" + +msgid "Albums you play will appear here." +msgstr "Albums die je afspeelt verschijnen hier." + +msgid "Recently Played Tracks" +msgstr "Onlangs afgespeelde nummers" + +msgid "Tracks you play will appear here." +msgstr "Nummers die je afspeelt verschijnen hier." + +msgid "Popular albums" +msgstr "Populaire albums" + +msgid "{} selected" +msgstr "{} geselecteerd" + +msgid "Filter by category" +msgstr "Filteren op categorie" + +msgid "All categories" +msgstr "Alle categorieën" + +msgid "Customize" +msgstr "Aanpassen" + +msgid "Enabling more sections increases load time." +msgstr "Meer secties inschakelen verhoogt de laadtijd." + +msgid "{} of {} enabled" +msgstr "{} van {} ingeschakeld" + +msgid "Reset to defaults" +msgstr "Standaardwaarden herstellen" + +msgid "Radio Stations" +msgstr "Radiostations" + +msgid "RADIO" +msgstr "RADIO" + +msgid "Your Top Artists" +msgstr "Jouw topartiesten" + +msgid "Artists to Follow" +msgstr "Artiesten om te volgen" + +msgid "Pinned" +msgstr "Vastgezet" + +msgid "Pin" +msgstr "Vastzetten" + +msgid "Unpin" +msgstr "Losmaken" + +msgid "Showing {} of {} track." +msgid_plural "Showing {} of {} tracks." +msgstr[0] "Toont {} van {} nummer." +msgstr[1] "Toont {} van {} nummers." + +msgid "Spotlight" +msgstr "In de schijnwerpers" + +msgid "Shine a light on one of your favourite artists." +msgstr "Zet een van je favoriete artiesten in de schijnwerpers." + +msgid "ARTIST" +msgstr "ARTIEST" + +msgid "Top tracks" +msgstr "Topnummers" + +msgid "By {}" +msgstr "Door {}" + +msgid "TOP TRACKS" +msgstr "TOPNUMMERS" + +msgid "Qobuz Radio Station" +msgstr "Qobuz-radiostation" + +msgid "Available offline" +msgstr "Offline beschikbaar" + +msgid "Tracks" +msgstr "Nummers" + +msgid "Albums" +msgstr "Albums" + +msgid "Artists" +msgstr "Artiesten" + +msgid "Playlists" +msgstr "Afspeellijsten" + +msgid "Labels" +msgstr "Labels" + +msgid "Favorite" +msgstr "Favoriet" + +msgid "Library" +msgstr "Bibliotheek" + +msgid "Search" +msgstr "Zoeken" + +msgid "Group: Off" +msgstr "Groeperen: uit" + +msgid "Group: Album" +msgstr "Groeperen: album" + +msgid "Group: Artist" +msgstr "Groeperen: artiest" + +msgid "Group: Name" +msgstr "Groeperen: naam" + +msgid "Group: A-Z" +msgstr "Groeperen: A-Z" + +msgid "Title A-Z" +msgstr "Titel A-Z" + +msgid "Title Z-A" +msgstr "Titel Z-A" + +msgid "Artist A-Z" +msgstr "Artiest A-Z" + +msgid "Couldn't load your Library." +msgstr "Kan je bibliotheek niet laden." + +msgid "Remove from Library" +msgstr "Verwijderen uit bibliotheek" + +msgid "No tracks in your Library yet." +msgstr "Nog geen nummers in je bibliotheek." + +msgid "No tracks match your search." +msgstr "Geen nummers komen overeen met je zoekopdracht." + +msgid "No albums in your Library yet." +msgstr "Nog geen albums in je bibliotheek." + +msgid "No albums match your search." +msgstr "Geen albums komen overeen met je zoekopdracht." + +msgid "No artists in your Library yet." +msgstr "Nog geen artiesten in je bibliotheek." + +msgid "No artists match your search." +msgstr "Geen artiesten komen overeen met je zoekopdracht." + +msgid "No playlists match your search." +msgstr "Geen afspeellijsten komen overeen met je zoekopdracht." + +msgid "No followed playlists yet." +msgstr "Nog geen gevolgde afspeellijsten." + +msgid "No playlists in your Library yet." +msgstr "Nog geen afspeellijsten in je bibliotheek." + +msgid "No labels in your Library yet." +msgstr "Nog geen labels in je bibliotheek." + +msgid "No labels match your search." +msgstr "Geen labels komen overeen met je zoekopdracht." + +msgid "Select an artist to see their albums." +msgstr "Selecteer een artiest om de albums te bekijken." + +msgid "Couldn't load albums." +msgstr "Kan albums niet laden." + +msgid "No albums found." +msgstr "Geen albums gevonden." + +msgid "Go to artist page" +msgstr "Naar artiestenpagina" + +msgid "albums" +msgstr "Albums" + +msgid "Releases" +msgstr "Releases" + +msgid "Search this label…" +msgstr "Zoeken binnen dit label…" + +msgid "No results" +msgstr "Geen resultaten" + +msgid "More labels" +msgstr "Meer labels" + +msgid "About" +msgstr "Over" + +msgid "Folders" +msgstr "Mappen" + +msgid "tracks" +msgstr "nummers" + +msgid "Play all" +msgstr "Alles afspelen" + +msgid "Clear session" +msgstr "Sessie wissen" + +msgid "No playable tracks in this folder." +msgstr "Geen afspeelbare nummers in deze map." + +msgid "Play this album" +msgstr "Dit album afspelen" + +msgid "Local Library" +msgstr "Lokale bibliotheek" + +msgid "Artist Z-A" +msgstr "Artiest Z-A" + +msgid "Year (newest)" +msgstr "Jaar (nieuwste)" + +msgid "Year (oldest)" +msgstr "Jaar (oudste)" + +msgid "No grouping" +msgstr "Geen groepering" + +msgid "Group by letter" +msgstr "Groeperen op letter" + +msgid "Group by artist" +msgstr "Groeperen op artiest" + +msgid "By album" +msgstr "Op album" + +msgid "By artist" +msgstr "Op artiest" + +msgid "By name" +msgstr "Op naam" + +msgid "Search artists" +msgstr "Artiesten zoeken" + +msgid "Couldn't load your local albums." +msgstr "Kan je lokale albums niet laden." + +msgid "No albums in your local library yet." +msgstr "Nog geen albums in je lokale bibliotheek." + +msgid "Couldn't load your local tracks." +msgstr "Kan je lokale nummers niet laden." + +msgid "No tracks in your local library yet." +msgstr "Nog geen nummers in je lokale bibliotheek." + +msgid "Add to Mixtape/Collection" +msgstr "Toevoegen aan mixtape/verzameling" + +msgid "No folders in your local library yet." +msgstr "Nog geen mappen in je lokale bibliotheek." + +msgid "No folders match your search." +msgstr "Geen mappen komen overeen met je zoekopdracht." + +msgid "Search folders" +msgstr "Mappen zoeken" + +msgid "sel" +msgstr "sel" + +msgid "Check all" +msgstr "Alles aanvinken" + +msgid "Select a folder to see its contents." +msgstr "Selecteer een map om de inhoud te bekijken." + +msgid "Search albums..." +msgstr "Albums zoeken..." + +msgid "This folder has no tracks." +msgstr "Deze map bevat geen nummers." + +msgid "No artists in your local library yet." +msgstr "Nog geen artiesten in je lokale bibliotheek." + +msgid "artists" +msgstr "Artiesten" + +msgid "Select an artist to view their albums" +msgstr "Selecteer een artiest om de albums te bekijken" + +msgid "No albums found" +msgstr "Geen albums gevonden" + +msgid "Local Library port in progress" +msgstr "Lokale bibliotheek-port in uitvoering" + +msgid "This tab is coming next." +msgstr "Dit tabblad komt binnenkort." + +msgid "Filter" +msgstr "Filteren" + +msgid "Hi-Res" +msgstr "Hi-Res" + +msgid "CD" +msgstr "CD" + +msgid "Lossy" +msgstr "Lossy" + +msgid "Other" +msgstr "Overige" + +msgid "Local" +msgstr "Lokaal" + +msgid "Offline cache" +msgstr "Offline cache" + +msgid "Scene" +msgstr "Scene" + +msgid "No artists found for this scene." +msgstr "Geen artiesten gevonden voor deze scene." + +msgid "QBZ" +msgstr "QBZ" + +msgid "QOBUZ™ PLAYER" +msgstr "Qobuz™ Speler" + +msgid "I have read the" +msgstr "Ik heb de" + +msgid "Qobuz™ Terms of Service" +msgstr "Qobuz™ Servicevoorwaarden gelezen" + +msgid "Sign in via Browser" +msgstr "Inloggen via browser" + +msgid "Use your system browser instead" +msgstr "Gebruik in plaats daarvan je systeembrowser" + +msgid "No internet connection — you can start in offline mode with your local library and downloads" +msgstr "Geen internetverbinding — je kunt in offline modus starten met je lokale bibliotheek en downloads" + +msgid "A network sign-in page may be blocking the connection" +msgstr "Een netwerk-aanmeldpagina blokkeert mogelijk de verbinding" + +msgid "Could not restore your session" +msgstr "Kan je sessie niet herstellen" + +msgid "Start offline (no access to Qobuz™ services)" +msgstr "Offline starten (geen toegang tot Qobuz™-diensten)" + +msgid "QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™." +msgstr "QBZ vereist een actief Qobuz™-abonnement. Je inloggegevens worden rechtstreeks naar Qobuz™ verzonden." + +msgid "This application uses the Qobuz API but is not certified by Qobuz." +msgstr "Deze applicatie maakt gebruik van de Qobuz-API maar is niet gecertificeerd door Qobuz." + +msgid "Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz." +msgstr "Qobuz™ is een handelsmerk van Qobuz. QBZ is een open-source applicatie gelicentieerd onder de MIT-licentie en is niet gelieerd aan, onderschreven door of gecertificeerd door Qobuz." + +msgid "No tracks for this mix." +msgstr "Geen nummers voor deze mix." + +msgid "Album" +msgstr "Album" + +msgid "Bands & Projects" +msgstr "Bands & Projecten" + +msgid "No album appearances found." +msgstr "Geen albumoptredens gevonden." + +msgid "You're offline" +msgstr "Je bent offline" + +msgid "Offline mode is enabled. Disable it in Settings to use Qobuz." +msgstr "Offline modus is ingeschakeld. Schakel deze uit in de instellingen om Qobuz te gebruiken." + +msgid "No internet connection. Your local library and downloads keep working." +msgstr "Geen internetverbinding. Je lokale bibliotheek en downloads blijven werken." + +msgid "Open Settings" +msgstr "Instellingen openen" + +msgid "Offline Cache" +msgstr "Offline cache" + +msgid "{} track" +msgid_plural "{} tracks" +msgstr[0] "{} nummer" +msgstr[1] "{} nummers" + +msgid "Sort: A-Z" +msgstr "Sorteren: A-Z" + +msgid "Sort: Recent" +msgstr "Sorteren: recent" + +msgid "Sort: Largest" +msgstr "Sorteren: grootste" + +msgid "Sort: Smallest" +msgstr "Sorteren: kleinste" + +msgid "Failed only" +msgstr "Alleen mislukt" + +msgid " selected" +msgstr " geselecteerd" + +msgid "Re-download" +msgstr "Opnieuw downloaden" + +msgid "Remove" +msgstr "Verwijderen" + +msgid "All artists" +msgstr "Alle artiesten" + +msgid "No offline tracks yet" +msgstr "Nog geen offline nummers" + +msgid "Search playlists…" +msgstr "Afspeellijsten zoeken…" + +msgid "1 playlist" +msgstr "1 afspeellijst" + +msgid "{} playlists" +msgstr "{} afspeellijsten" + +msgid "Move to folder" +msgstr "Naar map verplaatsen" + +msgid "No folder" +msgstr "Geen map" + +msgid "Playlist manager" +msgstr "Afspeellijstbeheer" + +msgid "Visible" +msgstr "Zichtbaar" + +msgid "Hidden" +msgstr "Verborgen" + +msgid "All" +msgstr "Alle" + +msgid "Recent" +msgstr "Recent" + +msgid "Play Count" +msgstr "Aantal keer afgespeeld" + +msgid "Track Count" +msgstr "Aantal nummers" + +msgid "Name A-Z" +msgstr "Naam A-Z" + +msgid "New folder" +msgstr "Nieuwe map" + +msgid "{} folders, {} playlists" +msgstr "{} mappen, {} afspeellijsten" + +msgid "Use the arrows to reorder playlists" +msgstr "Gebruik de pijlen om afspeellijsten te herschikken" + +msgid "Folders ({})" +msgstr "Mappen ({})" + +msgid "Playlists ({})" +msgstr "Afspeellijsten ({})" + +msgid "No playlists found." +msgstr "Geen afspeellijsten gevonden." + +msgid "Artist" +msgstr "Artiest" + +msgid "Playlist" +msgstr "Afspeellijst" + +msgid "Search tracks" +msgstr "Nummers zoeken" + +msgid "Sort" +msgstr "Sorteren" + +msgid "This playlist is empty." +msgstr "Deze afspeellijst is leeg." + +msgid "Upload to Qobuz?" +msgstr "Uploaden naar Qobuz?" + +msgid "This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy." +msgstr "Dit maakt de afspeellijst aan op Qobuz met de Qobuz-nummers, houdt lokale bestanden gekoppeld en verwijdert de lokale kopie." + +msgid "Cancel" +msgstr "Annuleren" + +msgid "Upload" +msgstr "Uploaden" + +msgid "About this playlist" +msgstr "Over deze afspeellijst" + +msgid "ITEM" +msgstr "ITEM" + +msgid "TYPE" +msgstr "Type" + +msgid "SOURCE" +msgstr "Bron" + +msgid "QUALITY" +msgstr "Kwaliteit" + +msgid "TRACKS" +msgstr "nummers" + +msgid "YEAR" +msgstr "Jaar" + +msgid "Folder name" +msgstr "Mapnaam" + +msgid "Creating..." +msgstr "Aanmaken..." + +msgid "Create" +msgstr "Aanmaken" + +msgid "Edit folder" +msgstr "Map bewerken" + +msgid "Enter folder name" +msgstr "Voer mapnaam in" + +msgid "Icon" +msgstr "Icoon" + +msgid "Hide from sidebar" +msgstr "Verbergen in zijbalk" + +msgid "Delete" +msgstr "Verwijderen" + +msgid "Save" +msgstr "Opslaan" + +msgid "JUMP TO" +msgstr "Spring naar" + +msgid "Search this page…" +msgstr "Zoeken op deze pagina…" + +msgid "Something is already playing" +msgstr "Er wordt al iets afgespeeld" + +msgid "Add this to the current queue, or clear the queue and start playing it?" +msgstr "Dit aan de huidige wachtrij toevoegen, of de wachtrij wissen en dit afspelen?" + +msgid "Clear queue and play" +msgstr "Wachtrij wissen en afspelen" + +msgid "Edit metadata" +msgstr "Metadata bewerken" + +msgid "Album title" +msgstr "Albumtitel" + +msgid "Album artist" +msgstr "Albumartiest" + +msgid "Year" +msgstr "Jaar" + +msgid "Genre" +msgstr "Genre" + +msgid "Catalog number" +msgstr "Catalogusnummer" + +msgid "Total discs" +msgstr "Totaal aantal discs" + +msgid "Searching…" +msgstr "Zoeken…" + +msgid "No results, try a different search" +msgstr "Geen resultaten, probeer een andere zoekopdracht" + +msgid "Apply" +msgstr "Toepassen" + +msgid "Album folder" +msgstr "Albummap" + +msgid "Sidecar file (recommended)" +msgstr "Sidecar-bestand (aanbevolen)" + +msgid "Write to audio files" +msgstr "Naar audiobestanden schrijven" + +msgid "This writes tags directly into your audio files." +msgstr "Dit schrijft tags rechtstreeks in je audiobestanden." + +msgid "Direct write is unavailable for CUE-based albums." +msgstr "Direct schrijven is niet beschikbaar voor CUE-albums." + +msgid "Writing" +msgstr "Schrijven" + +msgid "Saving…" +msgstr "Opslaan…" + +msgid "Edit playlist" +msgstr "Afspeellijst bewerken" + +msgid "Playlist name" +msgstr "Naam van afspeellijst" + +msgid "Description" +msgstr "Beschrijving" + +msgid "Offline-only playlist" +msgstr "Alleen-offline afspeellijst" + +msgid "New playlist" +msgstr "Nieuwe afspeellijst" + +msgid "Name" +msgstr "Naam" + +msgid "My Playlist" +msgstr "Mijn afspeellijst" + +msgid "Description (optional)" +msgstr "Beschrijving (optioneel)" + +msgid "Folder" +msgstr "Map" + +msgid "Make playlist public" +msgstr "Afspeellijst openbaar maken" + +msgid "Created locally — you're offline" +msgstr "Lokaal aangemaakt — je bent offline" + +msgid "Add to mixtape" +msgstr "Aan mixtape toevoegen" + +msgid "Share Qobuz link" +msgstr "Qobuz-link delen" + +msgid "Share Album.link" +msgstr "Album.link delen" + +msgid "Refresh offline copy" +msgstr "Offline kopie vernieuwen" + +msgid "Some of these tracks were already added" +msgstr "Sommige van deze nummers waren al toegevoegd" + +msgid "This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates." +msgstr "Deze afspeellijst bevat al {} van de {} nummers die je toevoegt. Voeg alleen de nieuwe toe, of voeg alles toe inclusief duplicaten." + +msgid "Adding..." +msgstr "Toevoegen..." + +msgid "Add all tracks" +msgstr "Alle nummers toevoegen" + +msgid "Add only new tracks" +msgstr "Alleen nieuwe nummers toevoegen" + +msgid "Add to Playlist" +msgstr "Aan afspeellijst toevoegen" + +msgid "Choose playlist" +msgstr "Afspeellijst kiezen" + +msgid "Adding {} local track(s)" +msgstr "{} lokale nummer(s) toevoegen" + +msgid "Adding {} track(s)" +msgstr "{} nummer(s) toevoegen" + +msgid "+ Create new playlist" +msgstr "+ Nieuwe afspeellijst aanmaken" + +msgid "Search playlists..." +msgstr "Afspeellijsten zoeken..." + +msgid "Playlist Name" +msgstr "Naam van afspeellijst" + +msgid "Saving..." +msgstr "Opslaan..." + +msgid "Create & Add" +msgstr "Aanmaken & toevoegen" + +msgid "Add" +msgstr "Toevoegen" + +msgid "No playlists found" +msgstr "Geen afspeellijsten gevonden" + +msgid "{} more playlist(s)..." +msgstr "Nog {} afspeellijst(en)..." + +msgid "Import Playlist" +msgstr "Afspeellijst importeren" + +msgid "This feature requires internet connection" +msgstr "Deze functie vereist een internetverbinding" + +msgid "Playlist URL" +msgstr "Afspeellijst-URL" + +msgid "ALLOWED SOURCES" +msgstr "TOEGESTANE BRONNEN" + +msgid "Conversion progress" +msgstr "Conversievoortgang" + +msgid "Summary" +msgstr "Samenvatting" + +msgid "Close" +msgstr "Sluiten" + +msgid "Importing..." +msgstr "Importeren..." + +msgid "Import" +msgstr "Importeren" + +msgid "Fetching..." +msgstr "Ophalen..." + +msgid "Fetch playlist" +msgstr "Afspeellijst ophalen" + +msgid "Shuffle" +msgstr "Shuffelen" + +msgid "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" +msgstr "16-bit / 44,1 kHz — George Harrison · My Sweet Lord" + +msgid "24-bit / 44.1 kHz — Billie Eilish · LUNCH" +msgstr "24-bit / 44,1 kHz — Billie Eilish · LUNCH" + +msgid "24-bit / 96 kHz — Iron Maiden · Stratego" +msgstr "24-bit / 96 kHz — Iron Maiden · Stratego" + +msgid "24-bit / 192 kHz — Toto · Africa" +msgstr "24-bit / 192 kHz — Toto · Africa" + +msgid "Welcome" +msgstr "Welkom" + +msgid "Check" +msgstr "Controleren" + +msgid "DACs" +msgstr "DAC's" + +msgid "Configure" +msgstr "Configureren" + +msgid "Test" +msgstr "Testen" + +msgid "Done" +msgstr "Klaar" + +msgid "HiFi Wizard — DAC Setup" +msgstr "HiFi-wizard — DAC-configuratie" + +msgid "Guided DAC Setup" +msgstr "Begeleide DAC-installatie" + +msgid "This wizard helps you configure your DAC for bit-perfect playback on Linux. QBZ never changes your system automatically — it auto-detects your DACs, shows you the exact commands and config to copy, and lets you test playback right here." +msgstr "Deze wizard helpt je je DAC te configureren voor bit-perfecte weergave op Linux. QBZ wijzigt je systeem nooit automatisch — het detecteert je DAC's, toont je de exacte commando's en configuratie om te kopiëren, en laat je de weergave direct hier testen." + +msgid "You are responsible for your own backups. This is a helper, not a system recovery tool." +msgstr "Je bent zelf verantwoordelijk voor je back-ups. Dit is een hulpmiddel, geen systeemhersteltool." + +msgid "What to test with" +msgstr "Waarmee te testen" + +msgid "The built-in test plays four tracks spanning different bit-depths and sample rates (16/44.1, 24/44.1, 24/96, 24/192) so you can see your DAC switch. If you'd rather use your own queue, add one track of each kind first so every rate is exercised." +msgstr "De ingebouwde test speelt vier nummers af met verschillende bitdieptes en samplefrequenties (16/44,1, 24/44,1, 24/96, 24/192) zodat je je DAC ziet schakelen. Wil je liever je eigen wachtrij gebruiken, voeg dan eerst één nummer van elke soort toe zodat elke frequentie aan bod komt." + +msgid "I understand and accept these terms" +msgstr "Ik begrijp en accepteer deze voorwaarden" + +msgid "Check Audio Stack" +msgstr "Audio-stack controleren" + +msgid "Sandboxed — host check unavailable" +msgstr "Sandboxed — hostcontrole niet beschikbaar" + +msgid "QBZ is packaged as Flatpak/Snap and can't inspect your host audio stack. If playback already works, you're set. Pick your distribution and init system below for the right setup commands." +msgstr "QBZ is verpakt als Flatpak/Snap en kan je host-audiostack niet inspecteren. Als de weergave al werkt, ben je klaar. Kies hieronder je distributie en init-systeem voor de juiste installatiecommando's." + +msgid "Audio stack ready" +msgstr "Audiostack gereed" + +msgid "Some packages are missing" +msgstr "Sommige pakketten ontbreken" + +msgid "Your distribution" +msgstr "Je distributie" + +msgid "Init system (service commands)" +msgstr "Init-systeem (servicecommando's)" + +msgid "Which DACs do you want to configure?" +msgstr "Welke DAC's wil je configureren?" + +msgid "Detecting your DACs…" +msgstr "Je DAC's detecteren…" + +msgid "default" +msgstr "standaard" + +msgid "Hide manual entry" +msgstr "Handmatige invoer verbergen" + +msgid "Can't see your DAC?" +msgstr "Zie je je DAC niet?" + +msgid "Find your DAC's PipeWire node name and paste it here:" +msgstr "Zoek de PipeWire-nodenaam van je DAC en plak deze hier:" + +msgid "1. Find your DAC under 'Audio > Sinks' and note its number" +msgstr "1. Zoek je DAC onder 'Audio > Sinks' en noteer het nummer" + +msgid "2. Run with that number to read the node.name" +msgstr "2. Voer uit met dat nummer om de node.name te lezen" + +msgid "Valid node name" +msgstr "Geldige nodenaam" + +msgid "Invalid — should contain alsa_output or alsa_input" +msgstr "Ongeldig — moet alsa_output of alsa_input bevatten" + +msgid "Review & Apply" +msgstr "Controleren & toepassen" + +msgid "QBZ never writes these files" +msgstr "QBZ schrijft deze bestanden nooit" + +msgid "QBZ already pins rate, sink and exclusive mode at runtime. These files only change system-wide behavior — copy and run them yourself if you want them. Back up first." +msgstr "QBZ pint frequentie, sink en exclusieve modus al tijdens runtime. Deze bestanden wijzigen alleen systeembreed gedrag — kopieer en voer ze zelf uit als je ze wilt. Maak eerst een back-up." + +msgid "1. Back up your current PipeWire/WirePlumber config" +msgstr "1. Maak een back-up van je huidige PipeWire/WirePlumber-configuratie" + +msgid "I created a backup" +msgstr "Ik heb een back-up gemaakt" + +msgid "Sample-rate switching (PipeWire)" +msgstr "Samplefrequentie-schakeling (PipeWire)" + +msgid "Per-app bit-perfect (PipeWire client)" +msgstr "Bit-perfect per app (PipeWire-client)" + +msgid "Pin the DAC + rates (WirePlumber)" +msgstr "DAC + frequenties pinnen (WirePlumber)" + +msgid "I created the configuration files" +msgstr "Ik heb de configuratiebestanden aangemaakt" + +msgid "3. Restart the audio services to load the config" +msgstr "3. Herstart de audioservices om de configuratie te laden" + +msgid "I restarted the audio services" +msgstr "Ik heb de audioservices herstart" + +msgid "Test Playback" +msgstr "Weergave testen" + +msgid "Plays four tracks at different bit-depths and sample rates through the selected DAC. Watch your DAC's display switch rates — and confirm it below." +msgstr "Speelt vier nummers af met verschillende bitdieptes en samplefrequenties via de geselecteerde DAC. Kijk hoe het display van je DAC van frequentie wisselt — en bevestig het hieronder." + +msgid "Stop" +msgstr "Stoppen" + +msgid "Play test" +msgstr "Test afspelen" + +msgid "Use my current queue" +msgstr "Mijn huidige wachtrij gebruiken" + +msgid "All set" +msgstr "Alles gereed" + +msgid "Your DAC is ready for bit-perfect playback. If you applied the config files, restart the audio services (or log out and back in) for them to take effect." +msgstr "Je DAC is klaar voor bit-perfecte weergave. Als je de configuratiebestanden hebt toegepast, herstart dan de audioservices (of log uit en weer in) om ze van kracht te laten worden." + +msgid "Config files you can create:" +msgstr "Configuratiebestanden die je kunt aanmaken:" + +msgid "Back" +msgstr "Terug" + +msgid "Start" +msgstr "Starten" + +msgid "Finish" +msgstr "Voltooien" + +msgid "Next" +msgstr "Volgende" + +msgid "Play now" +msgstr "Nu afspelen" + +msgid "Start playing now?" +msgstr "Nu beginnen met afspelen?" + +msgid "Not now" +msgstr "Niet nu" + +msgid "Show location" +msgstr "Locatie tonen" + +msgid "Remove from Local Library" +msgstr "Uit lokale bibliotheek verwijderen" + +msgid "Forget download record" +msgstr "Downloadregistratie vergeten" + +msgid "Could not open that folder." +msgstr "Deze map kon niet worden geopend." + +msgid "Could not remove that library folder." +msgstr "Deze bibliotheekmap kon niet worden verwijderd." + +msgid "Could not forget that download." +msgstr "Deze download kon niet worden vergeten." + +msgid "Create radio" +msgstr "Radio aanmaken" + +msgid "Add to library" +msgstr "Aan bibliotheek toevoegen" + +msgid "Remove from playlist" +msgstr "Verwijderen uit afspeellijst" + +msgid "Share Song.link" +msgstr "Song.link delen" + +msgid "Remove offline copy" +msgstr "Offline kopie verwijderen" + +msgid "Go to album" +msgstr "Naar album" + +msgid "Go to artist" +msgstr "Naar artiest" + +msgid "Track info" +msgstr "Nummerinformatie" + +msgid "selected" +msgstr "geselecteerd" + +msgid "Artist is blacklisted" +msgstr "Artiest staat op de zwarte lijst" + +msgid "Main Artist" +msgstr "Hoofdartiest" + +msgid "Performer" +msgstr "Uitvoerende" + +msgid "Composer" +msgstr "Componist" + +msgid "Release Name" +msgstr "Releasenaam" + +msgid "Most popular" +msgstr "Populairst" + +msgid "No results." +msgstr "Geen resultaten." + +msgid "No results for" +msgstr "Geen resultaten voor" + +msgid "Top result" +msgstr "Topresultaat" + +msgid "View more" +msgstr "Meer weergeven" + +msgid "QOBUZ CONNECT" +msgstr "QOBUZ CONNECT" + +msgid "Connect diagnostics" +msgstr "Connect-diagnostiek" + +msgid "Live session topology and a rolling event log for debugging Qobuz Connect at runtime." +msgstr "Live sessietopologie en een doorlopend gebeurtenislog voor het debuggen van Qobuz Connect tijdens runtime." + +msgid "Open diagnostics" +msgstr "Diagnostiek openen" + +msgid "PLAYBACK" +msgstr "Afspeelbediening" + +msgid "Continue playback after track ends" +msgstr "Weergave voortzetten nadat het nummer eindigt" + +msgid "Keep playing the rest of the album or playlist instead of stopping." +msgstr "Blijf de rest van het album of de afspeellijst afspelen in plaats van te stoppen." + +msgid "Show track playing context" +msgstr "Afspeelcontext van nummer tonen" + +msgid "Display the context-stack icon in the player." +msgstr "Toon het contextstack-icoon in de speler." + +msgid "Gapless playback" +msgstr "Gapless weergave" + +msgid "Play consecutive same-format tracks without a gap." +msgstr "Speel opeenvolgende nummers met hetzelfde formaat zonder pauze af." + +msgid "SESSION" +msgstr "SESSIE" + +msgid "Restore session on startup" +msgstr "Sessie herstellen bij opstarten" + +msgid "Restore the queue and current track on the next launch." +msgstr "Herstel de wachtrij en het huidige nummer bij de volgende start." + +msgid "Resume playback position" +msgstr "Afspeelpositie hervatten" + +msgid "Also seek back to where the saved track left off." +msgstr "Spoel ook terug naar waar het opgeslagen nummer was gebleven." + +msgid "STREAMING" +msgstr "STREAMING" + +msgid "Stream uncached tracks" +msgstr "Niet-gecachte nummers streamen" + +msgid "Start uncached tracks via streaming instead of waiting for the full download." +msgstr "Start niet-gecachte nummers via streaming in plaats van te wachten op de volledige download." + +msgid "Initial buffer size" +msgstr "Initiële buffergrootte" + +msgid "Seconds of audio buffered before streaming playback starts." +msgstr "Seconden audio gebufferd voordat streamingweergave start." + +msgid "s" +msgstr "s" + +msgid "Streaming only" +msgstr "Alleen streaming" + +msgid "Skip writing tracks to the local cache while streaming." +msgstr "Sla het schrijven van nummers naar de lokale cache over tijdens het streamen." + +msgid "When quality retries fail" +msgstr "Wanneer kwaliteitspogingen mislukken" + +msgid "What to do when every quality tier for a track is unavailable." +msgstr "Wat te doen wanneer elk kwaliteitsniveau voor een nummer onbeschikbaar is." + +msgid "Folder settings" +msgstr "Mapinstellingen" + +msgid "Folder settings saved" +msgstr "Mapinstellingen opgeslagen" + +msgid "Could not save the folder settings." +msgstr "Kon de mapinstellingen niet opslaan." + +msgid "Folder location" +msgstr "Maplocatie" + +msgid "Change" +msgstr "Wijzigen" + +msgid "Checking..." +msgstr "Controleren..." + +msgid "Folder is accessible" +msgstr "Map is toegankelijk" + +msgid "Folder is not accessible" +msgstr "Map is niet toegankelijk" + +msgid "Display name" +msgstr "Weergavenaam" + +msgid "Optional friendly name" +msgstr "Optionele beschrijvende naam" + +msgid "Shown instead of the full path in the folder list." +msgstr "Wordt getoond in plaats van het volledige pad in de mappenlijst." + +msgid "Enabled" +msgstr "Ingeschakeld" + +msgid "Disabled folders are skipped during scans." +msgstr "Uitgeschakelde mappen worden overgeslagen bij scans." + +msgid "Network folder" +msgstr "Netwerkmap" + +msgid "Mark this folder as a network share (excluded when offline)." +msgstr "Markeer deze map als netwerkshare (uitgesloten wanneer offline)." + +msgid "Network filesystem type" +msgstr "Type netwerkbestandssysteem" + +msgid "Auto-detect" +msgstr "Automatisch detecteren" + +msgid "Last scanned" +msgstr "Laatst gescand" + +msgid "Scan this folder" +msgstr "Deze map scannen" + +msgid "FOLDER" +msgstr "MAP" + +msgid "LAST SCAN" +msgstr "LAATSTE SCAN" + +msgid "STATUS" +msgstr "STATUS" + +msgid "Unavailable" +msgstr "Niet beschikbaar" + +msgid "Active" +msgstr "Actief" + +msgid "LIBRARY FOLDERS" +msgstr "BIBLIOTHEEKMAPPEN" + +msgid "Browse..." +msgstr "Bladeren..." + +msgid "Choose a music folder" +msgstr "Kies een muziekmap" + +msgid "Browse for a music folder, or type the full path." +msgstr "Blader naar een muziekmap of typ het volledige pad." + +msgid "Filter folders..." +msgstr "Mappen filteren..." + +msgid "No folders yet. Add a folder to build your local library." +msgstr "Nog geen mappen. Voeg een map toe om je lokale bibliotheek op te bouwen." + +msgid "No folders match your filter." +msgstr "Geen mappen komen overeen met je filter." + +msgid "Scanning" +msgstr "Scannen" + +msgid "MAINTENANCE" +msgstr "ONDERHOUD" + +msgid "Cleanup missing files" +msgstr "Ontbrekende bestanden opschonen" + +msgid "Remove tracks whose files no longer exist on disk." +msgstr "Verwijder nummers waarvan de bestanden niet meer op schijf staan." + +msgid "Cleaning up..." +msgstr "Opschonen..." + +msgid "Cleanup" +msgstr "Opschonen" + +msgid "DANGER ZONE" +msgstr "GEVARENZONE" + +msgid "Clear library database" +msgstr "Bibliotheekdatabase wissen" + +msgid "Remove all indexed tracks. Your audio files are not deleted." +msgstr "Verwijder alle geïndexeerde nummers. Je audiobestanden worden niet verwijderd." + +msgid "Clearing..." +msgstr "Wissen..." + +msgid "Clear all" +msgstr "Alles wissen" + +msgid "PLEX" +msgstr "PLEX" + +msgid "Connect a Plex Media Server on your local network." +msgstr "Verbind een Plex Media Server op je lokale netwerk." + +msgid "Server address" +msgstr "Serveradres" + +msgid "Only local network servers are supported." +msgstr "Alleen servers op het lokale netwerk worden ondersteund." + +msgid "Enter a valid server address." +msgstr "Voer een geldig serveradres in." + +msgid "http://127.0.0.1:32400" +msgstr "http://127.0.0.1:32400" + +msgid "Authorize" +msgstr "Autoriseren" + +msgid "Generate a code and sign in to Plex in your browser." +msgstr "Genereer een code en log in bij Plex in je browser." + +msgid "Working..." +msgstr "Bezig..." + +msgid "Generate code" +msgstr "Code genereren" + +msgid "Link code" +msgstr "Koppelcode" + +msgid "Enter this code at the Plex sign-in page." +msgstr "Voer deze code in op de Plex-aanmeldpagina." + +msgid "Copy code" +msgstr "Code kopiëren" + +msgid "Open Plex sign-in" +msgstr "Plex-aanmelding openen" + +msgid "Connected to Plex" +msgstr "Verbonden met Plex" + +msgid "The code expired. Generate a new one." +msgstr "De code is verlopen. Genereer een nieuwe." + +msgid "Ping the saved server and report what answers." +msgstr "Ping de opgeslagen server en meld wat er antwoordt." + +msgid "Connected to {}" +msgstr "Verbonden met {}" + +msgid "Could not reach the Plex server." +msgstr "Kon de Plex-server niet bereiken." + +msgid "Plex is not configured." +msgstr "Plex is niet geconfigureerd." + +msgid "Enter token manually" +msgstr "Token handmatig invoeren" + +msgid "Use an existing X-Plex-Token instead of the PIN flow." +msgstr "Gebruik een bestaande X-Plex-Token in plaats van de PIN-flow." + +msgid "Token" +msgstr "Token" + +msgid "X-Plex-Token" +msgstr "X-Plex-Token" + +msgid "Check connection" +msgstr "Verbinding controleren" + +msgid "Verify the server is reachable." +msgstr "Controleer of de server bereikbaar is." + +msgid "Libraries" +msgstr "Bibliotheken" + +msgid "Fetch your Plex music libraries." +msgstr "Haal je Plex-muziekbibliotheken op." + +msgid "Get libraries" +msgstr "Bibliotheken ophalen" + +msgid "MUSIC LIBRARIES" +msgstr "MUZIEKBIBLIOTHEKEN" + +msgid "({} tracks)" +msgstr "({} nummers)" + +msgid "Write metadata to Plex (experimental)" +msgstr "Metadata naar Plex schrijven (experimenteel)" + +msgid "Allow QBZ to write track metadata back to your Plex server." +msgstr "Sta QBZ toe om nummermetadata terug te schrijven naar je Plex-server." + +msgid "PLEX DANGER ZONE" +msgstr "PLEX-GEVARENZONE" + +msgid "Disconnect" +msgstr "Verbinding verbreken" + +msgid "Sign out of Plex and clear the local cache." +msgstr "Log uit bij Plex en wis de lokale cache." + +msgid "Clear cache" +msgstr "Cache wissen" + +msgid "Remove cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Verwijder gecachte Plex-bibliotheken en -nummers. Je aanmelding blijft behouden." + +msgid "OFFLINE MODE" +msgstr "OFFLINE MODUS" + +msgid "Status" +msgstr "Status" + +msgid "Captive portal detected — sign in to the network to get online." +msgstr "Captive portal gedetecteerd — meld je aan bij het netwerk om online te komen." + +msgid "Offline mode enabled" +msgstr "Offline modus ingeschakeld" + +msgid "No connection" +msgstr "Geen verbinding" + +msgid "Online" +msgstr "Online" + +msgid "Check now" +msgstr "Nu controleren" + +msgid "Enable Offline Mode" +msgstr "Offline modus inschakelen" + +msgid "Test the connection now instead of waiting for the next automatic check." +msgstr "Test de verbinding nu in plaats van te wachten op de volgende automatische controle." + +msgid "Include sign-in credentials" +msgstr "Inloggegevens opnemen" + +msgid "The bundle will contain your tokens. Anyone who opens the file can sign in as you." +msgstr "De bundel bevat je tokens. Iedereen die het bestand opent, kan als jou inloggen." + +msgid "Manually switch to offline mode even with internet." +msgstr "Schakel handmatig over naar offline modus, ook met internet." + +msgid "OFFLINE CACHE" +msgstr "OFFLINE CACHE" + +msgid "Manage offline cache" +msgstr "Offline cache beheren" + +msgid "Browse and manage your downloaded tracks and albums." +msgstr "Blader door en beheer je gedownloade nummers en albums." + +msgid "Open manager" +msgstr "Beheer openen" + +msgid "Cache folder" +msgstr "Cachemap" + +msgid "Open the folder where offline tracks are stored on disk." +msgstr "Open de map waar offline nummers op schijf worden opgeslagen." + +msgid "Open folder" +msgstr "Map openen" + +msgid "Clear offline cache" +msgstr "Offline cache wissen" + +msgid "Remove every downloaded track. This cannot be undone." +msgstr "Verwijder elk gedownload nummer. Dit kan niet ongedaan worden gemaakt." + +msgid "LYRICS" +msgstr "SONGTEKSTEN" + +msgid "Clear lyrics cache" +msgstr "Songtekstencache wissen" + +msgid "{} entries using {}" +msgstr "{} items, {} in gebruik" + +msgid "Manage" +msgstr "Beheren" + +msgid "Artist Blacklist" +msgstr "Zwarte lijst van artiesten" + +msgid "{} artist(s) blocked" +msgstr "{} artiest(en) geblokkeerd" + +msgid "{} artist(s) blocked (disabled)" +msgstr "{} artiest(en) geblokkeerd (uitgeschakeld)" + +msgid "Settings" +msgstr "Instellingen" + +msgid "Audio" +msgstr "Audio" + +msgid "Playback" +msgstr "Afspeelbediening" + +msgid "Appearance" +msgstr "Uiterlijk" + +msgid "Offline" +msgstr "Offline" + +msgid "Blacklist" +msgstr "Zwarte lijst" + +msgid "Integrations" +msgstr "Integraties" + +msgid "Developer" +msgstr "Ontwikkelaar" + +msgid "Loading settings..." +msgstr "Instellingen laden..." + +msgid "Added {}" +msgstr "Toegevoegd op {}" + +msgid "Blacklisted artists are hidden from search results, radio, suggestions, and similar artists." +msgstr "Artiesten op de zwarte lijst worden verborgen in zoekresultaten, radio, suggesties en vergelijkbare artiesten." + +msgid "Clear All" +msgstr "Alles wissen" + +msgid "{} artists" +msgstr "{} artiesten" + +msgid "Blacklist filtering is disabled. Blacklisted artists are shown everywhere until you re-enable it." +msgstr "Zwarte-lijstfiltering is uitgeschakeld. Artiesten op de zwarte lijst worden overal getoond totdat je deze weer inschakelt." + +msgid "Loading blacklist…" +msgstr "Zwarte lijst laden…" + +msgid "No blacklisted artists" +msgstr "Geen artiesten op de zwarte lijst" + +msgid "To hide an artist, go to their page and use the menu." +msgstr "Om een artiest te verbergen, ga naar de artiestenpagina en gebruik het menu." + +msgid "No results for \"{}\"" +msgstr "Geen resultaten voor \"{}\"" + +msgid "Clear Blacklist?" +msgstr "Zwarte lijst wissen?" + +msgid "This will remove all {} blacklisted artists. This cannot be undone." +msgstr "Dit verwijdert alle {} artiesten van de zwarte lijst. Dit kan niet ongedaan worden gemaakt." + +msgid "SCROBBLERS" +msgstr "SCROBBLERS" + +msgid "Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services." +msgstr "Stuur je afgespeelde nummers naar Last.fm en ListenBrainz. Werkt voor Qobuz-, lokale en Plex-nummers. Deze schakelaar bepaalt ook of Aanbevelingen je luistergeschiedenis van die diensten mag lezen." + +msgid "LAST.FM" +msgstr "LAST.FM" + +msgid "Scrobble to Last.fm" +msgstr "Scrobbelen naar Last.fm" + +msgid "Signed in as {}." +msgstr "Ingelogd als {}." + +msgid "Connect your Last.fm account to enable scrobbling." +msgstr "Verbind je Last.fm-account om scrobbelen in te schakelen." + +msgid "Connect" +msgstr "Verbinden" + +msgid "Authorize QBZ in your browser, then click Finish." +msgstr "Autoriseer QBZ in je browser en klik daarna op Voltooien." + +msgid "Connect Last.fm" +msgstr "Last.fm verbinden" + +msgid "Open authorize page" +msgstr "Autorisatiepagina openen" + +msgid "Disconnect Last.fm" +msgstr "Last.fm loskoppelen" + +msgid "Sign out of Last.fm." +msgstr "Log uit bij Last.fm." + +msgid "LISTENBRAINZ" +msgstr "LISTENBRAINZ" + +msgid "Scrobble to ListenBrainz" +msgstr "Scrobbelen naar ListenBrainz" + +msgid "Paste your ListenBrainz user token to enable scrobbling." +msgstr "Plak je ListenBrainz-gebruikerstoken om scrobbelen in te schakelen." + +msgid "User token" +msgstr "Gebruikerstoken" + +msgid "From listenbrainz.org/settings." +msgstr "Van listenbrainz.org/settings." + +msgid "ListenBrainz token" +msgstr "ListenBrainz-token" + +msgid "Save token" +msgstr "Token opslaan" + +msgid "Validates the token against ListenBrainz." +msgstr "Valideert de token bij ListenBrainz." + +msgid "Validating..." +msgstr "Valideren..." + +msgid "Connect ListenBrainz" +msgstr "ListenBrainz verbinden" + +msgid "Disconnect ListenBrainz" +msgstr "ListenBrainz loskoppelen" + +msgid "Sign out of ListenBrainz." +msgstr "Log uit bij ListenBrainz." + +msgid "DISCORD" +msgstr "DISCORD" + +msgid "Discord Rich Presence" +msgstr "Discord Rich Presence" + +msgid "Show what you're listening to as your Discord status. Opt-in — Discord must be running." +msgstr "Toon wat je aan het luisteren bent als je Discord-status. Opt-in — Discord moet actief zijn." + +msgid "Flatpak socket access" +msgstr "Flatpak-sockettoegang" + +msgid "Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\nflatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" +msgstr "Flatpak-installatie en de presence verschijnt niet? Geef toegang tot Discords IPC-socket en herstart daarna QBZ:\nflatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" + +msgid "Streaming quality" +msgstr "Streamingkwaliteit" + +msgid "The quality tier QBZ requests for playback." +msgstr "Het kwaliteitsniveau dat QBZ aanvraagt voor weergave." + +msgid "Limit quality to device" +msgstr "Kwaliteit beperken tot apparaat" + +msgid "Cap streaming quality at the output device's maximum sample rate." +msgstr "Begrens de streamingkwaliteit op de maximale samplefrequentie van het uitvoerapparaat." + +msgid "Maximum sample rate" +msgstr "Maximale samplefrequentie" + +msgid "The highest sample rate streamed tracks may use." +msgstr "De hoogste samplefrequentie die gestreamde nummers mogen gebruiken." + +msgid "OUTPUT" +msgstr "UITVOER" + +msgid "Audio backend" +msgstr "Audio-backend" + +msgid "The audio stack QBZ routes playback through." +msgstr "De audiostack waardoor QBZ de weergave routeert." + +msgid "Output device" +msgstr "Uitvoerapparaat" + +msgid "The DAC or sound device that receives audio." +msgstr "De DAC of het geluidsapparaat dat audio ontvangt." + +msgid "ALSA plugin" +msgstr "ALSA-plugin" + +msgid "How ALSA opens the device — hw is bit-perfect, plughw converts." +msgstr "Hoe ALSA het apparaat opent — hw is bit-perfect, plughw converteert." + +msgid "Hardware volume control" +msgstr "Hardware-volumeregeling" + +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Regel het volume via een mixer die door het geselecteerde ALSA-apparaat wordt aangeboden. Als die niet beschikbaar is, blijft directe weergave vast op 100%." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Dit ALSA-apparaat heeft geen compatibele hardwarevolumeregeling. Directe weergave blijft vast op 100%." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ kon het vorige audioapparaat niet volledig vrijgeven. Bekijk het audiologboek voor details." + +msgid "BIT-PERFECT" +msgstr "BIT-PERFECT" + +msgid "Exclusive mode" +msgstr "Exclusieve modus" + +msgid "Lock the device so no other app can resample it." +msgstr "Vergrendel het apparaat zodat geen andere app het kan resamplen." + +msgid "Reserve DAC while running" +msgstr "DAC reserveren tijdens gebruik" + +msgid "Hold the device reserved so other apps can't grab it." +msgstr "Houd het apparaat gereserveerd zodat andere apps het niet kunnen overnemen." + +msgid "DAC passthrough" +msgstr "DAC-passthrough" + +msgid "Send the bitstream untouched to the DAC." +msgstr "Stuur de bitstream onaangetast naar de DAC." + +msgid "Force bit-perfect" +msgstr "Bit-perfect forceren" + +msgid "Pin the PipeWire quantum and rate for the active track." +msgstr "Pin het PipeWire-quantum en de frequentie voor het actieve nummer." + +msgid "Allow quality fallback" +msgstr "Kwaliteitsterugval toestaan" + +msgid "Drop to a lower tier when the requested one is unavailable." +msgstr "Val terug naar een lager niveau wanneer het gevraagde niet beschikbaar is." + +msgid "HiFi Wizard" +msgstr "HiFi-wizard" + +msgid "Auto-detect your DACs and set up bit-perfect playback, step by step." +msgstr "Detecteer automatisch je DAC's en stel stap voor stap bit-perfecte weergave in." + +msgid "Open Wizard" +msgstr "Wizard openen" + +msgid "STARTUP" +msgstr "OPSTARTEN" + +msgid "Sync audio settings on startup" +msgstr "Audio-instellingen synchroniseren bij opstarten" + +msgid "Reload saved audio settings into the player when QBZ launches." +msgstr "Laad opgeslagen audio-instellingen opnieuw in de speler wanneer QBZ start." + +msgid "Lock output device" +msgstr "Uitvoerapparaat vergrendelen" + +msgid "Keep external routing intact — skip switching the default sink." +msgstr "Houd externe routering intact — sla het wisselen van de standaardsink over." + +msgid "THEME" +msgstr "THEMA" + +msgid "Theme" +msgstr "Thema" + +msgid "Album header gradient" +msgstr "Albumheader-gradiënt" + +msgid "Use artwork-derived blur as a backdrop in album and artist detail views." +msgstr "Gebruik een van de albumhoes afgeleide vervaging als achtergrond in album- en artiestweergaven." + +msgid "Intelligent Search" +msgstr "Intelligent zoeken" + +msgid "Smart search cache, ranking, and the search preview dropdown. On by default." +msgstr "Slimme zoekcache, rangschikking en het zoekvoorbeeld-dropdownmenu. Standaard ingeschakeld." + +msgid "Generate a color theme from your system wallpaper or a custom image" +msgstr "Genereer een kleurthema van je systeemachtergrond of een eigen afbeelding" + +msgid "Select Image..." +msgstr "Afbeelding selecteren..." + +msgid "Detected: " +msgstr "Gedetecteerd: " + +msgid "Experimental: theme may not match your system exactly." +msgstr "Experimenteel: het thema komt mogelijk niet exact overeen met je systeem." + +msgid "Regenerate" +msgstr "Opnieuw genereren" + +msgid "TYPOGRAPHY & LANGUAGE" +msgstr "TYPOGRAFIE & TAAL" + +msgid "Language" +msgstr "Taal" + +msgid "UI Scale" +msgstr "UI-schaal" + +msgid "Immersive search" +msgstr "Immersief zoeken" + +msgid "What selecting a result in the Immersive search does. Disabled turns the in-immersive search off." +msgstr "Wat het selecteren van een resultaat in het immersieve zoeken doet. Uitgeschakeld zet het zoeken in de immersieve weergave uit." + +msgid "Immersive default view" +msgstr "Standaard immersieve weergave" + +msgid "Which immersive view opens by default. 'Remember last' restores your last view." +msgstr "Welke immersieve weergave standaard opent. 'Laatste onthouden' herstelt je laatste weergave." + +msgid "LIBRARY & VISUALS" +msgstr "BIBLIOTHEEK & VISUALS" + +msgid "Show navigation in sidebar" +msgstr "Navigatie in zijbalk tonen" + +msgid "Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar." +msgstr "Verplaats de secties Ontdekken, Bibliotheek, Lokale bibliotheek en Mijn QBZ uit de header naar de zijbalk." + +msgid "My QBZ" +msgstr "Mijn QBZ" + +msgid "Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default." +msgstr "Hernoem de Mijn QBZ-hub. Laat de naam leeg (of druk op reset) om de standaard te herstellen." + +msgid "Show playlist cover collages in sidebar" +msgstr "Hoescollages van afspeellijsten in zijbalk tonen" + +msgid "Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images." +msgstr "Toon een 2×2-miniatuur van hoezen naast elke afspeellijst. Schakel uit op tragere machines om de extra afbeeldingen over te slaan." + +msgid "Show album artwork in Local Library track list" +msgstr "Albumhoezen tonen in de nummerlijst van de lokale bibliotheek" + +msgid "Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost." +msgstr "Toon de albumhoes-miniatuur naast elk nummer in de weergaven Nummers en Mappen. Standaard uit — bij grote bibliotheken kost elke rij extra beeldverwerking." + +msgid "NOTIFICATIONS" +msgstr "MELDINGEN" + +msgid "In-app toasts notifications" +msgstr "In-app toastmeldingen" + +msgid "System Notifications" +msgstr "Systeemmeldingen" + +msgid "WINDOW TITLE" +msgstr "VENSTERTITEL" + +msgid "Show track in window title" +msgstr "Nummer in venstertitel tonen" + +msgid "Title template" +msgstr "Titelsjabloon" + +msgid "Placeholders: {{artist}}, {{track}}, {{album}}" +msgstr "Placeholders: {{artist}}, {{track}}, {{album}}" + +msgid "TITLE BAR" +msgstr "TITELBALK" + +msgid "Use system title bar" +msgstr "Systeemtitelbalk gebruiken" + +msgid "Replace QBZ custom title bar with your system's native window decorations. Changing this will restart the app." +msgstr "Vervang de aangepaste QBZ-titelbalk door de native vensterdecoraties van je systeem. Wijzigen herstart de app." + +msgid "Hide title bar" +msgstr "Titelbalk verbergen" + +msgid "Hide the title bar completely (for tiling window manager users)" +msgstr "Verberg de titelbalk volledig (voor gebruikers van tiling window managers)" + +msgid "WINDOW CONTROLS" +msgstr "VENSTERKNOPPEN" + +msgid "Window controls position" +msgstr "Positie van vensterknoppen" + +msgid "Place the window control buttons on the left or right side of the title bar" +msgstr "Plaats de vensterknoppen links of rechts op de titelbalk" + +msgid "Show window controls" +msgstr "Vensterknoppen tonen" + +msgid "Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these." +msgstr "Toon de knoppen minimaliseren, maximaliseren en sluiten in de titelbalk. Schakel uit als je window manager dit afhandelt." + +msgid "PLAYER VIEWS" +msgstr "SPELERWEERGAVEN" + +msgid "Show volume +/- buttons" +msgstr "Volume +/- knoppen tonen" + +msgid "Add discrete plus and minus buttons next to the volume slider in the player bar." +msgstr "Voeg aparte plus- en minknoppen toe naast de volumeschuif in de spelerbalk." + +msgid "Mini player default view" +msgstr "Standaardweergave minispeler" + +msgid "Startup page" +msgstr "Opstartpagina" + +msgid "Choose which page to show when the app starts" +msgstr "Kies welke pagina wordt getoond wanneer de app start" + +msgid "Show Purchases" +msgstr "Aankopen tonen" + +msgid "Show the Purchases section in the sidebar for browsing and downloading your purchased music" +msgstr "Toon de sectie Aankopen in de zijbalk om je gekochte muziek te bekijken en te downloaden" + +msgid "MENU BAR" +msgstr "MENUBALK" + +msgid "SYSTEM TRAY" +msgstr "SYSTEEMVAK" + +msgid "Enable tray icon" +msgstr "Systeemvakicoon inschakelen" + +msgid "Show icon in system tray (requires restart)" +msgstr "Toon icoon in het systeemvak (vereist herstart)" + +msgid "Close to tray" +msgstr "Sluiten naar systeemvak" + +msgid "Keep playing in the menu bar instead of quitting when you close the window" +msgstr "Blijf afspelen in de menubalk in plaats van af te sluiten wanneer je het venster sluit" + +msgid "Keep playing in the tray instead of quitting when you close the window" +msgstr "Blijf afspelen in het systeemvak in plaats van af te sluiten wanneer je het venster sluit" + +msgid "Hide Dock icon when closed to menu bar" +msgstr "Dock-icoon verbergen bij sluiten naar menubalk" + +msgid "Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)" +msgstr "Draai als menubalk-app terwijl het venster gesloten is. Uit behoudt het Dock-icoon (zoals Spotify)" + +msgid "Tray icon variant" +msgstr "Variant systeemvakicoon" + +msgid "Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo" +msgstr "Kies een mono-glyph passend bij je paneel (Plasma, GNOME's permanent donkere bovenbalk) of het vinyllogo in kleur" + +msgid "IMMERSIVE" +msgstr "Meeslepend" + +msgid "Background effect" +msgstr "Achtergrondeffect" + +msgid "Controls how the blurred artwork background is rendered in immersive mode." +msgstr "Bepaalt hoe de vervaagde hoesachtergrond wordt weergegeven in immersieve modus." + +msgid "Immersive Panels" +msgstr "Immersieve panelen" + +msgid "Enable or disable individual immersive view panels" +msgstr "Schakel afzonderlijke panelen van de immersieve weergave in of uit" + +msgid "Disabled panels are hidden from the visualizer menu so you don't land on them by mistake." +msgstr "Uitgeschakelde panelen worden verborgen in het visualizermenu zodat je er niet per ongeluk op belandt." + +msgid "Animation Frame Rate" +msgstr "Animatie-framerate" + +msgid "Per-animation FPS control for immersive mode" +msgstr "FPS-regeling per animatie voor de immersieve modus" + +msgid "Configure frame rate per animation. Lower values save CPU. Requires re-entering immersive mode to apply." +msgstr "Stel de framerate per animatie in. Lagere waarden besparen CPU. Vereist opnieuw openen van de immersieve modus." + +msgid "PLAYLISTS" +msgstr "Afspeellijsten" + +msgid "No playlists yet." +msgstr "Nog geen afspeellijsten." + +msgid "Manage playlists" +msgstr "Afspeellijsten beheren" + +msgid "Remove from queue" +msgstr "Uit wachtrij verwijderen" + +msgid "Stop after this" +msgstr "Stoppen na dit nummer" + +msgid "Remove all after" +msgstr "Alles hierna verwijderen" + +msgid "History" +msgstr "Geschiedenis" + +msgid "NOW PLAYING" +msgstr "NU AAN HET AFSPELEN" + +msgid "UP NEXT ({} of {}, {} left)" +msgstr "HIERNA ({} van {}, {} resterend)" + +msgid "Page {} of {}" +msgstr "Pagina {} van {}" + +msgid "Your queue is empty" +msgstr "Je wachtrij is leeg" + +msgid "Play an album or track to get started" +msgstr "Speel een album of nummer af om te beginnen" + +msgid "No tracks match your search" +msgstr "Geen nummers komen overeen met je zoekopdracht" + +msgid "RECENTLY PLAYED" +msgstr "ONLANGS AFGESPEELD" + +msgid "Nothing played yet" +msgstr "Nog niets afgespeeld" + +msgid "Search queue" +msgstr "Wachtrij doorzoeken" + +msgid "Auto-follow" +msgstr "Automatisch volgen" + +msgid "Font" +msgstr "Lettertype" + +msgid "System" +msgstr "Systeem" + +msgid "LINE Seed JP" +msgstr "LINE Seed JP" + +msgid "Montserrat" +msgstr "Montserrat" + +msgid "Noto Sans" +msgstr "Noto Sans" + +msgid "Source Sans 3" +msgstr "Source Sans 3" + +msgid "Size" +msgstr "Grootte" + +msgid "S" +msgstr "S" + +msgid "M" +msgstr "M" + +msgid "L" +msgstr "L" + +msgid "XL" +msgstr "XL" + +msgid "Active color" +msgstr "Actieve kleur" + +msgid "Uppercase" +msgstr "Hoofdletters" + +msgid "Dimming" +msgstr "Dimmen" + +msgid "Soft" +msgstr "Zacht" + +msgid "Strong" +msgstr "Sterk" + +msgid "Copy lyrics" +msgstr "Songtekst kopiëren" + +msgid "Reset" +msgstr "Reset" + +msgid "Previous" +msgstr "Vorige" + +msgid "Pause" +msgstr "Pauzeren" + +msgid "Repeat" +msgstr "Herhalen" + +msgid "Add to…" +msgstr "Toevoegen aan…" + +msgid "Add album to collection" +msgstr "Album aan verzameling toevoegen" + +msgid "Remove album from collection" +msgstr "Album uit verzameling verwijderen" + +msgid "Remove from library" +msgstr "Verwijderen uit bibliotheek" + +msgid "{} (not playable)" +msgstr "{} (niet afspeelbaar)" + +msgid "Cast" +msgstr "Casten" + +msgid "Connected to" +msgstr "Verbonden met" + +msgid "Streaming {} · {}" +msgstr "Streamt {} · {}" + +msgid "No devices found" +msgstr "Geen apparaten gevonden" + +msgid "Looking for Chromecast & DLNA on your network" +msgstr "Zoeken naar Chromecast & DLNA op je netwerk" + +msgid "Display settings" +msgstr "Weergave-instellingen" + +msgid "Fetching lyrics..." +msgstr "Songtekst ophalen..." + +msgid "No lyrics available" +msgstr "Geen songtekst beschikbaar" + +msgid "Missing lyrics? You can contribute them to LRCLIB:" +msgstr "Ontbrekende songtekst? Je kunt deze bijdragen aan LRCLIB:" + +msgid "Lyrics unavailable offline" +msgstr "Songtekst niet beschikbaar offline" + +msgid "Lyrics source" +msgstr "Songtekstbron" + +msgid "Qobuz Connect" +msgstr "Qobuz Connect" + +msgid "Connected" +msgstr "Verbonden" + +msgid "Disconnected" +msgstr "Verbinding verbroken" + +msgid "Playing on: {}" +msgstr "Speelt af op: {}" + +msgid "Looking for devices…" +msgstr "Apparaten zoeken…" + +msgid "Make sure your renderer is on the same network" +msgstr "Zorg dat je renderer zich op hetzelfde netwerk bevindt" + +msgid "Play here" +msgstr "Hier afspelen" + +msgid "Audio settings" +msgstr "Audio-instellingen" + +msgid "Normalization" +msgstr "Normalisatie" + +msgid "Level loudness across tracks" +msgstr "Egaliseer de luidheid tussen nummers" + +msgid "Gapless" +msgstr "Gapless" + +msgid "Seamless track transitions" +msgstr "Naadloze overgangen tussen nummers" + +msgid "Now Playing view" +msgstr "Nu aan het afspelen-weergave" + +msgid "New" +msgstr "Nieuw" + +msgid "Classic" +msgstr "Klassiek" + +msgid "Miniplayer" +msgstr "Minispeler" + +msgid "Immersive" +msgstr "Meeslepend" + +msgid "Unmute" +msgstr "Dempen opheffen" + +msgid "Mute" +msgstr "Dempen" + +msgid "Volume" +msgstr "Volume" + +msgid "Qobuz Connect: On" +msgstr "Qobuz Connect: aan" + +msgid "Qobuz Connect: Off" +msgstr "Qobuz Connect: uit" + +msgid "Volume down" +msgstr "Volume omlaag" + +msgid "Volume up" +msgstr "Volume omhoog" + +msgid "Nothing playing" +msgstr "Niets aan het afspelen" + +msgid "Go to where this is playing from" +msgstr "Ga naar waar dit vandaan wordt afgespeeld" + +msgid "Casting" +msgstr "Casten" + +msgid "Output mode" +msgstr "Uitvoermodus" + +msgid "Move to root" +msgstr "Naar hoofdmap verplaatsen" + +msgid "No folders yet." +msgstr "Nog geen mappen." + +msgid "Discover" +msgstr "Ontdekken" + +msgid "Mixtapes" +msgstr "Mixtapes" + +msgid "Collections" +msgstr "Verzamelingen" + +msgid "Search playlists" +msgstr "Afspeellijsten zoeken" + +msgid "Sort by" +msgstr "Sorteren op" + +msgid "Name (A-Z)" +msgstr "Naam (A-Z)" + +msgid "# of tracks" +msgstr "Aantal nummers" + +msgid "Refresh" +msgstr "Vernieuwen" + +msgid "Logged out" +msgstr "Uitgelogd" + +msgid "Offline (manual)" +msgstr "Offline (handmatig)" + +msgid "Offline (hard)" +msgstr "Offline (hard)" + +msgid "You're signed out. Sign-in needs a connection." +msgstr "Je bent uitgelogd. Inloggen vereist een verbinding." + +msgid "Connection available — sign back in to Qobuz." +msgstr "Verbinding beschikbaar — log opnieuw in bij Qobuz." + +msgid "You're signed out — sign back in to Qobuz." +msgstr "Je bent uitgelogd — log opnieuw in bij Qobuz." + +msgid "No internet connection — your local library and downloads keep working." +msgstr "Geen internetverbinding — je lokale bibliotheek en downloads blijven werken." + +msgid "Sign in" +msgstr "Inloggen" + +msgid "Offline mode" +msgstr "Offline modus" + +msgid "Enter" +msgstr "Enter" + +msgid "Guest" +msgstr "Gast" + +msgid "Not signed in" +msgstr "Niet ingelogd" + +msgid "Keyboard Shortcuts" +msgstr "Sneltoetsen" + +msgid "Documentation" +msgstr "Documentatie" + +msgid "Report an Issue" +msgstr "Probleem melden" + +msgid "Log Out" +msgstr "Uitloggen" + +msgid "Create Mixtape" +msgstr "Mixtape aanmaken" + +msgid "Create Collection" +msgstr "Verzameling aanmaken" + +msgid "NAME" +msgstr "Naam" + +msgid "KIND" +msgstr "SOORT" + +msgid "+ New Mixtape" +msgstr "+ Nieuwe mixtape" + +msgid "+ New Collection" +msgstr "+ Nieuwe verzameling" + +msgid "Rename" +msgstr "Hernoemen" + +msgid "Edit description" +msgstr "Beschrijving bewerken" + +msgid "Delete \"{}\"? This cannot be undone." +msgstr "\"{}\" verwijderen? Dit kan niet ongedaan worden gemaakt." + +msgid "All ({})" +msgstr "Alles ({})" + +msgid "Random queue" +msgstr "Willekeurige wachtrij" + +msgid "Create a random queue with songs from this collection. How many songs do you want?" +msgstr "Maak een willekeurige wachtrij met nummers uit deze verzameling. Hoeveel nummers wil je?" + +msgid "Number of songs" +msgstr "Aantal nummers" + +msgid "{} songs available" +msgstr "{} nummers beschikbaar" + +msgid "No mixtapes yet" +msgstr "Nog geen mixtapes" + +msgid "Position" +msgstr "Positie" + +msgid "Items" +msgstr "Items" + +msgid "Recently updated" +msgstr "Onlangs bijgewerkt" + +msgid "No results found" +msgstr "Geen resultaten gevonden" + +msgid "No collections yet" +msgstr "Nog geen verzamelingen" + +msgid "All kinds" +msgstr "Alle soorten" + +msgid "Artist Collections" +msgstr "Artiestenverzamelingen" + +msgid "Remove from collection" +msgstr "Uit verzameling verwijderen" + +msgid "Not found" +msgstr "Niet gevonden" + +msgid "Upload cover" +msgstr "Hoes uploaden" + +msgid "Clear custom cover" +msgstr "Aangepaste hoes wissen" + +msgid "Album shuffle" +msgstr "Album shuffle" + +msgid "In order" +msgstr "Op volgorde" + +msgid "Convert to Collection" +msgstr "Converteren naar verzameling" + +msgid "Convert to Mixtape" +msgstr "Converteren naar mixtape" + +msgid "No items yet. Add albums, tracks, or playlists from their detail pages." +msgstr "Nog geen items. Voeg albums, nummers of afspeellijsten toe vanaf hun detailpagina's." + +msgid "1 filter" +msgstr "1 filter" + +msgid "{} filters" +msgstr "{} filters" + +msgid "Type" +msgstr "Type" + +msgid "All types" +msgstr "Alle types" + +msgid "Track" +msgstr "Nummer" + +msgid "Qobuz" +msgstr "Qobuz" + +msgid "Plex" +msgstr "Plex" + +msgid "Item" +msgstr "Item" + +msgid "Already added" +msgstr "Al toegevoegd" + +msgid "ADD TO MIXTAPE/COLLECTION" +msgstr "Toevoegen aan mixtape/verzameling" + +msgid "No matches." +msgstr "Geen overeenkomsten." + +msgid "No mixtapes or collections yet." +msgstr "Nog geen mixtapes of verzamelingen." + +msgid "EP" +msgstr "EP" + +msgid "Single" +msgstr "Single" + +msgid "Live" +msgstr "Live" + +msgid "Compilation" +msgstr "Compilatie" + +msgid "Use auto-detected" +msgstr "Automatisch gedetecteerd gebruiken" + +msgid "alternate" +msgstr "alternatief" + +msgid "compilation" +msgstr "compilatie" + +msgid "BUILD ARTIST COLLECTION" +msgstr "Artiestenverzameling opbouwen" + +msgid "ORDER BY" +msgstr "Sorteren op" + +msgid "Release date" +msgstr "Releasedatum" + +msgid "Manual" +msgstr "Handmatig" + +msgid "Loading..." +msgstr "Laden..." + +msgid "ALBUM" +msgstr "Album" + +msgid "{} of {} album selected" +msgid_plural "{} of {} albums selected" +msgstr[0] "{} van {} album geselecteerd" +msgstr[1] "{} van {} albums geselecteerd" + +msgid "Now Playing" +msgstr "Nu aan het afspelen" + +msgid "No lyrics" +msgstr "Geen songtekst" + +msgid "No track selected" +msgstr "Geen nummer geselecteerd" + +msgid "Loading suggestions..." +msgstr "Suggesties laden..." + +msgid "Failed to load suggestions" +msgstr "Suggesties laden mislukt" + +msgid "Recommended tracks" +msgstr "Aanbevolen nummers" + +msgid "Exit Immersive" +msgstr "Immersieve modus verlaten" + +msgid "FOCUS" +msgstr "FOCUS" + +msgid "Plasma" +msgstr "Plasma" + +msgid "Aurora" +msgstr "Aurora" + +msgid "SPLIT" +msgstr "GESPLITST" + +msgid "Track Info" +msgstr "Nummerinformatie" + +msgid "Suggestions" +msgstr "Suggesties" + +msgid "UP NEXT" +msgstr "HIERNA" + +msgid "Tracks you play will appear here" +msgstr "Nummers die je afspeelt verschijnen hier" + +msgid "No track playing" +msgstr "Geen nummer aan het afspelen" + +msgid "Queue is empty" +msgstr "Wachtrij is leeg" + +msgid "Search albums & tracks…" +msgstr "Zoeken naar albums & nummers…" + +msgid "Acclaimed Releases" +msgstr "Geprezen releases" + +msgid "No award-winning releases yet." +msgstr "Nog geen bekroonde releases." + +msgid "Award" +msgstr "Prijs" + +msgid "Unfollow award" +msgstr "Prijs ontvolgen" + +msgid "Follow award" +msgstr "Prijs volgen" + +msgid "See All" +msgstr "Alles bekijken" + +msgid "Other awards" +msgstr "Andere prijzen" + +msgid "{} item needs attention before bit-perfect playback will work." +msgid_plural "{} items need attention before bit-perfect playback will work." +msgstr[0] "{} item vereist aandacht voordat bit-perfecte weergave werkt." +msgstr[1] "{} items vereisen aandacht voordat bit-perfecte weergave werkt." + +msgid "Removed {} artist from blacklist" +msgid_plural "Removed {} artists from blacklist" +msgstr[0] "{} artiest van de zwarte lijst verwijderd" +msgstr[1] "{} artiesten van de zwarte lijst verwijderd" + +msgid "Removed {} folder" +msgid_plural "Removed {} folders" +msgstr[0] "{} map verwijderd" +msgstr[1] "{} mappen verwijderd" + +msgid "Scan complete ({} file skipped)" +msgid_plural "Scan complete ({} files skipped)" +msgstr[0] "Scan voltooid ({} bestand overgeslagen)" +msgstr[1] "Scan voltooid ({} bestanden overgeslagen)" + +msgid "{} album" +msgid_plural "{} albums" +msgstr[0] "{} album" +msgstr[1] "{} albums" + +msgid "{} item" +msgid_plural "{} items" +msgstr[0] "{} item" +msgstr[1] "{} items" + +msgid "{} duplicate skipped" +msgid_plural "{} duplicates skipped" +msgstr[0] "{} duplicaat overgeslagen" +msgstr[1] "{} duplicaten overgeslagen" + +msgid "Removed {} item" +msgid_plural "Removed {} items" +msgstr[0] "{} item verwijderd" +msgstr[1] "{} items verwijderd" + +msgid "Caching {} track offline…" +msgid_plural "Caching {} tracks offline…" +msgstr[0] "{} nummer offline cachen…" +msgstr[1] "{} nummers offline cachen…" + +msgid "(Re)start the PipeWire audio services" +msgstr "(Her)start de PipeWire-audioservices" + +msgid "({} local)" +msgstr "({} lokaal)" + +msgid "176.4 kHz" +msgstr "176,4 kHz" + +msgid "192 kHz" +msgstr "192 kHz" + +msgid "352.8 kHz" +msgstr "352,8 kHz" + +msgid "384 kHz" +msgstr "384 kHz" + +msgid "44.1 kHz" +msgstr "44,1 kHz" + +msgid "48 kHz" +msgstr "48 kHz" + +msgid "88.2 kHz" +msgstr "88,2 kHz" + +msgid "96 kHz" +msgstr "96 kHz" + +msgid "A fresh mix every week." +msgstr "Elke week een verse mix." + +msgid "Add a folder before scanning" +msgstr "Voeg een map toe voordat je scant" + +msgid "Added to queue" +msgstr "Aan wachtrij toegevoegd" + +msgid "Added {} to {}" +msgstr "{} toegevoegd aan {}" + +msgid "Added {} to {} ({})" +msgstr "{} toegevoegd aan {} ({})" + +msgid "Adding tracks to playlist..." +msgstr "Nummers aan afspeellijst toevoegen..." + +msgid "Adding tracks: {} / {}" +msgstr "Nummers toevoegen: {} / {}" + +msgid "Album folder not found on disk" +msgstr "Albummap niet gevonden op schijf" + +msgid "Album metadata saved" +msgstr "Albummetadata opgeslagen" + +msgid "Album title is required" +msgstr "Albumtitel is verplicht" + +msgid "Albums of the Week" +msgstr "Albums van de week" + +msgid "Albums on Local Library" +msgstr "Albums in lokale bibliotheek" + +msgid "Already in {}" +msgstr "Al in {}" + +msgid "Always skip track" +msgstr "Nummer altijd overslaan" + +msgid "Always try lowest quality" +msgstr "Altijd laagste kwaliteit proberen" + +msgid "April" +msgstr "April" + +msgid "Are you absolutely sure?" +msgstr "Weet je het absoluut zeker?" + +msgid "Artist Spotlight" +msgstr "Artiest in de schijnwerpers" + +msgid "Artists on Local Library" +msgstr "Artiesten in lokale bibliotheek" + +msgid "Ask me" +msgstr "Vraag het mij" + +msgid "August" +msgstr "Augustus" + +msgid "Authorize QBZ in your browser, then click \"Finish\"" +msgstr "Autoriseer QBZ in je browser en klik daarna op \"Voltooien\"" + +msgid "Available tracks only — offline" +msgstr "Alleen beschikbare nummers — offline" + +msgid "Bit-perfect (Hardware / Digital)" +msgstr "Bit-perfect (hardware / digitaal)" + +msgid "Blacklist disabled" +msgstr "Zwarte lijst uitgeschakeld" + +msgid "Blacklist enabled" +msgstr "Zwarte lijst ingeschakeld" + +msgid "Booklet open task failed: {}" +msgstr "Openen van boekje mislukt: {}" + +msgid "COLLECTION" +msgstr "VERZAMELING" + +msgid "Cache cleared" +msgstr "Cache gewist" + +msgid "Cached for offline" +msgstr "Gecacht voor offline" + +msgid "Cannot convert this kind" +msgstr "Dit soort kan niet worden geconverteerd" + +msgid "Checked {} tracks - all OK" +msgstr "{} nummers gecontroleerd - alles OK" + +msgid "Checking playlist link..." +msgstr "Afspeellijstlink controleren..." + +msgid "Checking your audio stack…" +msgstr "Je audiostack controleren…" + +msgid "Couldn't load the test tracks (offline?)" +msgstr "Kan de testtracks niet laden (offline?)" + +msgid "Choose a cover image" +msgstr "Kies een hoesafbeelding" + +msgid "Choose a folder to play" +msgstr "Kies een map om af te spelen" + +msgid "Choose a {} icon" +msgstr "Kies een {}-icoon" + +msgid "Cleaning up missing files..." +msgstr "Ontbrekende bestanden opschonen..." + +msgid "Cleanup failed" +msgstr "Opschonen mislukt" + +msgid "Clear Plex cache?" +msgstr "Plex-cache wissen?" + +msgid "Clear library database?" +msgstr "Bibliotheekdatabase wissen?" + +msgid "Code copied" +msgstr "Code gekopieerd" + +msgid "Compilations" +msgstr "Compilaties" + +msgid "Connected as {}" +msgstr "Verbonden als {}" + +msgid "Connected to {} (v{})" +msgstr "Verbonden met {} (v{})" + +msgid "Continue Listening" +msgstr "Verder luisteren" + +msgid "Converted" +msgstr "Geconverteerd" + +msgid "Couldn't add folder" +msgstr "Kan map niet toevoegen" + +msgid "Couldn't change folder location (path may already exist)" +msgstr "Kan maplocatie niet wijzigen (pad bestaat mogelijk al)" + +msgid "Couldn't clean up missing files" +msgstr "Kan ontbrekende bestanden niet opschonen" + +msgid "Couldn't clear the Plex cache" +msgstr "Kan de Plex-cache niet wissen" + +msgid "Couldn't clear the library database" +msgstr "Kan de bibliotheekdatabase niet wissen" + +msgid "Couldn't clear the offline cache" +msgstr "Kan de offline cache niet wissen" + +msgid "Couldn't create the Qobuz playlist" +msgstr "Kan de Qobuz-afspeellijst niet aanmaken" + +msgid "Couldn't load library folders" +msgstr "Kan bibliotheekmappen niet laden" + +msgid "Couldn't load that album" +msgstr "Kan dat album niet laden" + +msgid "Couldn't load that playlist" +msgstr "Kan die afspeellijst niet laden" + +msgid "Couldn't load that track" +msgstr "Kan dat nummer niet laden" + +msgid "Couldn't load this album" +msgstr "Kan dit album niet laden" + +msgid "Couldn't load this artist" +msgstr "Kan deze artiest niet laden" + +msgid "Couldn't load this collection" +msgstr "Kan deze verzameling niet laden" + +msgid "Couldn't load this playlist" +msgstr "Kan deze afspeellijst niet laden" + +msgid "Couldn't open that folder" +msgstr "Kan die map niet openen" + +msgid "Couldn't save folder settings" +msgstr "Kan mapinstellingen niet opslaan" + +msgid "Couldn't save metadata: {}" +msgstr "Kan metadata niet opslaan: {}" + +msgid "Cover removed" +msgstr "Hoes verwijderd" + +msgid "Cover updated" +msgstr "Hoes bijgewerkt" + +msgid "Creating playlist..." +msgstr "Afspeellijst aanmaken..." + +msgid "Critics' Picks" +msgstr "Keuze van critici" + +msgid "DAC: {} · {} · {} ch" +msgstr "DAC: {} · {} · {} kan." + +msgid "December" +msgstr "December" + +msgid "Defaults" +msgstr "Standaardwaarden" + +msgid "Disconnect from Plex?" +msgstr "Verbinding met Plex verbreken?" + +msgid "EPs & Singles" +msgstr "EP's & Singles" + +msgid "Enable PipeWire in your NixOS configuration" +msgstr "Schakel PipeWire in in je NixOS-configuratie" + +msgid "Enter a title or artist to search" +msgstr "Voer een titel of artiest in om te zoeken" + +msgid "Enter code {} at the Plex sign-in page" +msgstr "Voer code {} in op de Plex-aanmeldpagina" + +msgid "Error: {}" +msgstr "Fout: {}" + +msgid "Error: {} (did you authorize in the browser?)" +msgstr "Fout: {} (heb je geautoriseerd in de browser?)" + +msgid "Essentials by Genre" +msgstr "Essentials per genre" + +msgid "Every track needs a title" +msgstr "Elk nummer heeft een titel nodig" + +msgid "Failed to cast track" +msgstr "Casten van nummer mislukt" + +msgid "Failed to change play mode" +msgstr "Wijzigen van afspeelmodus mislukt" + +msgid "Failed to clear blacklist" +msgstr "Wissen van zwarte lijst mislukt" + +msgid "Failed to clear lyrics cache" +msgstr "Wissen van songtekstencache mislukt" + +msgid "Failed to create temp dir: {}" +msgstr "Aanmaken van tijdelijke map mislukt: {}" + +msgid "Failed to decode booklet page: {}" +msgstr "Decoderen van boekjespagina mislukt: {}" + +msgid "Failed to delete" +msgstr "Verwijderen mislukt" + +msgid "Failed to fetch booklet: {}" +msgstr "Ophalen van boekje mislukt: {}" + +msgid "Failed to fetch metadata" +msgstr "Ophalen van metadata mislukt" + +msgid "Failed to read booklet: {}" +msgstr "Lezen van boekje mislukt: {}" + +msgid "Failed to remove artist" +msgstr "Verwijderen van artiest mislukt" + +msgid "Failed to remove cover" +msgstr "Verwijderen van hoes mislukt" + +msgid "Failed to remove items" +msgstr "Verwijderen van items mislukt" + +msgid "Failed to rename" +msgstr "Hernoemen mislukt" + +msgid "Failed to save description" +msgstr "Opslaan van beschrijving mislukt" + +msgid "Failed to toggle blacklist" +msgstr "Omschakelen van zwarte lijst mislukt" + +msgid "Failed to upload cover" +msgstr "Uploaden van hoes mislukt" + +msgid "Failed to write booklet: {}" +msgstr "Schrijven van boekje mislukt: {}" + +msgid "Library Albums" +msgstr "Bibliotheekalbums" + +msgid "February" +msgstr "Februari" + +msgid "File not available — is the drive mounted?" +msgstr "Bestand niet beschikbaar — is de schijf aangekoppeld?" + +msgid "Files not available — is the drive mounted?" +msgstr "Bestanden niet beschikbaar — is de schijf aangekoppeld?" + +msgid "Folder opened" +msgstr "Map geopend" + +msgid "Found {} tracks from {}." +msgstr "{} nummers gevonden van {}." + +msgid "From the same artist" +msgstr "Van dezelfde artiest" + +msgid "From your most-played playlists." +msgstr "Uit je meest afgespeelde afspeellijsten." + +msgid "Ideal Discography" +msgstr "Ideale discografie" + +msgid "Image" +msgstr "Afbeelding" + +msgid "Import failed: {}" +msgstr "Import mislukt: {}" + +msgid "Imported {} of {} tracks into QBZ." +msgstr "{} van {} nummers geïmporteerd in QBZ." + +msgid "Install the ALSA bridge so playback can reach PipeWire" +msgstr "Installeer de ALSA-brug zodat de weergave PipeWire kan bereiken" + +msgid "Install the PipeWire audio stack" +msgstr "Installeer de PipeWire-audiostack" + +msgid "Install the PipeWire tools (pw-dump)" +msgstr "Installeer de PipeWire-tools (pw-dump)" + +msgid "Install the Pulse compatibility tools (optional fallback)" +msgstr "Installeer de Pulse-compatibiliteitstools (optionele terugval)" + +msgid "January" +msgstr "Januari" + +msgid "July" +msgstr "Juli" + +msgid "June" +msgstr "Juni" + +msgid "Last.fm disconnected" +msgstr "Last.fm losgekoppeld" + +msgid "Last.fm sign-in failed to start" +msgstr "Starten van Last.fm-aanmelding mislukt" + +msgid "Library database cleared" +msgstr "Bibliotheekdatabase gewist" + +msgid "ListenBrainz disconnected" +msgstr "ListenBrainz losgekoppeld" + +msgid "Listening suggestions" +msgstr "Luistersuggesties" + +msgid "Loaded {} cached tracks" +msgstr "{} gecachte nummers geladen" + +msgid "Log in to cache tracks offline" +msgstr "Log in om nummers offline te cachen" + +msgid "Lossless: {}" +msgstr "Lossless: {}" + +msgid "Lyrics cache cleared" +msgstr "Songtekstencache gewist" + +msgid "Lyrics copied" +msgstr "Songtekst gekopieerd" + +msgid "MIXTAPE" +msgstr "MIXTAPE" + +msgid "March" +msgstr "Maart" + +msgid "Matching tracks: {} / {} ({} found)" +msgstr "Nummers matchen: {} / {} ({} gevonden)" + +msgid "May" +msgstr "Mei" + +msgid "Middle-click to pause" +msgstr "Middelklik om te pauzeren" + +msgid "Middle-click to play" +msgstr "Middelklik om af te spelen" + +msgid "Mixed queue — not cast to Qobuz Connect" +msgstr "Gemengde wachtrij — niet gecast naar Qobuz Connect" + +msgid "More From Your Library" +msgstr "Meer uit je bibliotheek" + +msgid "More Labels" +msgstr "Meer labels" + +msgid "Most Streamed" +msgstr "Meest gestreamd" + +msgid "Music Player\nNothing playing" +msgstr "Muziekspeler\nNiets aan het afspelen" + +msgid "Network folder detected" +msgstr "Netwerkmap gedetecteerd" + +msgid "Never" +msgstr "Nooit" + +msgid "New Releases" +msgstr "Nieuwe releases" + +msgid "Next Track" +msgstr "Volgend nummer" + +msgid "No limit" +msgstr "Geen limiet" + +msgid "No matching tracks found." +msgstr "Geen overeenkomende nummers gevonden." + +msgid "No sinks detected — reinstall the ALSA UCM profiles, then reboot" +msgstr "Geen sinks gedetecteerd — herinstalleer de ALSA UCM-profielen en herstart daarna" + +msgid "No top tracks available for this artist" +msgstr "Geen topnummers beschikbaar voor deze artiest" + +msgid "No tracks available offline" +msgstr "Geen nummers offline beschikbaar" + +msgid "Not available offline" +msgstr "Niet offline beschikbaar" + +msgid "Nothing playable in this playlist right now" +msgstr "Momenteel niets afspeelbaars in deze afspeellijst" + +msgid "November" +msgstr "November" + +msgid "October" +msgstr "Oktober" + +msgid "Offline cache cleared" +msgstr "Offline cache gewist" + +msgid "Offline cache is full — free space or raise the limit" +msgstr "Offline cache is vol — maak ruimte vrij of verhoog de limiet" + +msgid "Offline caching failed" +msgstr "Offline cachen mislukt" + +msgid "Offline listening period expired — reconnect to verify your subscription" +msgstr "Offline luisterperiode verlopen — maak opnieuw verbinding om je abonnement te verifiëren" + +msgid "On Local Library" +msgstr "In lokale bibliotheek" + +msgid "Opened folder ({} files skipped)" +msgstr "Map geopend ({} bestanden overgeslagen)" + +msgid "Other Outputs" +msgstr "Andere uitvoerapparaten" + +msgid "PLAYLIST" +msgstr "AFSPEELLIJST" + +msgid "Paste your ListenBrainz user token first" +msgstr "Plak eerst je ListenBrainz-gebruikerstoken" + +msgid "Play/Pause" +msgstr "Afspelen/pauzeren" + +msgid "Playing next" +msgstr "Wordt hierna afgespeeld" + +msgid "Playing {} of {}" +msgstr "Speelt {} van {}" + +msgid "Playlist ({} local)" +msgstr "Afspeellijst ({} lokaal)" + +msgid "Playlist created in Qobuz™." +msgstr "Afspeellijst aangemaakt in Qobuz™." + +msgid "Playlist uploaded to Qobuz" +msgstr "Afspeellijst geüpload naar Qobuz" + +msgid "Playlist: {}" +msgstr "Afspeellijst: {}" + +msgid "Plex link failed to start" +msgstr "Starten van Plex-koppeling mislukt" + +msgid "Plugin Hardware" +msgstr "Plugin-hardware" + +msgid "Press Accolades" +msgstr "Persprijzen" + +msgid "Previous Track" +msgstr "Vorig nummer" + +msgid "Purchase Only" +msgstr "Alleen aankoop" + +msgid "QBZ requesting {} · {}-bit" +msgstr "QBZ vraagt {} · {}-bit aan" + +msgid "Qobuz Connect is unavailable while offline" +msgstr "Qobuz Connect is niet beschikbaar in offline modus" + +msgid "Qobuz Connect renderer disconnected" +msgstr "Qobuz Connect-renderer losgekoppeld" + +msgid "Qobuz Connect renderer unreachable" +msgstr "Qobuz Connect-renderer onbereikbaar" + +msgid "Qobuzissimes" +msgstr "Qobuzissimes" + +msgid "Quit QBZ" +msgstr "QBZ afsluiten" + +msgid "Rate limited, try again shortly" +msgstr "Snelheidslimiet bereikt, probeer het zo opnieuw" + +msgid "Recently Played" +msgstr "Onlangs afgespeeld" + +msgid "Rediscover Your Library" +msgstr "Herdek je bibliotheek" + +msgid "Release Watch" +msgstr "Release Watch" + +msgid "Removed \"{}\" ({})" +msgstr "\"{}\" verwijderd ({})" + +msgid "Removed album from offline" +msgstr "Album verwijderd uit offline" + +msgid "Removed from offline" +msgstr "Verwijderd uit offline" + +msgid "Removed {} of {} tracks" +msgstr "{} van {} nummers verwijderd" + +msgid "Scan cancelled" +msgstr "Scan geannuleerd" + +msgid "Scan complete" +msgstr "Scan voltooid" + +msgid "Scan failed" +msgstr "Scan mislukt" + +msgid "Scanning track paths..." +msgstr "Nummerpaden scannen..." + +msgid "Scroll to adjust volume" +msgstr "Scroll om het volume aan te passen" + +msgid "Search failed: {}" +msgstr "Zoeken mislukt: {}" + +msgid "Searching Qobuz catalog..." +msgstr "Qobuz-catalogus doorzoeken..." + +msgid "Select music folder" +msgstr "Muziekmap selecteren" + +msgid "September" +msgstr "September" + +msgid "Show/Hide Window" +msgstr "Venster tonen/verbergen" + +msgid "Similar to {}" +msgstr "Vergelijkbaar met {}" + +msgid "Skipped: {}" +msgstr "Overgeslagen: {}" + +msgid "Some tracks can't be cast to Qobuz Connect" +msgstr "Sommige nummers kunnen niet naar Qobuz Connect worden gecast" + +msgid "Song Radio" +msgstr "Nummerradio" + +msgid "Split into {} playlists (Qobuz 2000-track limit)" +msgstr "Opgesplitst in {} afspeellijsten (Qobuz-limiet van 2000 nummers)" + +msgid "Start the sign-in first" +msgstr "Start eerst de aanmelding" + +msgid "Starting…" +msgstr "Starten…" + +msgid "System default" +msgstr "Systeemstandaard" + +msgid "TRACK" +msgstr "nummer" + +msgid "These items resolved to 0 playable tracks" +msgstr "Deze items leverden 0 afspeelbare nummers op" + +msgid "This action cannot be undone." +msgstr "Deze actie kan niet ongedaan worden gemaakt." + +msgid "This album has no playable tracks" +msgstr "Dit album heeft geen afspeelbare nummers" + +msgid "This collection resolved to 0 playable tracks" +msgstr "Deze verzameling leverde 0 afspeelbare nummers op" + +msgid "This item resolved to 0 playable tracks" +msgstr "Dit item leverde 0 afspeelbare nummers op" + +msgid "This modifies your audio files on disk and cannot be undone." +msgstr "Dit wijzigt je audiobestanden op schijf en kan niet ongedaan worden gemaakt." + +msgid "This playlist has no playable tracks" +msgstr "Deze afspeellijst heeft geen afspeelbare nummers" + +msgid "This removes ALL indexed tracks from the database. Your audio files are NOT deleted. You will need to re-scan your folders afterward." +msgstr "Dit verwijdert ALLE geïndexeerde nummers uit de database. Je audiobestanden worden NIET verwijderd. Je moet je mappen daarna opnieuw scannen." + +msgid "This removes cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Dit verwijdert gecachte Plex-bibliotheken en -nummers. Je aanmelding blijft behouden." + +msgid "This signs out of Plex and clears the locally cached libraries and tracks." +msgstr "Dit logt uit bij Plex en wist de lokaal gecachte bibliotheken en nummers." + +msgid "This track is no longer available" +msgstr "Dit nummer is niet meer beschikbaar" + +msgid "This track is not yet available" +msgstr "Dit nummer is nog niet beschikbaar" + +msgid "Track count differs from the result; titles applied by position" +msgstr "Aantal nummers wijkt af van het resultaat; titels toegepast op positie" + +msgid "Track not available offline" +msgstr "Nummer niet offline beschikbaar" + +msgid "Track not castable to Qobuz Connect" +msgstr "Nummer niet te casten naar Qobuz Connect" + +msgid "Track unavailable on Qobuz Connect" +msgstr "Nummer niet beschikbaar op Qobuz Connect" + +msgid "Tracks matched: {} / {}" +msgstr "Nummers gematcht: {} / {}" + +msgid "Unavailable Plex track" +msgstr "Niet-beschikbaar Plex-nummer" + +msgid "Unavailable track" +msgstr "Niet-beschikbaar nummer" + +msgid "Unknown" +msgstr "Onbekend" + +msgid "Unknown Artist" +msgstr "Onbekende artiest" + +msgid "Unknown source" +msgstr "Onbekende bron" + +msgid "Upcoming" +msgstr "Aankomend" + +msgid "Upload incomplete — local playlist kept" +msgstr "Upload onvolledig — lokale afspeellijst behouden" + +msgid "Various Artists" +msgstr "Diverse artiesten" + +msgid "Waiting for the DAC to start playing…" +msgstr "Wachten tot de DAC begint met afspelen…" + +msgid "Write tags to audio files?" +msgstr "Tags naar audiobestanden schrijven?" + +msgid "Writing tags to files isn't supported for CUE albums; use sidecar mode" +msgstr "Tags naar bestanden schrijven wordt niet ondersteund voor CUE-albums; gebruik sidecar-modus" + +msgid "Year must be a number" +msgstr "Jaar moet een getal zijn" + +msgid "You're offline — try again when connected" +msgstr "Je bent offline — probeer het opnieuw zodra je verbonden bent" + +msgid "Your audio stack is ready for bit-perfect playback." +msgstr "Je audiostack is klaar voor bit-perfecte weergave." + +msgid "Your queue is empty — add some tracks first, or press Play test." +msgstr "Je wachtrij is leeg — voeg eerst nummers toe, of druk op Test afspelen." + +msgid "by {}" +msgstr "Door {}" + +msgid "hw (Direct Hardware)" +msgstr "hw (directe hardware)" + +msgid "pcm (Most compatible)" +msgstr "pcm (meest compatibel)" + +msgid "plughw (Auto-convert)" +msgstr "plughw (automatisch converteren)" + +msgid "{} + {} more" +msgstr "{} + {} meer" + +msgid "{} albums · {} tracks" +msgstr "{} albums · {} nummers" + +msgid "{} h {} min" +msgstr "{} u {} min" + +msgid "{} libraries found" +msgstr "{} bibliotheken gevonden" + +msgid "{} min" +msgstr "{} min" + +msgid "{} removed from blacklist" +msgstr "{} van de zwarte lijst verwijderd" + +msgid "{} tracks loaded" +msgstr "{} nummers geladen" + +msgid "{} tracks · {}" +msgstr "{} nummers · {}" + +msgid "{} {}" +msgstr "{} {}" + +msgid "{} {}, {}" +msgstr "{} {}, {}" + +msgid "{} — Complete Discography" +msgstr "{} — volledige discografie" + +msgid "· Unlimited" +msgstr "· Onbeperkt" + +msgid "· of {}" +msgstr "· van {}" + +msgid "My Purchases" +msgstr "Mijn aankopen" + +msgid "Purchases are downloaded as DRM-free files. Availability depends on your Qobuz region." +msgstr "Aankopen worden gedownload als DRM-vrije bestanden. Beschikbaarheid hangt af van je Qobuz-regio." + +msgid "Search purchases…" +msgstr "Aankopen zoeken…" + +msgid "No purchases yet" +msgstr "Nog geen aankopen" + +msgid "No purchased tracks yet" +msgstr "Nog geen gekochte nummers" + +msgid "Filters" +msgstr "Filters" + +msgid "Availability" +msgstr "Beschikbaarheid" + +msgid "Hide unavailable" +msgstr "Niet-beschikbare verbergen" + +msgid "CD Quality" +msgstr "CD-kwaliteit" + +msgid "MP3" +msgstr "MP3" + +msgid "Downloads" +msgstr "Downloads" + +msgid "Hide downloaded" +msgstr "Gedownloade verbergen" + +msgid "Group: A–Z" +msgstr "Groeperen: A–Z" + +msgid "Sort: Date" +msgstr "Sorteren: datum" + +msgid "Sort: Artist" +msgstr "Sorteren: artiest" + +msgid "Sort: Album" +msgstr "Sorteren: album" + +msgid "Sort: Quality" +msgstr "Sorteren: kwaliteit" + +msgid "Date" +msgstr "Datum" + +msgid "Download all" +msgstr "Alles downloaden" + +msgid "Download complete" +msgstr "Download voltooid" + +msgid "Download cancelled ({}/{})" +msgstr "Download geannuleerd ({}/{})" + +msgid "Downloading {}/{}..." +msgstr "Downloaden {}/{}..." + +msgid "Select format" +msgstr "Formaat selecteren" + +msgid "Purchased on" +msgstr "Gekocht op" + +msgid "Add these downloaded tracks to your local library." +msgstr "Voeg deze gedownloade nummers toe aan je lokale bibliotheek." + +msgid "{} tracks" +msgstr "{} nummers" + +msgid "Choose folder" +msgstr "Map kiezen" + +msgid "Couldn't add to library" +msgstr "Kan niet aan bibliotheek toevoegen" + +msgid "Added to library" +msgstr "Toegevoegd aan bibliotheek" + +msgid "Purchased from Qobuz" +msgstr "Gekocht bij Qobuz" + +msgid "Couldn't load purchases. Check your connection." +msgstr "Kan aankopen niet laden. Controleer je verbinding." + +msgid "This track has no album to download" +msgstr "Dit nummer heeft geen album om te downloaden" + +msgid "Failed to start download. Please try again." +msgstr "Starten van download mislukt. Probeer het opnieuw." + +msgid "No downloadable formats available" +msgstr "Geen downloadbare formaten beschikbaar" + +msgid "Download goodies" +msgstr "Extra's downloaden" + +msgid "Suggested songs" +msgstr "Voorgestelde nummers" + +msgid "Suggest songs for this playlist" +msgstr "Nummers voorstellen voor deze afspeellijst" + +msgid "Finding similar artists..." +msgstr "Vergelijkbare artiesten zoeken..." + +msgid "Refresh suggestions" +msgstr "Suggesties vernieuwen" + +msgid "Not interested" +msgstr "Geen interesse" + +msgid "QBZ Radio" +msgstr "QBZ-radio" + +msgid "Qobuz Radio" +msgstr "Qobuz-radio" + +msgid "Create QBZ radio" +msgstr "QBZ-radio aanmaken" + +msgid "Create Qobuz radio" +msgstr "Qobuz-radio aanmaken" + +msgid "Follow on Qobuz" +msgstr "Volgen op Qobuz" + +msgid "Following on Qobuz" +msgstr "Gevolgd op Qobuz" + +msgid "Copy to your library" +msgstr "Kopiëren naar je bibliotheek" + +msgid "METADATA" +msgstr "METADATA" + +msgid "MusicBrainz" +msgstr "MusicBrainz" + +msgid "Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections." +msgstr "Schakel artiestenrelaties en verrijkte metadata van MusicBrainz in. Geen telemetrie — het matcht alleen releases om artiestenpagina's en nummersuggesties voor afspeellijsten te verrijken. Zet uit om die secties uit te schakelen." + +msgid "No tracks to add" +msgstr "Geen nummers om toe te voegen" + +msgid "Failed to add tracks" +msgstr "Toevoegen van nummers mislukt" + +msgid "Copied to your library" +msgstr "Gekopieerd naar je bibliotheek" + +msgid "Failed to copy playlist" +msgstr "Kopiëren van afspeellijst mislukt" + +msgid "Playlist has no tracks to copy" +msgstr "Afspeellijst heeft geen nummers om te kopiëren" + +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{} nummer toegevoegd" +msgstr[1] "{} nummers toegevoegd" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{} nummer toegevoegd aan {}" +msgstr[1] "{} nummers toegevoegd aan {}" + +msgid "Added {} track to queue" +msgid_plural "Added {} tracks to queue" +msgstr[0] "{} nummer aan wachtrij toegevoegd" +msgstr[1] "{} nummers aan wachtrij toegevoegd" + +msgid "Unfollow on Qobuz" +msgstr "Ontvolgen op Qobuz" + +msgid "Stops in" +msgstr "Stopt over" + +msgid "Cancel Timer" +msgstr "Timer annuleren" + +msgid "Stop playback after:" +msgstr "Weergave stoppen na:" + +msgid "30 min" +msgstr "30 min" + +msgid "1 hr" +msgstr "1 uur" + +msgid "2 hr" +msgstr "2 uur" + +msgid "3 hr" +msgstr "3 uur" + +msgid "5 hr" +msgstr "5 uur" + +msgid "Custom…" +msgstr "Aangepast…" + +msgid "min" +msgstr "min" + +msgid "Set Timer" +msgstr "Timer instellen" + +msgid "Cancel stop after this" +msgstr "Stoppen na dit nummer annuleren" + +msgid "LOGS" +msgstr "LOGS" + +msgid "Application logs" +msgstr "Applicatielogs" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Bekijk het in-app log, kopieer het (geheimen weggelaten) of upload het om te delen in een GitHub-issue." + +msgid "Open log viewer" +msgstr "Logviewer openen" + +msgid "Setting" +msgstr "Instelling" + +msgid "Saved" +msgstr "Opgeslagen" + +msgid "Runtime" +msgstr "Runtime" + +msgid "Diagnostics" +msgstr "Diagnostiek" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Momentopname van runtime- vs. opgeslagen configuratie." + +msgid "Exported" +msgstr "Geëxporteerd" + +msgid "Export to clipboard" +msgstr "Exporteren naar klembord" + +msgid "Cast Discovery" +msgstr "Cast-detectie" + +msgid "Scanning…" +msgstr "Scannen…" + +msgid "Scan for devices" +msgstr "Scannen naar apparaten" + +msgid "Graphics" +msgstr "Grafisch" + +msgid "Environment" +msgstr "Omgeving" + +msgid "Logs" +msgstr "Logs" + +msgid "View logs" +msgstr "Logboeken bekijken" + +msgid "Upload failed." +msgstr "Uploaden mislukt." + +msgid "All levels" +msgstr "Alle niveaus" + +msgid "Error" +msgstr "Fout" + +msgid "Warn" +msgstr "Waarschuwing" + +msgid "Info" +msgstr "Info" + +msgid "Debug" +msgstr "Debug" + +msgid "Trace" +msgstr "Trace" + +msgid "Search logs…" +msgstr "Logs doorzoeken…" + +msgid "Auto-tail" +msgstr "Auto-tail" + +msgid "Time" +msgstr "Tijd" + +msgid "Level" +msgstr "Niveau" + +msgid "Target" +msgstr "Doel" + +msgid "Message" +msgstr "Bericht" + +msgid "No log entries." +msgstr "Geen logitems." + +msgid "Copied" +msgstr "Gekopieerd" + +msgid "Copy all" +msgstr "Alles kopiëren" + +msgid "Copy diagnostics bundle" +msgstr "Diagnostiekbundel kopiëren" + +msgid "Uploading…" +msgstr "Uploaden…" + +msgid "Upload (public)" +msgstr "Uploaden (openbaar)" + +msgid "Open log file" +msgstr "Logbestand openen" + +msgid "Report an issue" +msgstr "Probleem melden" + +msgid "Go to logs" +msgstr "Naar logs" + +msgid "Create issue report" +msgstr "Probleemrapport aanmaken" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Om het oplossen van bugs en problemen makkelijker te maken, wordt aangeraden de applicatielogs te delen. We hebben de logs die we versturen zorgvuldig gemaskeerd zodat er geen gevoelige gegevens in staan. Het proces is handmatig, nooit automatisch, zodat je kunt controleren wat er wordt gedeeld." + +msgid "Cache window" +msgstr "Cachevenster" + +msgid "24 hours" +msgstr "24 uur" + +msgid "36 hours" +msgstr "36 uur" + +msgid "48 hours" +msgstr "48 uur" + +msgid "72 hours" +msgstr "72 uur" + +msgid "Refresh now" +msgstr "Nu vernieuwen" + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback without browser sample rate limitations." +msgstr "Een native muziekstreamingsclient voor Linux, ontworpen voor audiofielen die bit-perfecte Hi-Fi-weergave nodig hebben zonder de beperkingen van de samplefrequentie van de browser." + +msgid "Acknowledgments" +msgstr "Dankbetuigingen" + +msgid "Animated background renderer" +msgstr "Renderer voor geanimeerde achtergrond" + +msgid "Audio playback engine" +msgstr "Audio-afspeelengine" + +msgid "Author" +msgstr "Auteur" + +msgid "Build" +msgstr "Build" + +msgid "Build Info" +msgstr "Build-informatie" + +msgid "Codename" +msgstr "Codenaam" + +msgid "Contributors" +msgstr "Bijdragers" + +msgid "Hatred towards all those companies that discriminate against the Linux community and don't provide us with a decent client for their product." +msgstr "Haat jegens al die bedrijven die de Linux-gemeenschap discrimineren en ons niet voorzien van een fatsoenlijke client voor hun product." + +msgid "Icon library" +msgstr "Icoonbibliotheek" + +msgid "License" +msgstr "Licentie" + +msgid "Lyrics API" +msgstr "Songteksten API" + +msgid "Made with" +msgstr "Gemaakt met" + +msgid "Music link aggregation API" +msgstr "Muzieklink-aggregatie API" + +msgid "Music metadata database" +msgstr "Muziekmetadata-database" + +msgid "Music streaming service and API" +msgstr "Muziekstreamingsdienst en API" + +msgid "Platform" +msgstr "Platform" + +msgid "Release" +msgstr "Release" + +msgid "Release notes are not available." +msgstr "Release-opmerkingen zijn niet beschikbaar." + +msgid "Synced lyrics provider" +msgstr "Aanbieder van gesynchroniseerde songteksten" + +msgid "UI framework" +msgstr "UI-framework" + +msgid "Version" +msgstr "Versie" + +msgid "Website" +msgstr "Website" + +msgid "What's new" +msgstr "Wat is nieuw" + +msgid "What's new in v{}" +msgstr "Wat is nieuw in v{}" + +msgid "in" +msgstr "in" + +msgid "QBZ uses your Last.fm and ListenBrainz account data when they're connected (if they aren't, we recommend connecting them to enrich this section). Here you set how often that data is fetched — it defaults to 48 hours, or you can request an update right now." +msgstr "QBZ gebruikt je Last.fm- en ListenBrainz-accountgegevens wanneer deze zijn verbonden (zo niet, dan raden we aan ze te verbinden om deze sectie te verrijken). Hier stel je in hoe vaak die gegevens worden opgehaald — standaard elke 48 uur, of je kunt nu direct een update aanvragen." + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback." +msgstr "Een native muziekstreamingclient voor Linux, ontworpen voor audiofielen die bit-perfecte hifi-weergave nodig hebben." + +msgid "Built with Rust and Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, and Lucide." +msgstr "Gebouwd met Rust en Slint, en aangedreven door Rodio en Symphonia. Met dank aan Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh en Lucide." + +msgid "Block this album" +msgstr "Dit album blokkeren" + +msgid "Unblock album" +msgstr "Album deblokkeren" + +msgid "Album \"{}\" blocked" +msgstr "Album \"{}\" geblokkeerd" + +msgid "Album \"{}\" unblocked" +msgstr "Album \"{}\" gedeblokkeerd" + +msgid "Failed to block album" +msgstr "Blokkeren van album mislukt" + +msgid "Failed to unblock album" +msgstr "Deblokkeren van album mislukt" + +msgid "{} albums" +msgstr "{} albums" + +msgid "No blocked albums" +msgstr "Geen geblokkeerde albums" + +msgid "To block an album, open its menu and choose Block this album." +msgstr "Om een album te blokkeren, open je het menu en kies je Dit album blokkeren." + +msgid "Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed." +msgstr "Geblokkeerde albums worden verborgen in zoeken, ontdekken en overzichten — ook wanneer hun artiest is toegestaan." + +msgid "This will remove all {} blocked albums. This cannot be undone." +msgstr "Dit verwijdert alle {} geblokkeerde albums. Dit kan niet ongedaan worden gemaakt." + +msgid "{} artists, {} albums blocked" +msgstr "{} artiesten, {} albums geblokkeerd" + +msgid "{} artists, {} albums blocked (disabled)" +msgstr "{} artiesten, {} albums geblokkeerd (uitgeschakeld)" + +msgid "Failed to clear album blacklist" +msgstr "Wissen van album-zwarte lijst mislukt" + +msgid "Conductor" +msgstr "Dirigent" + +msgid "Orchestra" +msgstr "Orkest" + +msgid "Featured Artist" +msgstr "Gastartiest" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "{} album van de zwarte lijst verwijderd" +msgstr[1] "{} albums van de zwarte lijst verwijderd" + +msgid "RECOMMENDATIONS" +msgstr "AANBEVELINGEN" + +msgid "Show Recommendations in Discover" +msgstr "Aanbevelingen tonen in Ontdekken" + +msgid "A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases." +msgstr "Een gepersonaliseerd Ontdekken-tabblad op basis van je luistergedrag. Verbind voor de beste resultaten hieronder Last.fm en ListenBrainz — MusicBrainz wordt automatisch gebruikt om releases te matchen." + +msgid "Recommendations" +msgstr "Aanbevelingen" + +msgid "More like the artists you love" +msgstr "Meer zoals de artiesten waar je van houdt" + +msgid "Based on what you've been into lately" +msgstr "Gebaseerd op waar je onlangs naar luisterde" + +msgid "Weekly Exploration" +msgstr "Wekelijkse verkenning" + +msgid "Weekly Jams" +msgstr "Wekelijkse jams" + +msgid "Popular artists on Qobuz" +msgstr "Populaire artiesten op Qobuz" + +msgid "No recommendations yet" +msgstr "Nog geen aanbevelingen" + +msgid "Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations." +msgstr "Verbind Last.fm of ListenBrainz in de instellingen, of speel meer muziek af, om gepersonaliseerde aanbevelingen te krijgen." + +msgid "JACK is not bit-perfect" +msgstr "JACK is niet bit-perfect" + +msgid "The JACK backend routes audio through the JACK graph, which resamples to the graph's sample rate. For bit-perfect playback use ALSA (direct) or PipeWire with passthrough." +msgstr "De JACK-backend routeert audio door de JACK-graaf, die resamplet naar de samplefrequentie van de graaf. Gebruik voor bit-perfecte weergave ALSA (direct) of PipeWire met passthrough." + +msgid "Extra large" +msgstr "Extra groot" + +msgid "INTERFACE" +msgstr "Interface" + +msgid "Interface size" +msgstr "Interfacegrootte" + +msgid "Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)" +msgstr "Schaalt de hele interface, zoals browserzoom. Klein toont meer inhoud op het scherm, Groot en Extra groot verbeteren de leesbaarheid (vereist herstart)" + +msgid "Interface size changed — restart QBZ to apply" +msgstr "Interfacegrootte gewijzigd — herstart QBZ om toe te passen" + +msgid "Extra small" +msgstr "Extra klein" + +msgid "Sign in with your browser" +msgstr "Inloggen met je browser" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Ga verder in je webbrowser — QBZ wacht tot je klaar bent met inloggen." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "De browser kan op de achtergrond openen zonder focus te krijgen — controleer je andere vensters." + +msgid "Signing you in…" +msgstr "Je wordt ingelogd…" + +msgid "Sign-in failed" +msgstr "Inloggen mislukt" + +msgid "About QBZ" +msgstr "Over QBZ" + +msgid "Already used by" +msgstr "Al in gebruik door" + +msgid "Auto (recommended)" +msgstr "Automatisch (aanbevolen)" + +msgid "Discrete GPU (higher performance)" +msgstr "Aparte GPU (meer prestaties)" + +msgid "Integrated GPU (lower power)" +msgstr "Geïntegreerde GPU (zuiniger)" + +msgid "Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)" +msgstr "Automatisch kiest de beste renderer voor je grafische hardware. Wijzig dit alleen als de app traag aanvoelt of verkeerd rendert (vereist herstart)" + +msgid "Customize Shortcuts" +msgstr "Sneltoetsen aanpassen" + +msgid "GPU" +msgstr "GPU" + +msgid "GPU (compatibility)" +msgstr "GPU (compatibiliteit)" + +msgid "Go" +msgstr "Ga" + +msgid "Line Bed" +msgstr "Line Bed" + +msgid "Tunnel Flow" +msgstr "Tunnel Flow" + +msgid "Open Music Link" +msgstr "Muzieklink openen" + +msgid "Open Playlist Importer" +msgstr "Afspeellijst-importeur openen" + +msgid "Open Qobuz Link" +msgstr "Qobuz-link openen" + +msgid "Paste a Qobuz, Spotify, Apple Music, Tidal, Deezer or song.link URL" +msgstr "Plak een Qobuz-, Spotify-, Apple Music-, Tidal-, Deezer- of song.link-URL" + +msgid "Place the Purchases entry in the custom title bar instead of the sidebar" +msgstr "Plaats de Aankopen-knop in de aangepaste titelbalk in plaats van de zijbalk" + +msgid "Press keys…" +msgstr "Druk op toetsen…" + +msgid "Purchases" +msgstr "Aankopen" + +msgid "Purchases in title bar" +msgstr "Aankopen in titelbalk" + +msgid "RENDERER" +msgstr "RENDERER" + +msgid "Rendering backend" +msgstr "Rendering-backend" + +msgid "Reset All to Defaults" +msgstr "Alles naar standaardwaarden herstellen" + +msgid "Resolving…" +msgstr "Oplossen…" + +msgid "Software" +msgstr "Software" + +msgid "Spectral Ribbon" +msgstr "Spectral Ribbon" + +msgid "This looks like a playlist." +msgstr "Dit lijkt een afspeellijst te zijn." + +msgid "Wave Bed" +msgstr "Wave Bed" + +msgid "What's New" +msgstr "Wat is nieuw" + +msgid "modified" +msgstr "gewijzigd" + +msgid "Keep your system's native window decorations. Turn off to use the QBZ header as the title bar, with its own window controls and drag support. Takes effect after restarting QBZ." +msgstr "Behoud de native vensterdecoraties van je systeem. Zet uit om de QBZ-header als titelbalk te gebruiken, met eigen vensterknoppen en sleepondersteuning. Wordt van kracht na het herstarten van QBZ." + +msgid "Frameless window without window controls or header drag (for tiling window manager users)" +msgstr "Kaderloos venster zonder vensterknoppen of headerslepen (voor gebruikers van tiling window managers)" + +msgid "Title bar mode changed — restart QBZ to apply" +msgstr "Titelbalkmodus gewijzigd — herstart QBZ om toe te passen" + +msgid "Compact header navigation" +msgstr "Compacte headernavigatie" + +msgid "Use the icon-only section navigation in the header even while the sidebar is open." +msgstr "Gebruik de sectienavigatie met alleen iconen in de header, ook terwijl de zijbalk open is." + +msgid "DSD: {}" +msgstr "DSD: {}" + +msgid "Cannot play DSD file" +msgstr "Kan DSD-bestand niet afspelen" + +msgid "DSD playback" +msgstr "DSD-weergave" + +msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC." +msgstr "Hoe DSD-nummers de DAC bereiken. WAARSCHUWING: kies DoP of Native alleen als je DAC dit ondersteunt — op elke andere DAC klinken ze als harde ruis. Het volume staat vast in DoP/Native-modus. Native vereist bovendien kernelondersteuning voor de DAC." + +msgid "Convert to PCM (works everywhere)" +msgstr "Converteren naar PCM (werkt overal)" + +msgid "DoP — DSD over PCM (bit-perfect)" +msgstr "DoP — DSD over PCM (bit-perfect)" + +msgid "Native DSD (kernel support required)" +msgstr "Native DSD (kernelondersteuning vereist)" + +msgid "Date added" +msgstr "Datum toegevoegd" + +msgid "Renderer setting reverted to Auto — the previous start didn't finish" +msgstr "Renderer-instelling teruggezet naar Automatisch — de vorige start is niet voltooid" + +msgid "Renderer switched to GPU (compatibility) — the GPU renderer failed to start" +msgstr "Renderer overgeschakeld naar GPU (compatibiliteit) — de GPU-renderer kon niet starten" + +msgid "Audio output error" +msgstr "Audio-uitvoerfout" + +msgid "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default" +msgstr "Directe ALSA-apparaattoegang is geblokkeerd binnen Flatpak/Snap — schakel de audio-backend over naar PipeWire of Systeemstandaard" + +msgid "Auto theme generation failed" +msgstr "Automatische themageneratie mislukt" + +msgid "Play all tracks" +msgstr "Alle nummers afspelen" + +msgid "Shuffle all tracks" +msgstr "Alle nummers shuffelen" + +msgid "Select multiple tracks" +msgstr "Meerdere nummers selecteren" + +msgid "Play a random album" +msgstr "Een willekeurig album afspelen" + +msgid "Open a random artist" +msgstr "Een willekeurige artiest openen" + +msgid "Play a random playlist" +msgstr "Een willekeurige afspeellijst afspelen" + +msgid "Open a random label" +msgstr "Een willekeurig label openen" + +msgid "Start from current theme" +msgstr "Beginnen vanaf huidig thema" + +msgid "Copy the colors of the currently applied theme into the editor as a starting point." +msgstr "Kopieer de kleuren van het momenteel toegepaste thema naar de editor als startpunt." + +msgid "Use current colors" +msgstr "Huidige kleuren gebruiken" + +msgid "Dark theme" +msgstr "Donker thema" + +msgid "Set the overall light or dark polarity. Affects derived shades, borders and overlays." +msgstr "Stel de algehele lichte of donkere polariteit in. Beïnvloedt afgeleide tinten, randen en overlays." + +msgid "SURFACES" +msgstr "OPPERVLAKKEN" + +msgid "Background" +msgstr "Achtergrond" + +msgid "Surface" +msgstr "Oppervlak" + +msgid "Elevated" +msgstr "Verhoogd" + +msgid "TEXT" +msgstr "TEKST" + +msgid "Text" +msgstr "Tekst" + +msgid "Secondary text" +msgstr "Secundaire tekst" + +msgid "ACCENT" +msgstr "ACCENT" + +msgid "Accent" +msgstr "Accent" + +msgid "Danger" +msgstr "Gevaar" + +msgid "Warning" +msgstr "Waarschuwing" + +msgid "Success" +msgstr "Succes" + +msgid "OTHER" +msgstr "Overige" + +msgid "Border" +msgstr "Rand" + +msgid "No available tracks to play" +msgstr "Geen beschikbare nummers om af te spelen" + +msgid "Share logs" +msgstr "Logs delen" + +msgid "QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library)." +msgstr "QBZ kan worden gebruikt als offline speler zonder Qobuz™-account (geen toegang tot de Qobuz™-bibliotheek)." + +msgid "Renderer switched automatically — the previous renderer failed to start" +msgstr "Renderer automatisch overgeschakeld — de vorige renderer kon niet starten" + +# QConnect startup behavior (Settings > Playback, ported from Tauri) +msgid "Auto-connect Qobuz Connect on startup" +msgstr "Qobuz Connect automatisch verbinden bij opstarten" +msgid "Choose whether Qobuz Connect activates automatically when QBZ launches." +msgstr "Kies of Qobuz Connect automatisch wordt geactiveerd wanneer QBZ start." +msgid "Remember state" +msgstr "Status onthouden" +msgid "On by default" +msgstr "Standaard aan" +msgid "Off by default" +msgstr "Standaard uit" + +msgid "QBZ recovered from repeated startup crashes — some restored state was reset" +msgstr "QBZ is hersteld van herhaalde crashes bij het opstarten — een deel van de herstelde staat is gereset" + +# QConnect device name (Settings > Playback, ported from Tauri) +msgid "Qobuz Connect device name" +msgstr "Qobuz Connect-apparaatnaam" + +msgid "The name other Qobuz Connect apps see for this device. Applies on the next connection." +msgstr "De naam die andere Qobuz Connect-apps voor dit apparaat zien. Wordt toegepast bij de volgende verbinding." + +# Quality downgrade tooltip (#590 follow-up) +msgid "Source: {}" +msgstr "Bron: {}" + +msgid "Output: {}" +msgstr "Uitvoer: {}" + +# Local Library header Plex sync button (#573) +msgid "Sync Plex library" +msgstr "Plex-bibliotheek synchroniseren" + +# Library Albums sort options (#566) +msgid "Recently added" +msgstr "Recent toegevoegd" + +msgid "First added" +msgstr "Eerst toegevoegd" + +msgid "Random" +msgstr "Willekeurig" + + +# Offline cache clear — communicate it does not remove downloads (#606) +msgid "Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above." +msgstr "Maakt cachegegevens vrij. Je gedownloade albums blijven behouden — verwijder die via de offline manager hierboven." + +msgid "Couldn't clear the cache" +msgstr "Kan de cache niet wissen" + + +# Task 18 — Settings > Developer > Export settings modal (daemon 04 §4.2) +msgid "SETTINGS PORTABILITY" +msgstr "INSTELLINGEN OVERZETTEN" + +msgid "Export settings…" +msgstr "Instellingen exporteren…" + +msgid "Save a portable bundle of your settings to move to another machine or the qbzd daemon." +msgstr "Slaat een overdraagbare bundel van je instellingen op om ze naar een andere machine of de qbzd-daemon te verplaatsen." + +msgid "Export…" +msgstr "Exporteren…" + +msgid "Export settings" +msgstr "Instellingen exporteren" + +msgid "Save your settings as a bundle to import on the qbzd daemon or another machine." +msgstr "Sla je instellingen op als bundel om ze op de qbzd-daemon of een andere machine te importeren." + +msgid "Include Qobuz login and integration tokens" +msgstr "Qobuz-login en integratietokens meenemen" + +msgid "Anyone with the file can use your accounts. It is saved readable only by you; delete it after importing." +msgstr "Iedereen die het bestand kan lezen, kan je accounts gebruiken. Het wordt opgeslagen zodat alleen jij het kunt lezen; verwijder het na het importeren." + +msgid "Export" +msgstr "Exporteren" + +msgid "Bundle saved. On the daemon box:" +msgstr "Bundel opgeslagen. Op de daemon-machine:" + +msgid "Could not export settings" +msgstr "Kan instellingen niet exporteren" + + +# Share menu Song.link/Album.link toasts (#514) +msgid "Fetching Song.link..." +msgstr "Song.link ophalen..." + +msgid "Fetching Album.link..." +msgstr "Album.link ophalen..." + +msgid "Link copied" +msgstr "Link gekopieerd" + +msgid "Failed to copy link" +msgstr "Kopiëren van link mislukt" + +# Dynamic app background (Appearance settings) +msgid "Ambient" +msgstr "Ambient" + +msgid "Blurred art" +msgstr "Vervaagde albumhoes" + +msgid "Dynamic background" +msgstr "Dynamische achtergrond" + +msgid "Animated album-art background behind the whole app. High resource use — GPU accelerated." +msgstr "Geanimeerde albumhoes-achtergrond achter de hele app. Hoog verbruik van systeembronnen — GPU-versneld." + +# Preferred GPU (Renderer settings) +msgid "Preferred GPU" +msgstr "Voorkeurs-GPU" + +msgid "Which GPU renders the app. Only the GPUs actually present are listed. Choosing one other than the default also switches the renderer to Vulkan — the only backend Qt can select a GPU on (requires restart)" +msgstr "Welke GPU de app rendert. Alleen daadwerkelijk aanwezige GPU's worden getoond. Een andere dan de standaard kiezen schakelt de renderer ook naar Vulkan — de enige backend waarop Qt een GPU kan kiezen (herstart vereist)" + +msgid "Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)" +msgstr "Welke GPU de app rendert. Op een hybride laptop verplaatst het kiezen van de dedicated GPU de belasting van de dynamische achtergrond weg van de geïntegreerde GPU (koeler, maar meer stroomverbruik). Automatisch wordt aanbevolen (vereist herstart)" + +msgid "discrete" +msgstr "dedicated" + +msgid "integrated" +msgstr "geïntegreerd" + +msgid "Preferred GPU changed — restart QBZ to apply" +msgstr "Voorkeurs-GPU gewijzigd — herstart QBZ om toe te passen" + +msgid "Selected GPU is unavailable in this display session — using Auto" +msgstr "De geselecteerde GPU is niet beschikbaar in deze beeldschermsessie — Auto wordt gebruikt" + +msgid "Renderer changed — restart QBZ to apply" +msgstr "Renderer gewijzigd — start QBZ opnieuw om toe te passen" + +msgid "Search your library" +msgstr "Zoek in je bibliotheek" + +msgid "From catalog" +msgstr "Uit catalogus" + +msgid "In library" +msgstr "In bibliotheek" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Aan favorieten toevoegen" + +msgid "Remove from favorites" +msgstr "Uit favorieten verwijderen" + +msgid "Start radio" +msgstr "Radio starten" + +msgid "Booklet" +msgstr "Boekje" + +msgid "Add to Mixtape" +msgstr "Aan Mixtape toevoegen" + +msgid "Album info" +msgstr "Albuminfo" + +msgid "More options" +msgstr "Meer opties" + +msgid "Follow artist" +msgstr "Artiest volgen" + +msgid "Unfollow" +msgstr "Ontvolgen" + +msgid "Artist network" +msgstr "Artiestennetwerk" + +msgid "Upload to Qobuz" +msgstr "Uploaden naar Qobuz" + +msgid "Select tracks" +msgstr "Nummers selecteren" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz weigert tijdelijk verzoeken — even pauzeren. Probeer het zo opnieuw." + + +msgid "Flatpak" +msgstr "Flatpak" + +msgid "Snap" +msgstr "Snap" + +msgid "FLATPAK SANDBOX" +msgstr "FLATPAK-SANDBOX" + +msgid "SNAP SANDBOX" +msgstr "SNAP-SANDBOX" + +msgid "QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates." +msgstr "QBZ draait in een Flatpak-sandbox. Sommige functies hebben eenmalige rechten nodig — voer de commando's in een terminal uit en herstart daarna QBZ. De rechten blijven na updates behouden." + +msgid "QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ." +msgstr "QBZ draait in een Snap-sandbox. Sommige audio-interfaces moeten eenmalig worden gekoppeld — voer deze commando's in een terminal uit en herstart daarna QBZ." + +msgid "Exclusive DAC access (bit-perfect)" +msgstr "Exclusieve DAC-toegang (bit-perfect)" + +msgid "Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected." +msgstr "Aanbevolen voor exclusieve modus: laat QBZ PipeWire vragen de DAC netjes over te dragen (D-Bus-apparaatreservering). Zonder dit houdt PipeWire de DAC vast en mixen andere apps er nog steeds doorheen, zelfs met ALSA + hw + Exclusief geselecteerd." + +msgid "Music on NAS or external drives" +msgstr "Muziek op NAS of externe schijven" + +msgid "Grants access to a folder outside the sandbox. Repeat the command for each folder you need." +msgstr "Geeft toegang tot een map buiten de sandbox. Herhaal het commando voor elke map die je nodig hebt." + +msgid "Required audio connections" +msgstr "Vereiste audiokoppelingen" + +msgid "Optional — external drives & NAS" +msgstr "Optioneel — externe schijven & NAS" + +msgid "Copy" +msgstr "Kopiëren" + +msgid "Cut" +msgstr "Knippen" + +msgid "Paste" +msgstr "Plakken" + +msgid "Copied!" +msgstr "Gekopieerd!" + +msgid "Limited by your streaming quality setting" +msgstr "Beperkt door je instelling voor streamingkwaliteit" + +msgid "Qobuz has no higher quality for this track" +msgstr "Qobuz biedt geen hogere kwaliteit voor dit nummer" + +msgid "offline copy — cap not applied" +msgstr "offline kopie — limiet niet toegepast" + +msgid "cached copy — cap not applied" +msgstr "gecachte kopie — limiet niet toegepast" + +msgid "Limited by your output device's limit" +msgstr "Beperkt door de limiet van je uitvoerapparaat" + +msgid "Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting." +msgstr "Begrens de aangevraagde streamingkwaliteit op de limiet van je uitvoerapparaat. Geldt alleen voor lokale weergave, nooit voor casten." + +msgid "Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly." +msgstr "De exacte limieten van je apparaat konden op dit systeem niet worden uitgelezen, dus wordt een gangbare set aangenomen. De limiet geldt nog steeds, maar komt mogelijk niet exact overeen met je hardware." + +msgid "Detected device limit" +msgstr "Gedetecteerde apparaatlimiet" + +msgid "Quality for this device" +msgstr "Kwaliteit voor dit apparaat" + +msgid "Follow app setting ({})" +msgstr "App-instelling volgen ({})" + +msgid "Hi-Res — 24-bit, up to 96 kHz" +msgstr "Hi-Res — 24-bit, tot 96 kHz" + +msgid "CD — 16-bit / 44.1 kHz" +msgstr "CD — 16-bit / 44,1 kHz" + +msgid "MP3 320" +msgstr "MP3 320" + +msgid "Qobuz only offers these tiers — there is no 48 kHz option. The step below Hi-Res is CD 44.1 kHz." +msgstr "Qobuz biedt alleen deze niveaus — er is geen 48 kHz-optie. De stap onder Hi-Res is CD 44,1 kHz." + +msgid "Limited by the quality cap for {}" +msgstr "Beperkt door de kwaliteitslimiet voor {}" + +msgid "capped for this device" +msgstr "begrensd voor dit apparaat" + +msgid "Albums by folder" +msgstr "Albums per map" + +msgid "Albums by metadata" +msgstr "Albums per metadata" + +msgid "ALBUMS VIEW" +msgstr "ALBUMWEERGAVE" + +msgid "Album grouping" +msgstr "Albumgroepering" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Mappen: één kaart per albummap — ideaal voor compilaties en boxsets. Metadata: splitsen op album- + artiesttags." + +msgid "+{} more artists" +msgstr "+{} artiesten meer" + +msgid "Show less" +msgstr "Minder tonen" +# Most Played Albums rail title +msgid "Most Played Albums" +msgstr "Meest afgespeelde albums" + +# Most Played Albums View-all — per-card play count +msgid "{} plays" +msgstr "{} keer afgespeeld" + +# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast +msgid "Show lyrics translation" +msgstr "Songtekstvertaling tonen" + +msgid "Hide lyrics translation" +msgstr "Songtekstvertaling verbergen" + +msgid "Translation language" +msgstr "Vertaaltaal" + +msgid "Auto (account language)" +msgstr "Automatisch (accounttaal)" + +msgid "Translation is not available right now. Please try again later." +msgstr "Vertaling is nu niet beschikbaar. Probeer het later opnieuw." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Afgewezen artiesten verdwijnen alleen uit je Aanbevelingen — ze blijven zichtbaar in zoekresultaten en overal elders." + +msgid "No dismissed artists" +msgstr "Geen afgewezen artiesten" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Om een artiest niet meer in Aanbevelingen te zien, open je het kaartmenu en kies je Niet geïnteresseerd." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} verschijnt niet meer in Aanbevelingen" + +msgid "Artist dismissed from Recommendations" +msgstr "Artiest afgewezen voor Aanbevelingen" + +msgid "{} restored to Recommendations" +msgstr "{} hersteld in Aanbevelingen" + +msgid "LIBRARY › ALL" +msgstr "BIBLIOTHEEK › ALLES" + +msgid "Show local files & Plex" +msgstr "Lokale bestanden en Plex tonen" + +msgid "Include your favorited local files and Plex items in the Library All feed." +msgstr "Toon je favoriete lokale bestanden en Plex-items in de Bibliotheek-feed Alles." + +msgid "Play indicator animation" +msgstr "Afspeelindicator-animatie" + +msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU." +msgstr "Animeert de nu-spelende rij met equalizerbalken. Uit (standaard) toont een statisch pauze-icoon met een accentmarkering op de rand — lichter voor de CPU." + +msgid "Invert swipe navigation direction" +msgstr "Veegnavigatierichting omkeren" + +msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward." +msgstr "Wisselt de tweevingerveeg op het touchpad om: links gaat terug, rechts gaat vooruit." + +msgid "Local items in Library All" +msgstr "Lokale items in Bibliotheek › Alles" + +msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track." +msgstr "Alleen favorieten: wat je hebt gemarkeerd. Hele lokale bibliotheek: elk album, artiest en nummer uit mappen en Plex." + +msgid "Favorited items only" +msgstr "Alleen favorieten" + +msgid "Entire local library" +msgstr "Hele lokale bibliotheek" + +msgid "Select multiple albums" +msgstr "Meerdere albums selecteren" + +msgid "Play later" +msgstr "Later afspelen" + +msgid "Added to play later" +msgstr "Toegevoegd voor later" + +msgid "Next in queue" +msgstr "Volgende in de wachtrij" + +msgid "Next up" +msgstr "Hierna" + +msgid "Removed {} tracks from favorites" +msgstr "{} nummers verwijderd uit favorieten" + +msgid "Removing {} tracks from favorites…" +msgstr "{} nummers worden verwijderd uit favorieten…" + +msgid "Playlist imported" +msgstr "Afspeellijst geïmporteerd" + +msgid "Playlist import failed" +msgstr "Importeren van afspeellijst mislukt" + +msgid "Not logged in to Qobuz" +msgstr "Niet ingelogd bij Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Map verwijderen" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Map “{}” verwijderen? De afspeellijsten gaan terug naar het hoofdniveau." + +msgid "Choose a folder image" +msgstr "Kies een mapafbeelding" + +msgid "Images" +msgstr "Afbeeldingen" + +msgid "Failed to create folder" +msgstr "Aanmaken van map mislukt" + +msgid "Failed to save folder" +msgstr "Opslaan van map mislukt" + +msgid "Failed to delete folder" +msgstr "Verwijderen van map mislukt" + +msgid "Failed to rename playlist" +msgstr "Hernoemen van afspeellijst mislukt" + +msgid "Failed to delete playlist" +msgstr "Verwijderen van afspeellijst mislukt" + +msgid "Couldn't create the playlist" +msgstr "Kon de afspeellijst niet aanmaken" + +# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard, +# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. +msgid "Open playlist" +msgstr "Afspeellijst openen" + +# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut +# action labels + category names for the cheatsheet and the customize editor. +# Slint's Rust-side labels were never extracted (the port inherits the gap); +# this block fixes it for both frontends. en is EXCLUDED on purpose +# (sparse-override convention, qbz-i18n/src/lib.rs:120-125). + +msgid "Play / Pause" +msgstr "Afspelen / Pauzeren" + +msgid "Go Back" +msgstr "Terug" + +msgid "Go Forward" +msgstr "Vooruit" + +msgid "Toggle Sidebar" +msgstr "Zijbalk in-/uitschakelen" + +msgid "Immersive Mode" +msgstr "Immersieve modus" + +msgid "Close / Dismiss" +msgstr "Sluiten / Verbergen" + +msgid "Show Shortcuts" +msgstr "Sneltoetsen tonen" + +msgid "Toggle Mini Player" +msgstr "Minispeler in-/uitschakelen" + +msgid "Seek Forward (5s)" +msgstr "5 s vooruit spoelen" + +msgid "Seek Back (5s)" +msgstr "5 s terugspoelen" + +msgid "Seek Forward (10s)" +msgstr "10 s vooruit spoelen" + +msgid "Seek Back (10s)" +msgstr "10 s terugspoelen" + +msgid "Micro View" +msgstr "Microweergave" + +msgid "Compact View" +msgstr "Compacte weergave" + +msgid "Artwork View" +msgstr "Hoesweergave" + +msgid "Listen list" +msgstr "Luisterlijst" + +msgid "Lyrics View" +msgstr "Songtekstweergave" + +msgid "Navigation" +msgstr "Navigatie" + +msgid "Interface" +msgstr "Interface" + +msgid "Mini Player" +msgstr "Minispeler" + + +# Kiosk shell (2026-08-02 kiosk-port contract) + +msgid "Cover" +msgstr "Hoes" + +msgid "Radio" +msgstr "Radio" + +msgid "Up Next" +msgstr "Hierna" + +# Kiosk <-> Desktop profile toggle (view-mode menu) + +msgid "Kiosk mode" +msgstr "Kioskmodus" + +msgid "Desktop mode" +msgstr "Bureaubladmodus" + +# AlbumView cover menu / lightbox (Qt port, 2026-08-10) + +msgid "View cover" +msgstr "Hoes bekijken" + +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Afbeelding bekijken" + +msgid "Could not load the image" +msgstr "De afbeelding kon niet worden geladen" + +# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's +# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen +# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter", +# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On", +# "Bands & Projects", "Load more", "Loading…", "Artist Scene", +# "No artists found for this scene.", "No album appearances found." and +# "Select an artist to see their albums." (period-only delta) already exist. +# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7, +# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them +# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every +# call site substitutes explicitly, e.g. .replace("{artist}", name). + +msgid "Based on {artist} · {genres}" +msgstr "Gebaseerd op {artist} · {genres}" + +msgid "Searching MusicBrainz for {genre} artists..." +msgstr "Zoeken naar {genre}-artiesten op MusicBrainz..." + +msgid "Verifying availability on Qobuz..." +msgstr "Beschikbaarheid op Qobuz controleren..." + +msgid "Preparing results..." +msgstr "Resultaten voorbereiden..." + +msgid "No matching artists from this scene are currently available on Qobuz." +msgstr "Er zijn momenteel geen bijpassende artiesten uit deze scene beschikbaar op Qobuz." + +msgid "Loading albums..." +msgstr "Albums laden..." + +msgid "Failed to load albums" +msgstr "Albums laden mislukt" + +msgid "Failed to load appearances" +msgstr "Bijdragen laden mislukt" + +msgid "Looking up {name}..." +msgstr "{name} opzoeken..." + +msgid "Failed to look up musician" +msgstr "Muzikant opzoeken mislukt" + +msgid "Browse view" +msgstr "Bladerweergave" + +msgid "Grid view" +msgstr "Rasterweergave" + +msgid "Alphabetical (A-Z)" +msgstr "Alfabetisch (A-Z)" + +msgid "This musician appears in album credits but has limited catalog information available in Qobuz." +msgstr "Deze muzikant staat in de albumcredits, maar er is weinig catalogusinformatie beschikbaar op Qobuz." + +msgid "Unable to verify this musician's catalog. The information shown is based on album credits." +msgstr "De catalogus van deze muzikant kon niet worden geverifieerd. De getoonde informatie komt uit de albumcredits." + +msgid "This musician appears in album credits." +msgstr "Deze muzikant staat in de albumcredits." + +msgid "Discography" +msgstr "Discografie" + +msgid "Live Albums" +msgstr "Livealbums" + +msgid "Select an artist to see their albums" +msgstr "Selecteer een artiest om diens albums te zien" + +msgid "{} / {} artists" +msgstr "{} / {} artiesten" + +# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17 +# §8-6). "Unavailable" and "This track is no longer available" already +# exist in this file and are REUSED by the render and the reactive skip toast +# — nothing below twins them. +# +# The skipped-tracks toast is a plural PAIR, per the convention already set by +# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build +# drops N rows at once, and a singular-only string reads wrong in every locale +# with plural rules. Russian carries all three forms. +# +# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled +# state (contract §5.3/F5): the track left the catalogue but the user owns a +# downloaded copy, so it still plays. It cannot fold into "This track is no +# longer available" — that sentence would be a lie on a row playing right now. +# +# Em dash is U+2014 with spaces on both sides, as everywhere else in this file. +msgid "Find available version" +msgstr "Beschikbare versie zoeken" + +msgid "Replace unavailable track" +msgstr "Niet-beschikbaar nummer vervangen" + +msgid "No available version found" +msgstr "Geen beschikbare versie gevonden" + +msgid "Track replaced" +msgstr "Nummer vervangen" + +msgid "Could not replace the track" +msgstr "Vervangen van nummer mislukt" + +msgid "The replacement was added at the end of the playlist" +msgstr "De vervanging is aan het einde van de afspeellijst toegevoegd" + +msgid "{} track skipped — no longer available" +msgid_plural "{} tracks skipped — no longer available" +msgstr[0] "{} nummer overgeslagen — niet meer beschikbaar" +msgstr[1] "{} nummers overgeslagen — niet meer beschikbaar" + +msgid "No longer on Qobuz — playing your downloaded copy" +msgstr "Niet meer op Qobuz — je gedownloade kopie wordt afgespeeld" + +msgid "Exact match" +msgstr "Exacte overeenkomst" + +msgid "Low confidence" +msgstr "Lage zekerheid" + +msgid "Replace" +msgstr "Vervangen" + +msgid "Replacing..." +msgstr "Vervangen…" + +msgid "The replacement takes the unavailable track's place in the playlist." +msgstr "De vervanging neemt de plaats van het niet-beschikbare nummer in de afspeellijst in." + +# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19) +msgid "Items per carousel" +msgstr "Items per carrousel" + +# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19) +msgid "Recently Played Playlists" +msgstr "Onlangs afgespeelde afspeellijsten" + +msgid "Playlists you play will appear here." +msgstr "Afspeellijsten die je afspeelt verschijnen hier." + +# Applied-filters tooltip (2026-08-19) +msgid "+{} more" +msgstr "+{} meer" + +msgid "+{} more filters" +msgstr "+{} filters meer" + +msgid "Applied filters" +msgstr "Toegepaste filters" + +# Applied-filters tooltip: group labels (2026-08-19) +msgid "Category" +msgstr "Categorie" + +# Applied-filters tooltip: Library source switches (2026-08-19) +msgid "Favorites" +msgstr "Favorieten" + +# Discover configurator: Refresh content (2026-08-19) +msgid "Refresh content" +msgstr "Inhoud vernieuwen" + +# Appearance: click-on-section opens the first tab (2026-08-19) +msgid "Click on menu item navigates to first tab" +msgstr "Klik op een menu-item opent het eerste tabblad" + +msgid "Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu" +msgstr "Klikken op een sectie in de zijbalk of titelbalk opent deze op het eerste tabblad — Discover op Home, Library op All — in plaats van alleen het menu te tonen" + +# Discover configurator: per-rail default item count (2026-08-19) +msgid "Default ({})" +msgstr "Standaard ({})" + +# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources + +msgid "Streaming URL" +msgstr "Streaming-URL" + +msgid "Playlist file" +msgstr "Afspeellijstbestand" + +msgid "JSON file" +msgstr "JSON-bestand" + +msgid "ListenBrainz" +msgstr "ListenBrainz" + +msgid "Last.fm" +msgstr "Last.fm" + +msgid "Choose file…" +msgstr "Bestand kiezen…" + +msgid "No file selected" +msgstr "Geen bestand geselecteerd" + +msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog." +msgstr "Alleen de tracklijst wordt gelezen — de audiobestanden waarnaar wordt verwezen worden nooit geopend, gekopieerd of aan je lokale bibliotheek toegevoegd. Elk item wordt in de Qobuz-catalogus opgezocht." + +msgid "JSON is read best-effort — check the track count below before importing." +msgstr "JSON wordt naar beste kunnen gelezen — controleer het aantal tracks voordat je importeert." + +msgid "Could not read the file." +msgstr "Kon het bestand niet lezen." + +msgid "No tracks found in the file." +msgstr "Geen tracks gevonden in het bestand." + +msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON." +msgstr "Bestandsformaat niet herkend — gebruik XSPF, PLS, M3U/M3U8 of JSON." + +msgid "This looks like a live-stream manifest, not a playlist." +msgstr "Dit lijkt een livestream-manifest te zijn, geen afspeellijst." + +msgid "That file is too large to import." +msgstr "Dat bestand is te groot om te importeren." + +msgid "Could not recognize a track list in this JSON." +msgstr "Kon in deze JSON geen tracklijst herkennen." + +msgid "Username or profile URL" +msgstr "Gebruikersnaam of profiel-URL" + +msgid "Username or playlist URL" +msgstr "Gebruikersnaam of afspeellijst-URL" + +msgid "Play your library" +msgstr "Je bibliotheek afspelen" + +msgid "Play your mix" +msgstr "Je mix afspelen" + +msgid "Play your recommendations" +msgstr "Je aanbevelingen afspelen" + +msgid "Station" +msgstr "Station" + +msgid "No playlists found for {}." +msgstr "Geen afspeellijsten gevonden voor {}." + +msgid "Enter a public username." +msgstr "Voer een openbare gebruikersnaam in." + +msgid "Duplicates: {}" +msgstr "Duplicaten: {}" + +msgid "Media" +msgstr "Medium" + +msgid "Open audio CD" +msgstr "Audio-cd openen" + +msgid "Audio CD" +msgstr "Audio-cd" + +msgid "No optical drive found." +msgstr "Geen optisch station gevonden." + +msgid "No disc in the drive." +msgstr "Geen schijf in het station." + +msgid "That disc has no audio tracks." +msgstr "Die schijf heeft geen audiotracks." + +msgid "Could not read the disc." +msgstr "Kon de schijf niet lezen." + +msgid "Track {}" +msgstr "Track {}" + +msgid "Open SACD image…" +msgstr "SACD-image openen…" + +msgid "Choose a disc image" +msgstr "Kies een schijfimage" + +msgid "That image has no stereo audio area." +msgstr "Die image heeft geen stereo-audiogebied." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Deze schijf is DST-gecomprimeerd; QBZ kan die nog niet afspelen." + +msgid "That file is not a disc image." +msgstr "Dat bestand is geen schijfimage." + +msgid "That file is not a SACD image." +msgstr "Dat bestand is geen SACD-image." + +msgid "That SACD image is damaged or unsupported." +msgstr "Die SACD-image is beschadigd of wordt niet ondersteund." + +msgid "SACD" +msgstr "SACD" + +msgid "Library Explorer" +msgstr "Bibliotheekverkenner" + +msgid "Genres" +msgstr "Genres" + +msgid "All genres" +msgstr "Alle genres" + +msgid "All albums" +msgstr "Alle albums" + +msgid "No albums match these filters" +msgstr "Geen albums komen overeen met deze filters" + +msgid "Try a different genre, artist, album, source or quality." +msgstr "Probeer een ander genre, artiest, album, bron of kwaliteitsniveau." + +msgid "Favorites only" +msgstr "Alleen favorieten" + +msgid "Where should the ripped album go?" +msgstr "Waar moet het geripte album heen?" + +msgid "Ripping" +msgstr "Rippen" + +msgid "Nothing to rip." +msgstr "Niets om te rippen." + +msgid "Ripped {} track to your library" +msgid_plural "Ripped {} tracks to your library" +msgstr[0] "{} track naar je bibliotheek geript" +msgstr[1] "{} tracks naar je bibliotheek geript" + +# Discs: rip wizard, metadata lookup and the disc tooltips (2026-08-21) + +msgid "Play a folder, CD or disc image without adding it to your library" +msgstr "Speel een map, cd of schijfkopie af zonder die aan je bibliotheek toe te voegen" + +msgid "Reading the disc…" +msgstr "Schijf lezen…" + +msgid "Re-scan your folders and media servers for new music" +msgstr "Scan je mappen en mediaservers opnieuw op nieuwe muziek" + +msgid "Manage the folders your library is built from" +msgstr "Beheer de mappen waaruit je bibliotheek is opgebouwd" + +msgid "Play the whole disc from the first track" +msgstr "Speel de hele schijf vanaf het eerste nummer" + +msgid "Play it in a shuffled order" +msgstr "Speel af in willekeurige volgorde" + +msgid "Look this disc up and correct its details" +msgstr "Zoek deze schijf op en corrigeer de gegevens" + +msgid "Copy this CD to your computer as FLAC files" +msgstr "Kopieer deze cd naar je computer als FLAC-bestanden" + +msgid "Close this disc and go back to your library" +msgstr "Sluit deze schijf en ga terug naar je bibliotheek" + +msgid "Correct disc details" +msgstr "Schijfgegevens corrigeren" + +msgid "SACD image" +msgstr "SACD-kopie" + +msgid "Close without changing anything" +msgstr "Sluiten zonder iets te wijzigen" + +msgid "Community database. Knows discs by their table of contents." +msgstr "Communitydatabase. Herkent schijven aan hun inhoudsopgave." + +msgid "Collector database. Stronger on pressings, editions and catalogue numbers." +msgstr "Verzamelaarsdatabase. Sterker in persingen, edities en catalogusnummers." + +msgid "Search this provider" +msgstr "Zoek bij deze aanbieder" + +msgid "The provider is rate-limiting right now. Try again in a moment." +msgstr "De aanbieder beperkt op dit moment het aantal verzoeken. Probeer het zo opnieuw." + +msgid "No releases matched. Try fewer words, or the other provider." +msgstr "Geen resultaten. Probeer minder woorden, of de andere aanbieder." + +msgid "Search for the release this disc actually is." +msgstr "Zoek de uitgave die deze schijf werkelijk is." + +msgid "Remove my correction" +msgstr "Mijn correctie verwijderen" + +msgid "Forget the saved details for this disc and look it up fresh next time" +msgstr "Vergeet de opgeslagen gegevens van deze schijf en zoek ze de volgende keer opnieuw op" + +msgid "Applying..." +msgstr "Toepassen..." + +msgid "Use these details" +msgstr "Deze gegevens gebruiken" + +msgid "Rename this disc and remember it for next time" +msgstr "Hernoem deze schijf en onthoud dit voor de volgende keer" + +msgid "Rip this CD" +msgstr "Deze cd rippen" + +msgid "What will happen" +msgstr "Wat er gaat gebeuren" + +msgid "Track names" +msgstr "Nummernamen" + +msgid "Your library" +msgstr "Je bibliotheek" + +msgid "Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags." +msgstr "Elk nummer op de schijf wordt gelezen en als FLAC-bestand weggeschreven: één map per album, één bestand per nummer, met tags." + +msgid "Format: FLAC (lossless) — the only format QBZ writes" +msgstr "Formaat: FLAC (lossless) — het enige formaat dat QBZ wegschrijft" + +msgid "Where the album goes" +msgstr "Waar het album komt" + +msgid "Choose folder…" +msgstr "Map kiezen…" + +msgid "Change…" +msgstr "Wijzigen…" + +msgid "Pick the folder the album folder will be created in" +msgstr "Kies de map waarin de albummap wordt aangemaakt" + +msgid "Nothing chosen yet" +msgstr "Nog niets gekozen" + +msgid "This folder is already part of your Local Library." +msgstr "Deze map maakt al deel uit van je lokale bibliotheek." + +msgid "This folder is outside your Local Library, so the album will not appear there on its own." +msgstr "Deze map valt buiten je lokale bibliotheek, dus het album verschijnt daar niet vanzelf." + +msgid "Choose a destination first and this step will know what to offer." +msgstr "Kies eerst een bestemming, dan weet deze stap wat er te bieden valt." + +msgid "Re-scan that folder when the rip finishes" +msgstr "Scan die map opnieuw zodra het rippen klaar is" + +msgid "Only that folder, not your whole library." +msgstr "Alleen die map, niet je hele bibliotheek." + +msgid "Do nothing" +msgstr "Niets doen" + +msgid "The album appears the next time you scan." +msgstr "Het album verschijnt bij de volgende scan." + +msgid "Add this folder to Local Library and scan it" +msgstr "Voeg deze map toe aan je lokale bibliotheek en scan die" + +msgid "Future rips into the same folder will be picked up too." +msgstr "Latere rips naar dezelfde map worden ook opgepikt." + +msgid "The files are written; the library will not know about them." +msgstr "De bestanden worden weggeschreven; de bibliotheek weet er niets van." + +msgid "A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool." +msgstr "Een eenvoudige ripper, bedoeld om je cd's snel in je bibliotheek te krijgen. Voor AccurateRip-controles en geavanceerde opties kun je beter een gespecialiseerd programma gebruiken." + +msgid "Close without ripping anything" +msgstr "Sluiten zonder iets te rippen" + +msgid "Go back a step" +msgstr "Ga een stap terug" + +msgid "Start ripping" +msgstr "Beginnen met rippen" + +msgid "Go to the next step" +msgstr "Ga naar de volgende stap" + +msgid "Read the disc and write the FLAC files" +msgstr "Lees de schijf en schrijf de FLAC-bestanden" + +msgid "Choose a destination folder first" +msgstr "Kies eerst een bestemmingsmap" + +msgid "No disc is open." +msgstr "Er is geen schijf geopend." + +msgid "Couldn't load that release." +msgstr "Kon die uitgave niet laden." + +msgid "Disc details updated" +msgstr "Schijfgegevens bijgewerkt" + +msgid "Correction removed. Re-open the disc to look it up again." +msgstr "Correctie verwijderd. Open de schijf opnieuw om die weer op te zoeken." + +msgid "Only a physical CD can be ripped." +msgstr "Alleen een fysieke cd kan worden geript." + +msgid "Choose where the album should go first." +msgstr "Kies eerst waar het album moet komen." + +msgid "Couldn't add that folder to the library" +msgstr "Kon die map niet aan de bibliotheek toevoegen" + +# Rip progress panel, cancel and partial rips (2026-08-21) + +msgid "Writing FLAC files to" +msgstr "FLAC-bestanden worden geschreven naar" + +msgid "Hide this panel — the rip keeps going" +msgstr "Verberg dit paneel — het rippen gaat door" + +msgid "Ripping — don't eject the disc until this finishes." +msgstr "Bezig met rippen — werp de schijf pas uit als dit klaar is." + +msgid "Finished" +msgstr "Klaar" + +msgid "Files already written stay on disk. Delete them yourself if you don't want them." +msgstr "Al weggeschreven bestanden blijven op je schijf staan. Verwijder ze zelf als je ze niet wilt." + +msgid "Stop ripping" +msgstr "Stop met rippen" + +msgid "Stopping…" +msgstr "Bezig met stoppen…" + +msgid "Stop after the current track. Nothing is deleted." +msgstr "Stop na het huidige nummer. Er wordt niets verwijderd." + +msgid "Rip stopped. The files already written are still there." +msgstr "Rippen gestopt. De al weggeschreven bestanden staan er nog." + +msgid "See what the rip is doing, or stop it" +msgstr "Bekijk wat het rippen doet, of stop het" + +msgid "Pick at least one track to rip." +msgstr "Kies minstens één nummer om te rippen." + +# Ephemeral pane: per-disc blocks and the track filter (2026-08-21) + +msgid "Disc {}" +msgstr "Schijf {}" + +msgid "Filter tracks" +msgstr "Nummers filteren" + +msgid "No track matches that." +msgstr "Geen enkel nummer komt overeen." + +msgid "NAVIGATION" +msgstr "NAVIGATIE" + +msgid "SEARCH" +msgstr "ZOEKEN" + +msgid "PLAYER & VISUALS" +msgstr "SPELER & VISUALS" + +msgid "WINDOW" +msgstr "VENSTER" + +msgid "SYSTEM INTEGRATION" +msgstr "SYSTEEMINTEGRATIE" + +msgid "The typeface used across the app. System follows your operating system; the other choices are bundled with QBZ (requires restart)" +msgstr "Het lettertype voor de hele app. Systeem volgt het besturingssysteem; de andere keuzes worden met QBZ meegeleverd (herstart vereist)" + +msgid "Library folders" +msgstr "Bibliotheekmappen" + +msgid "{} folders" +msgstr "{} mappen" + +msgid "No Qobuz account? You can still use QBZ as a player for your own music library" +msgstr "Geen Qobuz-account? Je kunt QBZ nog steeds gebruiken als speler voor je eigen muziek" + +msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working." +msgstr "Je gebruikt QBZ zonder Qobuz-account. Je lokale bibliotheek en downloads blijven werken." + +msgid "Continuous playback" +msgstr "Doorlopend afspelen" + +msgid "Open" +msgstr "Openen" + +msgid "Folder view" +msgstr "Mapweergave" + +msgid "Open media" +msgstr "Media openen" + +msgid "Open folder…" +msgstr "Map openen…" + +# Tabvolgorde van de lokale bibliotheek en starten zonder sessie (2026-08-24) +msgid "LIBRARY TABS" +msgstr "BIBLIOTHEEKTABBLADEN" + +msgid "Tab order and default" +msgstr "Tabvolgorde en standaardtab" + +msgid "Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session." +msgstr "Kies de volgorde van de tabbladen van de lokale bibliotheek. Het eerste opent standaard, ook wanneer QBZ zonder Qobuz-sessie start." + +msgid "Local Library tabs" +msgstr "Tabbladen van de lokale bibliotheek" + +msgid "Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session." +msgstr "Wijzig de volgorde van de tabbladen van de lokale bibliotheek. Het eerste opent standaard en wordt getoond bij starten zonder Qobuz-sessie." + +msgid "Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu" +msgstr "Klikken op een onderdeel in de zij- of titelbalk opent het eerste tabblad — inclusief de gekozen standaard voor de lokale bibliotheek — in plaats van alleen het menu" + +# Positie van de genrefilters in de lokale bibliotheek (2026-08-24) +msgid "GENRES VIEW" +msgstr "GENREWEERGAVE" + +msgid "Genre filters position" +msgstr "Positie van genrefilters" + +msgid "Place the chained genre filters above, beside or below the album results." +msgstr "Plaats de gekoppelde genrefilters boven, naast of onder de albumresultaten." + +msgid "Top" +msgstr "Boven" + +msgid "Bottom" +msgstr "Onder" + +msgid "Album metadata saved." +msgstr "Albummetadata opgeslagen." + +msgid "Album title is required." +msgstr "Een albumtitel is verplicht." + +msgid "Apply to form" +msgstr "Toepassen op formulier" + +msgid "CUE-based albums can only be edited with a sidecar." +msgstr "Albums op basis van CUE kunnen alleen met een sidecarbestand worden bewerkt." + +msgid "CUE-based albums use sidecar metadata." +msgstr "Albums op basis van CUE gebruiken sidecarmetadata." + +msgid "Canonical" +msgstr "Canoniek" + +msgid "Close without saving" +msgstr "Sluiten zonder opslaan" + +msgid "Couldn't inspect album metadata." +msgstr "De albummetadata kon niet worden onderzocht." + +msgid "Couldn't load that metadata result." +msgstr "Dat metadataresultaat kon niet worden geladen." + +msgid "Couldn't open the metadata source." +msgstr "De metadatabron kon niet worden geopend." + +msgid "Detected" +msgstr "Gedetecteerd" + +msgid "Disc number" +msgstr "Schijfnummer" + +msgid "Editing the selected physical version" +msgstr "De geselecteerde fysieke versie wordt bewerkt" + +msgid "Enter an album title or artist to search." +msgstr "Voer een albumtitel of artiest in om te zoeken." + +msgid "Every track needs a title." +msgstr "Elke track heeft een titel nodig." + +msgid "File names are shown for reference and cannot be changed here." +msgstr "Bestandsnamen worden ter referentie getoond en kunnen hier niet worden gewijzigd." + +msgid "Find metadata" +msgstr "Metadata zoeken" + +msgid "ID3 writing" +msgstr "ID3 schrijven" + +msgid "Metadata editing is available for local file versions only." +msgstr "Metadata kan alleen voor lokale bestandsversies worden bewerkt." + +msgid "Metadata search failed." +msgstr "Het zoeken naar metadata is mislukt." + +msgid "No local album version is selected." +msgstr "Er is geen lokale albumversie geselecteerd." + +msgid "None" +msgstr "Geen" + +msgid "One or more audio files are unavailable." +msgstr "Een of meer audiobestanden zijn niet beschikbaar." + +msgid "Open source" +msgstr "Bron openen" + +msgid "QBZ will preflight every file, update its canonical tag layer, then read it back to verify the change. Keep a backup if these files cannot be replaced." +msgstr "QBZ controleert elk bestand vooraf, werkt de canonieke taglaag bij en leest deze opnieuw om de wijziging te verifiëren. Bewaar een back-up als deze bestanden niet kunnen worden vervangen." + +msgid "Save changes to" +msgstr "Wijzigingen opslaan in" + +msgid "Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled." +msgstr "Sommige bestanden bevatten conflicterende taglagen. De canonieke laag heeft voorrang, tenzij synchronisatie is ingeschakeld." + +msgid "Synchronize compatible tag layers" +msgstr "Compatibele taglagen synchroniseren" + +msgid "Tag layers" +msgstr "Taglagen" + +msgid "The canonical tag is not writable for every file." +msgstr "De canonieke tag kan niet in elk bestand worden geschreven." + +msgid "The metadata editor is no longer open." +msgstr "De metadata-editor is niet meer geopend." + +msgid "The metadata form could not be read." +msgstr "Het metadataformulier kon niet worden gelezen." + +msgid "The track list changed; reopen the editor." +msgstr "De tracklijst is gewijzigd; open de editor opnieuw." + +msgid "The track list contains a duplicate row." +msgstr "De tracklijst bevat een dubbele rij." + +msgid "Track number" +msgstr "Tracknummer" + +msgid "Unknown metadata persistence mode." +msgstr "Onbekende opslagmodus voor metadata." + +msgid "Write and verify" +msgstr "Schrijven en verifiëren" + +msgid "Write metadata into audio files?" +msgstr "Metadata naar audiobestanden schrijven?" + +msgid "Year must be a valid number." +msgstr "Het jaar moet een geldig getal zijn." + +msgid "{} must be a positive number." +msgstr "{} moet een positief getal zijn." + +msgid "The ephemeral session changed; reopen the metadata editor." +msgstr "De tijdelijke sessie is gewijzigd; open de metadata-editor opnieuw." + +msgid "Album artists" +msgstr "Albumartiesten" + +msgid "Album artists (ordered; separate with semicolons)" +msgstr "Albumartiesten (op volgorde; scheid met puntkomma's)" + +msgid "Artist credit" +msgstr "Artiestvermelding" + +msgid "Artists (ordered; semicolon separated)" +msgstr "Artiesten (op volgorde; gescheiden door puntkomma's)" + +msgid "Artwork is larger than 25 MiB." +msgstr "De albumafbeelding is groter dan 25 MiB." + +msgid "Artwork search failed." +msgstr "Zoeken naar albumafbeeldingen is mislukt." + +msgid "Artwork source did not provide a secure URL." +msgstr "De afbeeldingsbron gaf geen beveiligde URL." + +msgid "Choose album artwork" +msgstr "Albumafbeelding kiezen" + +msgid "Choose another file" +msgstr "Een ander bestand kiezen" + +msgid "Choose file" +msgstr "Bestand kiezen" + +msgid "Compilation / Various Artists" +msgstr "Verzamelalbum / Diverse artiesten" + +msgid "Composers" +msgstr "Componisten" + +msgid "Couldn't download that artwork." +msgstr "Kon die albumafbeelding niet downloaden." + +msgid "Couldn't stage artwork" +msgstr "Kon albumafbeelding niet voorbereiden" + +msgid "Current artwork" +msgstr "Huidige albumafbeelding" + +msgid "Discogs release ID" +msgstr "Discogs-release-ID" + +msgid "Enter an album title to find artwork." +msgstr "Voer een albumtitel in om albumafbeeldingen te zoeken." + +msgid "File" +msgstr "Bestand" + +msgid "Find artwork" +msgstr "Albumafbeelding zoeken" + +msgid "Local file" +msgstr "Lokaal bestand" + +msgid "MusicBrainz album artist IDs" +msgstr "MusicBrainz-ID's van albumartiesten" + +msgid "MusicBrainz artist IDs" +msgstr "MusicBrainz-ID's van artiesten" + +msgid "MusicBrainz recording ID" +msgstr "MusicBrainz-opname-ID" + +msgid "MusicBrainz release ID" +msgstr "MusicBrainz-release-ID" + +msgid "MusicBrainz release-group ID" +msgstr "MusicBrainz-releasegroep-ID" + +msgid "MusicBrainz track ID" +msgstr "MusicBrainz-track-ID" + +msgid "Performers" +msgstr "Uitvoerenden" + +msgid "Revert selection" +msgstr "Selectie terugdraaien" + +msgid "Selected track details" +msgstr "Details van geselecteerde track" + +msgid "That artwork result is no longer available." +msgstr "Dat resultaat voor de albumafbeelding is niet meer beschikbaar." + +msgid "The selected artwork changed; choose it again." +msgstr "De geselecteerde albumafbeelding is gewijzigd; kies deze opnieuw." + +msgid "The selected artwork has unsupported dimensions." +msgstr "De geselecteerde albumafbeelding heeft niet-ondersteunde afmetingen." + +msgid "The selected file is not a supported image." +msgstr "Het geselecteerde bestand is geen ondersteunde afbeelding." + +msgid "Goniometer" +msgstr "Goniometer" + +msgid "Oscilloscope" +msgstr "Oscilloscoop" + +msgid "Track waveform" +msgstr "Golfvorm van nummer" + +msgid "Show the full-track waveform in the player seek bar." +msgstr "Toon de golfvorm van het volledige nummer in de zoekbalk van de speler." + +msgid "Compact header" +msgstr "Compacte kop" + +msgid "Show less data in album headers." +msgstr "Toon minder gegevens in albumkoppen." + +msgid "Show full album header" +msgstr "Volledige albumkop tonen" + +msgid "Show compact album header" +msgstr "Compacte albumkop tonen" + +msgid "Immediate scrobbling" +msgstr "Direct scrobblen" + +msgid "Send scrobbles immediately while manual offline mode is enabled." +msgstr "Scrobbles direct verzenden wanneer de handmatige offlinemodus is ingeschakeld." + +msgid "Accumulated scrobbling" +msgstr "Opgespaard scrobblen" + +msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old." +msgstr "Scrobbles in de wachtrij zetten wanneer het netwerk niet beschikbaar is en ze verzenden zodra het terugkeert. Last.fm accepteert scrobbles tot 2 weken oud." + +msgid "Scrobble while logged out" +msgstr "Scrobblen wanneer afgemeld" + +msgid "Continue sending plays to connected scrobblers without a Qobuz session." +msgstr "Luisterbeurten naar verbonden scrobblediensten blijven sturen zonder Qobuz-sessie." + +msgid "Focus on playing" +msgstr "Afspelen volgen" + +msgid "Go to top" +msgstr "Naar boven" + +msgid "Quick view" +msgstr "Snelweergave" + +msgid "Could not load album" +msgstr "Album kon niet worden geladen" + +msgid "This album has no tracks." +msgstr "Dit album bevat geen nummers." + +msgid "Reactive Rings" +msgstr "Reactieve ringen" + +msgid "Windows — yes, but read this first" +msgstr "Windows — ja, maar lees dit eerst" + +msgid "Don't show this again" +msgstr "Niet meer weergeven" + +msgid "I understand" +msgstr "Ik begrijp het" +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker + +msgid "Volume Up" +msgstr "Volume omhoog" + +msgid "Volume Down" +msgstr "Volume omlaag" + +msgid "Mute / Unmute" +msgstr "Dempen / Dempen opheffen" + +msgid "Toggle Shuffle" +msgstr "Shuffle aan/uit" + +msgid "Cycle Repeat" +msgstr "Herhaling wisselen" + +msgid "Like Track" +msgstr "Nummer liken" + +msgid "Seek Forward" +msgstr "Vooruit spoelen" + +msgid "Seek Back" +msgstr "Terugspoelen" + +msgid "Keymap" +msgstr "Toetsenschema" + +msgid "Vim" +msgstr "Vim" + +# Local Library QoL: Explorer, scan progress, and per-track metadata editor +msgid "All years" +msgstr "Alle jaren" + +msgid "Explorer columns" +msgstr "Verkennerkolommen" + +msgid "Genre and year" +msgstr "Genre en jaar" + +msgid "Years" +msgstr "Jaren" + +msgid "Edit track metadata" +msgstr "Trackmetadata bewerken" + +msgid "Building library index" +msgstr "Bibliotheekindex opbouwen" + +msgid "Local folders" +msgstr "Lokale mappen" + +msgid "Next track" +msgstr "Volgende track" + +msgid "Overall" +msgstr "Totaal" + +msgid "Previous track" +msgstr "Vorige track" + +msgid "Scanning library" +msgstr "Bibliotheek scannen" + +msgid "The track is no longer in the library." +msgstr "De track staat niet meer in de bibliotheek." + +msgid "Updating library index" +msgstr "Bibliotheekindex bijwerken" + +msgid "Edit album" +msgstr "Album bewerken" + +msgid "Editing a local metadata sidecar" +msgstr "Een lokaal metadata-sidecarbestand bewerken" + +msgid "Media-server metadata is stored in a local sidecar." +msgstr "Metagegevens van de mediaserver worden opgeslagen in een lokaal sidecarbestand." + +msgid "Media-server metadata can only be edited with a sidecar." +msgstr "Metagegevens van de mediaserver kunnen alleen via een sidecarbestand worden bewerkt." + +msgid "Network file" +msgstr "Netwerkbestand" + +msgid "Offline download" +msgstr "Offline download" + +msgid "File path" +msgstr "Bestandspad" + +msgid "Containing folder" +msgstr "Bovenliggende map" + +msgid "Media item ID" +msgstr "Media-item-ID" + +msgid "Album ID" +msgstr "Album-ID" + +msgid "Server" +msgstr "Server" + +msgid "Duration" +msgstr "Duur" + +msgid "Format" +msgstr "Formaat" + +msgid "Channels" +msgstr "Kanalen" + +msgid "File size" +msgstr "Bestandsgrootte" + +msgid "Location" +msgstr "Locatie" + +msgid "The media item is no longer available." +msgstr "Het media-item is niet meer beschikbaar." + +msgid "The local album folder is not currently available." +msgstr "De lokale albummap is momenteel niet beschikbaar." + +msgid "Everything is already available offline" +msgstr "Alles is al offline beschikbaar" + +msgid "Some tracks are already available offline" +msgstr "Sommige nummers zijn al offline beschikbaar" + +msgid "{} of {} tracks in “{}” are already available offline." +msgstr "{} van {} nummers in ‘{}’ zijn al offline beschikbaar." + +msgid "Download the entire playlist again" +msgstr "De volledige playlist opnieuw downloaden" + +msgid "Download the entire album again" +msgstr "Het volledige album opnieuw downloaden" + +msgid "Re-downloads every track and can repair corrupt offline copies." +msgstr "Downloadt elk nummer opnieuw en kan beschadigde offlinekopieën herstellen." + +msgid "Only missing tracks" +msgstr "Alleen ontbrekende nummers" + +msgid "Keeps existing offline copies and downloads tracks that are not available yet." +msgstr "Behoudt bestaande offlinekopieën en downloadt nummers die nog niet beschikbaar zijn." + +msgid "PRIVACY" +msgstr "PRIVACY" + +msgid "Listening history" +msgstr "Luistergeschiedenis" + +msgid "Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler." +msgstr "Houd een lokaal logboek bij van wat je afspeelt: nummer, bron, hoeveel je ervan hebt gehoord en wanneer. Het blijft op deze computer, per account, en verlaat die nooit tenzij je een scrobbler inschakelt." + +msgid "Clear listening history" +msgstr "Luistergeschiedenis wissen" + +msgid "Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected." +msgstr "Verwijdert elke afspeelactie die op deze computer voor dit account is vastgelegd. Al verzonden scrobbles worden niet beïnvloed." + +msgid "Listening history cleared." +msgstr "Luistergeschiedenis gewist." + +msgid "Couldn't clear listening history." +msgstr "Kon de luistergeschiedenis niet wissen." + +msgid "Checking ALSA mixer controls…" +msgstr "ALSA-mixerregelaars controleren…" + +msgid "This device exposes multiple playback controls. Choose which one QBZ should use." +msgstr "Dit apparaat biedt meerdere afspeelregelaars. Kies welke QBZ moet gebruiken." + +msgid "The saved mixer control is no longer available. Choose another control." +msgstr "De opgeslagen mixerregelaar is niet meer beschikbaar. Kies een andere regelaar." + +msgid "QBZ does not have permission to read this device's ALSA mixer." +msgstr "QBZ heeft geen toestemming om de ALSA-mixer van dit apparaat te lezen." + +msgid "The ALSA mixer is busy. Close the app using it and try again." +msgstr "De ALSA-mixer is bezet. Sluit de app die deze gebruikt en probeer het opnieuw." + +msgid "The selected ALSA device is no longer available." +msgstr "Het geselecteerde ALSA-apparaat is niet meer beschikbaar." + +msgid "This ALSA device does not expose a mixer." +msgstr "Dit ALSA-apparaat biedt geen mixer." + +msgid "QBZ could not write the selected ALSA mixer control. Direct playback remains fixed at 100%." +msgstr "QBZ kon de geselecteerde ALSA-mixerregelaar niet schrijven. Direct afspelen blijft vastgezet op 100%." + +msgid "Selected mixer control" +msgstr "Geselecteerde mixerregelaar" + +msgid "Choose mixer control" +msgstr "Mixerregelaar kiezen" + +msgid "Change mixer control" +msgstr "Mixerregelaar wijzigen" + +msgid "Qobuz Connect could not shut down safely. Quit and reopen QBZ, then try logging out again." +msgstr "Qobuz Connect kon niet veilig worden afgesloten. Sluit QBZ af, open het opnieuw en probeer daarna nogmaals uit te loggen." + +msgid "Show filter panels" +msgstr "Filterpanelen tonen" + +msgid "Hide filter panels" +msgstr "Filterpanelen verbergen" + +msgid "Already in" +msgstr "Al in" + +msgid "Playlist index updating..." +msgstr "Playlist-index wordt bijgewerkt..." + +msgid "{} of {}" +msgstr "{} van {}" + +msgid "Last playlist" +msgstr "Laatste playlist" + +msgid "OK" +msgstr "OK" + +msgid "Sponsors" +msgstr "Sponsors" + +msgid "And all those anonymous sponsors..." +msgstr "En al die anonieme sponsors..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect kan lokale tracks niet vinden. {} track is overgeslagen en niet aan de wachtrij toegevoegd. Schakel Qobuz Connect uit om deze af te spelen." +msgstr[1] "Qobuz Connect kan lokale tracks niet vinden. {} tracks zijn overgeslagen en niet aan de wachtrij toegevoegd. Schakel Qobuz Connect uit om ze af te spelen." + +msgid "Qobuz Connect device" +msgstr "Qobuz Connect-apparaat" + +msgid "Continue playback on %1" +msgstr "Afspelen voortzetten op %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "De Qobuz Connect-wachtrij gebruiken en dat apparaat actief houden." + +msgid "Continue playback on this device" +msgstr "Afspelen voortzetten op dit apparaat" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "De Qobuz Connect-wachtrij gebruiken en de lokale wachtrij vervangen." + +msgid "Continue this device's current playback" +msgstr "Huidige weergave op dit apparaat voortzetten" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "De Qobuz Connect-wachtrij en afspeelstatus vervangen door de huidige wachtrij en positie van dit apparaat." + +msgid "Cancel Qobuz Connect" +msgstr "Qobuz Connect annuleren" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Beide wachtrijen ongewijzigd laten en de huidige lokale weergave voortzetten." + +msgid "Choose how to continue" +msgstr "Kies hoe je wilt doorgaan" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Op dit apparaat en een andere Qobuz Connect-speler wordt afgespeeld. Kies welke wachtrij en welk apparaat moeten doorgaan." + +msgid "Ask every time" +msgstr "Altijd vragen" + +msgid "Continue on the active renderer" +msgstr "Doorgaan op de actieve speler" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Hier doorgaan met de Qobuz Connect-wachtrij" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Lokale weergave voortzetten en de Qobuz Connect-wachtrij vervangen" + +msgid "When playback conflicts" +msgstr "Bij een afspeelconflict" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Vraag het elke keer of kies automatisch welke wachtrij en welk apparaat doorgaan." + +msgid "Purchased" +msgstr "Gekocht" + +msgid "Sort: Release date" +msgstr "Sorteren: Releasedatum" + +msgid "Until the purchased file is downloaded, Play streams the best quality Qobuz offers for this album. Once it is on disk, Play uses your copy." +msgstr "Zolang het gekochte bestand niet is gedownload, streamt Afspelen de beste kwaliteit die Qobuz voor dit album biedt. Zodra het op schijf staat, gebruikt Afspelen jouw kopie." + +msgid "Open in Local Library" +msgstr "Openen in lokale bibliotheek" + +msgid "Import / Export" +msgstr "Importeren / Exporteren" + +msgid "This bundle holds the app's own settings: audio, playback, appearance, integrations. It does not contain your Qobuz favorites, playlists, blacklist or purchases." +msgstr "Deze bundel bevat de instellingen van de app: audio, afspelen, uiterlijk, integraties. Je Qobuz-favorieten, playlists, blacklist en aankopen zitten er niet in." + +msgid "BLACKLIST" +msgstr "BLACKLIST" + +msgid "Export blacklist…" +msgstr "Blacklist exporteren…" + +msgid "Save your blocked artists and albums as a file you can hand to another QBZ user." +msgstr "Slaat je geblokkeerde artiesten en albums op als bestand dat je aan een andere QBZ-gebruiker kunt geven." + +msgid "Import blacklist…" +msgstr "Blacklist importeren…" + +msgid "Add blocked artists and albums from a file. Nothing already on your blacklist is removed." +msgstr "Voegt geblokkeerde artiesten en albums toe uit een bestand. Niets dat al op je blacklist staat wordt verwijderd." + +msgid "Import…" +msgstr "Importeren…" + +msgid "Import failed." +msgstr "Importeren mislukt." + +msgid "Not a QBZ blacklist file." +msgstr "Geen QBZ-blacklistbestand." + +msgid "Choose a blacklist file" +msgstr "Kies een blacklistbestand" + +msgid "Imported {} artists and {} albums ({} already blocked)." +msgstr "{} artiesten en {} albums geïmporteerd ({} al geblokkeerd)." + +msgid "Member" +msgstr "Lid" + +msgid "QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™." +msgstr "QBZ vereist een Qobuz™-account. Zonder actief abonnement is afspelen beperkt tot voorbeelden van 30 seconden; favorieten, playlists en aankopen blijven werken. Je inloggegevens gaan rechtstreeks naar Qobuz™." + +msgid "ACCOUNT MIGRATION" +msgstr "ACCOUNTMIGRATIE" + +msgid "Moves favorites, playlists and followed playlists from one Qobuz account into another, adding only what is missing and deleting nothing on either side. It does not move subscriptions or purchases: those stay with the Qobuz account that holds them." +msgstr "Verplaatst favorieten, playlists en gevolgde playlists van het ene Qobuz-account naar het andere; alleen wat ontbreekt wordt toegevoegd en er wordt aan geen van beide kanten iets verwijderd. Abonnementen en aankopen gaan niet mee: die blijven bij het Qobuz-account dat ze bezit." + +msgid "Create migration snapshot" +msgstr "Migratiesnapshot maken" + +msgid "Sign in with the account you are moving FROM, then save its favorites and playlists to a file in its QBZ profile." +msgstr "Meld je aan met het account waar je VANDAAN verhuist en sla zijn favorieten en playlists op in een bestand in zijn QBZ-profiel." + +msgid "Create…" +msgstr "Maken…" + +msgid "Migrate into this account" +msgstr "Migreren naar dit account" + +msgid "Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing." +msgstr "Meld je aan met het account waar je NAARTOE verhuist en kies hieronder een snapshot. Veilig te herhalen: een tweede keer voegt niets toe." + +msgid "No snapshots yet. Sign in with the old account and create one first." +msgstr "Nog geen snapshots. Meld je aan met het oude account en maak er eerst een." + +msgid "{} favorites · {} playlists · {} followed" +msgstr "{} favorieten · {} playlists · {} gevolgd" + +msgid "this account" +msgstr "dit account" + +msgid "Migrate…" +msgstr "Migreren…" + +msgid "Account {} · {}" +msgstr "Account {} · {}" + +msgid "Reading favorites and playlists…" +msgstr "Favorieten en playlists lezen…" + +msgid "Reading this account…" +msgstr "Dit account lezen…" + +msgid "Adding favorites…" +msgstr "Favorieten toevoegen…" + +msgid "Adding playlists…" +msgstr "Playlists toevoegen…" + +msgid "Following playlists…" +msgstr "Playlists volgen…" + +msgid "{} / {} — {}" +msgstr "{} / {} — {}" + +msgid "Snapshot failed." +msgstr "Snapshot mislukt." + +msgid "Migration failed." +msgstr "Migratie mislukt." + +msgid "Saved {} favorites, {} playlists and {} followed playlists to {}" +msgstr "{} favorieten, {} playlists en {} gevolgde playlists opgeslagen in {}" + +msgid "That snapshot was taken from this same account." +msgstr "Die snapshot komt van ditzelfde account." + +msgid "Nothing to migrate: everything is already in this account." +msgstr "Niets te migreren: alles staat al in dit account." + +msgid "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there." +msgstr "{} favorieten, {} playlists ({} nummers) en {} gevolgde playlists toegevoegd; {} waren er al." + +msgid "{} items failed; see the log." +msgstr "{} items mislukt; zie het logboek." + +msgid "Also copy the local profile" +msgstr "Ook het lokale profiel kopiëren" + +msgid "Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer." +msgstr "Bibliotheekmappen, playlistmappen en -volgorde, vastgezette items, blacklist en voorkeuren van het QBZ-profiel van het oude account op deze computer." + +msgid "Media server connections" +msgstr "Mediaserververbindingen" + +msgid "Plex, Jellyfin and Subsonic settings, including their credentials." +msgstr "Plex-, Jellyfin- en Subsonic-instellingen, inclusief inloggegevens." + +msgid "Scrobbler accounts" +msgstr "Scrobbler-accounts" + +msgid "Last.fm and ListenBrainz settings, including their credentials." +msgstr "Last.fm- en ListenBrainz-instellingen, inclusief inloggegevens." + +msgid "The listen log and the events behind offline recommendations." +msgstr "Het luisterlogboek en de gebeurtenissen achter offline aanbevelingen." + +msgid "Local profile: {} rows copied; {} playlist rows had no match." +msgstr "Lokaal profiel: {} rijen gekopieerd; {} playlistrijen zonder overeenkomst." + +msgid "The library already had folders: run a rescan to index the added ones." +msgstr "De bibliotheek had al mappen: voer een nieuwe scan uit om de toegevoegde te indexeren." + +msgid "Restart QBZ to see the migrated local profile." +msgstr "Herstart QBZ om het gemigreerde lokale profiel te zien." + +msgid "The local profile could not be copied; see the log." +msgstr "Het lokale profiel kon niet worden gekopieerd; zie het logboek." +msgid "Delete…" +msgstr "Verwijderen…" + +msgid "Delete migration snapshot?" +msgstr "Migratiemomentopname verwijderen?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Hiermee wordt alleen de migratiebundel verwijderd. Het QBZ-profiel en de gebruikersgegevens van het oude account blijven behouden." + +msgid "Delete snapshot" +msgstr "Momentopname verwijderen" + +msgid "Migration snapshot deleted." +msgstr "Migratiemomentopname verwijderd." + +msgid "Could not delete the migration snapshot." +msgstr "Kon de migratiemomentopname niet verwijderen." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} vernieuwt de bibliotheek ({} items gescand). Wacht tot dit klaar is en synchroniseer daarna opnieuw." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "In het bronprofiel was geen Plex-, Jellyfin- of Subsonic-verbinding opgeslagen, dus er kon niets worden gekopieerd." + +msgid "Media server connections copied: {} of {}." +msgstr "Gekopieerde mediaserververbindingen: {} van {}." + +msgid "Copying local profile…" +msgstr "Lokaal profiel kopiëren…" + +msgid "Refreshing migrated data…" +msgstr "Gemigreerde gegevens vernieuwen…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Dit gebeurt maar één keer. Wacht tot het klaar is en sluit het venster niet." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "U kunt dit paneel sluiten; de migratie gaat op de achtergrond verder." + +msgid "From" +msgstr "Bron" + +msgid "To" +msgstr "Doel" + +msgid "Step {} of {}" +msgstr "Stap {} van {}" + +msgid "Elapsed: {}" +msgstr "Verstreken: {}" + +msgid "Remaining: {}" +msgstr "Resterend: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Controleer de opgeslagen bronprofielen, kies wat u wilt kopiëren en bevestig het aangemelde doelaccount." + +msgid "Migrate account data" +msgstr "Accountgegevens migreren" + +msgid "Start migration" +msgstr "Migratie starten" + +msgid "What is a migration profile?" +msgstr "Wat is een migratieprofiel?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Het is een eenmalige bundel die vanuit het bronaccount is gemaakt. De Qobuz-favorieten en afspeellijsten blijven erin bewaard, zodat ze aan het nu aangemelde account kunnen worden toegevoegd. Optionele lokale gegevens worden rechtstreeks uit het oude QBZ-profiel op deze computer gekopieerd. Het verwijderen van de bundel verwijdert het oude profiel of accountgegevens niet." + +msgid "SAVED SOURCE PROFILES" +msgstr "OPGESLAGEN BRONPROFIELEN" + +msgid "No saved migration profiles are available." +msgstr "Er zijn geen opgeslagen migratieprofielen beschikbaar." + +msgid "Selected" +msgstr "Geselecteerd" + +msgid "Select" +msgstr "Selecteren" + +msgid "MIGRATION DIRECTION" +msgstr "MIGRATIERICHTING" + +msgid "Select a source profile above" +msgstr "Selecteer hierboven een bronprofiel" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Aangemeld Qobuz-account · ID {}" + +msgid "WHAT TO COPY" +msgstr "WAT KOPIËREN" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "De migratie is aanvullend: favorieten en afspeellijsten worden uit geen van beide accounts verwijderd." + +msgid "That library folder is disabled." +msgstr "Deze bibliotheekmap is uitgeschakeld." + +msgid "That folder overlaps an existing library folder." +msgstr "Deze map overlapt met een bestaande bibliotheekmap." + +msgid "This purchase is not available for download." +msgstr "Deze aankoop is niet beschikbaar om te downloaden." + +msgid "Could not save playback preference." +msgstr "De afspeelvoorkeur kon niet worden opgeslagen." + +msgid "Qobuz · stream/offline copy" +msgstr "Qobuz · stream/offlinekopie" + +msgid "Local purchase" +msgstr "Lokale aankoop" + +msgid "Play with" +msgstr "Afspelen met" + +msgid "Download purchase" +msgstr "Aankoop downloaden" + +msgid "1 track purchased" +msgstr "1 nummer gekocht" + +msgid "{} tracks purchased" +msgstr "{} nummers gekocht" + +msgid "This copy cannot be continued right now." +msgstr "Deze kopie kan momenteel niet worden voortgezet." + +msgid "Library folder refreshed." +msgstr "Bibliotheekmap vernieuwd." + +msgid "Download another copy" +msgstr "Nog een kopie downloaden" + +msgid "Complete" +msgstr "Volledig" + +msgid "Partial" +msgstr "Gedeeltelijk" + +msgid "Missing files" +msgstr "Ontbrekende bestanden" + +msgid "Changed files" +msgstr "Gewijzigde bestanden" + +msgid "Unreadable files" +msgstr "Onleesbare bestanden" + +msgid "Local purchase copies" +msgstr "Lokale aankoopkopieën" + +msgid "{} of {} tracks" +msgstr "{} van {} nummers" + +msgid "Continue copy" +msgstr "Kopie voortzetten" + +msgid "Add to Local Library" +msgstr "Toevoegen aan lokale bibliotheek" + +msgid "In Local Library" +msgstr "In lokale bibliotheek" + +msgid "{} of {} tracks available in this folder." +msgstr "{} van {} nummers beschikbaar in deze map." + +msgid "{} failed" +msgstr "{} mislukt" + +msgid "{} cancelled" +msgstr "{} geannuleerd" + +msgid "Download finished" +msgstr "Download voltooid" + +msgid "Shuffle: On" +msgstr "Shuffelen: aan" + +msgid "Shuffle: Off" +msgstr "Shuffelen: uit" + +msgid "Repeat: Off" +msgstr "Herhalen: uit" + +msgid "Repeat: All" +msgstr "Herhalen: alles" + +msgid "Repeat: One" +msgstr "Herhalen: één nummer" + +msgid "Show album artwork in Queue track list" +msgstr "Albumhoezen tonen in de nummerlijst van de wachtrij" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Vervang nummers in de wachtrij door miniaturen van albumhoezen. Standaard uit." + +msgid "Show album artwork in Library track list" +msgstr "Albumhoezen tonen in de nummerlijst van de bibliotheek" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Toon de albumhoesminiatuur tussen het nummer en de titel. Standaard uit." + +msgid "Play random" +msgstr "Willekeurig afspelen" + +msgid "Play selected" +msgstr "Selectie afspelen" + +msgid "Cancel selection" +msgstr "Selectie annuleren" + +msgid "QOBUZ LINKS" +msgstr "QOBUZ-LINKS" + +msgid "Open Qobuz links in QBZ" +msgstr "Qobuz-links openen in QBZ" + +msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here." +msgstr "Maakt QBZ de handler voor qobuzapp://-links uit je browser. Uit: de officiële Qobuz-app houdt ze; QBZ's eigen qbz://-links openen altijd hier." + +msgid "Look for better replacement" +msgstr "Een betere vervanging zoeken" + +msgid "Remove all favorites from this release" +msgstr "Alle favorieten van deze uitgave verwijderen" + +msgid "Remove all favorites from this release?" +msgstr "Alle favorieten van deze uitgave verwijderen?" + +msgid "Remove the album and every track from this release from your Library?" +msgstr "Het album en alle nummers van deze uitgave uit je bibliotheek verwijderen?" + +msgid "No replacement releases found" +msgstr "Geen vervangende uitgaven gevonden" + +msgid "Choosing a result opens the release. Your existing favorites are not changed." +msgstr "Als je een resultaat kiest, wordt de uitgave geopend. Je bestaande favorieten blijven ongewijzigd." + +msgid "No favorites from this release to remove" +msgstr "Geen favorieten van deze uitgave om te verwijderen" + +msgid "Favorites removed from this release" +msgstr "Favorieten van deze uitgave verwijderd" + +msgid "Could not remove favorites from this release" +msgstr "Favorieten van deze uitgave konden niet worden verwijderd" + +msgid "Some favorites could not be removed" +msgstr "Sommige favorieten konden niet worden verwijderd" + +msgid "Replace in Library" +msgstr "Vervangen in bibliotheek" + +msgid "Replacing favorite..." +msgstr "Favoriet wordt vervangen..." + +msgid "The selected release replaces the unavailable album favorite." +msgstr "De geselecteerde release vervangt het niet-beschikbare favoriete album." + +msgid "The matching track from the selected release replaces the unavailable favorite." +msgstr "De overeenkomende track van de geselecteerde release vervangt de niet-beschikbare favoriet." + +msgid "Could not open the selected release" +msgstr "De geselecteerde release kon niet worden geopend" + +msgid "Could not find a matching track in this release" +msgstr "Er is geen overeenkomende track in deze release gevonden" + +msgid "Could not add the replacement favorite" +msgstr "De vervangende favoriet kon niet worden toegevoegd" + +msgid "Favorite replaced" +msgstr "Favoriet vervangen" + +msgid "The replacement was added, but the unavailable favorite could not be removed" +msgstr "De vervanging is toegevoegd, maar de niet-beschikbare favoriet kon niet worden verwijderd" + +msgid "Failed to send playback to the selected device" +msgstr "Kon het afspelen niet naar het geselecteerde apparaat overdragen" + +msgid "Move up" +msgstr "Omhoog verplaatsen" + +msgid "Move down" +msgstr "Omlaag verplaatsen" + +msgid "Download" +msgstr "Downloaden" + +msgid "Edit" +msgstr "Bewerken" + +msgid "Grid" +msgstr "Raster" + +msgid "List" +msgstr "Lijst" + +msgid "Tree" +msgstr "Boomstructuur" + +msgid "Forward" +msgstr "Vooruit" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po new file mode 100644 index 000000000..4155900c4 --- /dev/null +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -0,0 +1,6843 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: qbz-slint\n" +"PO-Revision-Date: 2026-09-05 19:52-0600\n" +"Last-Translator: QBZ contributors\n" +"Language-Team: QBZ pt translators\n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "System Colors" +msgstr "Cores do Sistema" + +msgid "Wallpaper Sync" +msgstr "Sincronizar Papel de Parede" + +msgid "Custom Image" +msgstr "Imagem Personalizada" + +msgid "Auto" +msgstr "Automático" + +msgid "Disabled" +msgstr "Desativada" + +msgid "Replace current queue" +msgstr "Substituir fila atual" + +msgid "Play next" +msgstr "Reproduzir a seguir" + +msgid "Add to queue" +msgstr "Adicionar à fila" + +msgid "Remember last" +msgstr "Lembrar a última" + +msgid "Album Reactive" +msgstr "Reativo ao Álbum" + +msgid "Static" +msgstr "Estático" + +msgid "Coverflow" +msgstr "Coverflow" + +msgid "Spectrum" +msgstr "Espectro" + +msgid "Lyrics" +msgstr "Letras" + +msgid "Queue" +msgstr "Fila" + +msgid "Auto (opposite to window controls)" +msgstr "Automático (oposto aos controles da janela)" + +msgid "Left" +msgstr "Esquerda" + +msgid "Right" +msgstr "Direita" + +msgid "Rectangular" +msgstr "Retangular" + +msgid "Full-height rounded" +msgstr "Arredondado de altura total" + +msgid "Circular" +msgstr "Circular" + +msgid "Square" +msgstr "Quadrado" + +msgid "Small" +msgstr "Pequeno" + +msgid "Normal" +msgstr "Normal" + +msgid "Large" +msgstr "Grande" + +msgid "Default" +msgstr "Padrão" + +msgid "Monochrome" +msgstr "Monocromático" + +msgid "Custom" +msgstr "Personalizado" + +msgid "Remember last used" +msgstr "Lembrar última usada" + +msgid "Micro" +msgstr "Micro" + +msgid "Compact" +msgstr "Compacto" + +msgid "Artwork" +msgstr "Capa" + +msgid "Home" +msgstr "Início" + +msgid "Where you left off" +msgstr "Onde você parou" + +msgid "Mono light" +msgstr "Mono claro" + +msgid "Mono dark" +msgstr "Mono escuro" + +msgid "Color" +msgstr "Cor" + +msgid "Vinyl" +msgstr "Vinil" + +msgid "Visualizer" +msgstr "Visualizador" + +msgid "Neon Flow" +msgstr "Neon Flow" + +msgid "Tunnel" +msgstr "Túnel" + +msgid "Comet" +msgstr "Cometa" + +msgid "Split: Lyrics" +msgstr "Dividido: Letras" + +msgid "Split: Track Info" +msgstr "Dividido: Info da Faixa" + +msgid "Split: Suggestions" +msgstr "Dividido: Sugestões" + +msgid "Split: Queue" +msgstr "Dividido: Fila" + +msgid "Full" +msgstr "Completo" + +msgid "Lite" +msgstr "Leve" + +msgid "Off" +msgstr "Nenhuma" + +msgid "versions" +msgstr "versões" + +msgid "Search tracks..." +msgstr "Buscar faixas..." + +msgid "Title" +msgstr "Título" + +msgid "Quality" +msgstr "Qualidade" + +msgid "Disc" +msgstr "Disco" + +msgid "Loading…" +msgstr "Carregando…" + +msgid "Failed to load booklet" +msgstr "Falha ao carregar o livreto" + +msgid "Add cover" +msgstr "Adicionar capa" + +msgid "Change cover" +msgstr "Alterar capa" + +msgid "Remove cover" +msgstr "Remover capa" + +msgid "Open in browser" +msgstr "Abrir no navegador" + +msgid "Save as…" +msgstr "Guardar como…" + +msgid "Read more" +msgstr "Ler mais" + +msgid "Loading album…" +msgstr "A carregar o álbum…" + +msgid "Select all" +msgstr "Selecionar todos" + +msgid "Add to playlist" +msgstr "Adicionar à playlist" + +msgid "Add to Library" +msgstr "Adicionar à biblioteca" + +msgid "Make available offline" +msgstr "Disponibilizar offline" + +msgid "Clear" +msgstr "Limpar" + +msgid "LABEL" +msgstr "EDITORA" + +msgid "AWARDS" +msgstr "PRÉMIOS" + +msgid "EXTERNAL LINKS" +msgstr "LIGAÇÕES EXTERNAS" + +msgid "About this album" +msgstr "Sobre este álbum" + +msgid "Released by" +msgstr "Lançado pela" + +msgid "on" +msgstr "em" + +msgid "Credits" +msgstr "Créditos" + +msgid "Review" +msgstr "Crítica" + +msgid "Failed to load album credits" +msgstr "Falha ao carregar os créditos do álbum" + +msgid "Loading credits..." +msgstr "Carregando créditos..." + +msgid "Loading track info..." +msgstr "A carregar informações da faixa..." + +msgid "Failed to load track info" +msgstr "Falha ao carregar informações da faixa" + +msgid "Label" +msgstr "Gravadora" + +msgid "Newest" +msgstr "Mais recentes" + +msgid "Oldest" +msgstr "Mais antigos" + +msgid "A–Z" +msgstr "A–Z" + +msgid "Z–A" +msgstr "Z–A" + +msgid "Failed to load Library" +msgstr "Falha ao carregar biblioteca" + +msgid "Retry" +msgstr "Tentar Novamente" + +msgid "No releases found." +msgstr "Nenhum lançamento encontrado." + +msgid "See discography" +msgstr "Ver discografia" + +msgid "Load more" +msgstr "Carregar mais" + +msgid "Add image" +msgstr "Adicionar imagem" + +msgid "Change image" +msgstr "Alterar imagem" + +msgid "Remove image" +msgstr "Remover imagem" + +msgid "Create Artist Collection" +msgstr "Criar coleção de artistas" + +msgid "Artist Scene" +msgstr "Cena do artista" + +msgid "Share" +msgstr "Compartilhar" + +msgid "Show artist" +msgstr "Mostrar artista" + +msgid "Blacklist artist" +msgstr "Bloquear artista" + +msgid "This artist is hidden from discovery" +msgstr "Este artista está oculto da descoberta" + +msgid "Loading artist…" +msgstr "A carregar artista…" + +msgid "Popular Tracks" +msgstr "Faixas Populares" + +msgid "Play all next" +msgstr "Reproduzir tudo a seguir" + +msgid "Play all later" +msgstr "Tocar tudo depois" + +msgid "Add all to queue" +msgstr "Adicionar tudo à fila" + +msgid "Shuffle all" +msgstr "Reproduzir tudo aleatoriamente" + +msgid "Add all to playlist" +msgstr "Adicionar tudo à playlist" + +msgid "View less" +msgstr "Ver menos" + +msgid "Latest release" +msgstr "Lançamento mais recente" + +msgid "Appears On" +msgstr "Participações" + +msgid "Hide" +msgstr "Ocultar" + +msgid "Show" +msgstr "Mostrar" + +msgid "Network" +msgstr "Rede" + +msgid "Magazine" +msgstr "Revista" + +msgid "Loading origin…" +msgstr "A carregar origem…" + +msgid "BORN" +msgstr "NASCIMENTO" + +msgid "FOUNDED" +msgstr "FUNDAÇÃO" + +msgid "BORN IN" +msgstr "NASCIDO EM" + +msgid "FOUNDED IN" +msgstr "FUNDADO EM" + +msgid "DIED" +msgstr "FALECIMENTO" + +msgid "DISBANDED" +msgstr "DISSOLUÇÃO" + +msgid "LABELS" +msgstr "EDITORAS" + +msgid "No label info" +msgstr "Sem informação da editora" + +msgid "SIMILAR ARTISTS" +msgstr "ARTISTAS SEMELHANTES" + +msgid "RELATIONSHIPS" +msgstr "RELAÇÕES" + +msgid "Members & Former" +msgstr "Membros e antigos" + +msgid "Member Of" +msgstr "Membro de" + +msgid "Collaborators" +msgstr "Colaboradores" + +msgid "YOU MAY ALSO LIKE" +msgstr "TAMBÉM PODE GOSTAR" + +msgid "No stories for this artist" +msgstr "Sem histórias para este artista" + +msgid "About this artist" +msgstr "Acerca deste artista" + +msgid "Source" +msgstr "Fonte" + +msgid "Following" +msgstr "Seguindo" + +msgid "Follow" +msgstr "Seguir" + +msgid "View all" +msgstr "Ver tudo" + +msgid "View all →" +msgstr "Ver tudo →" + +msgid "Filter by genre" +msgstr "Filtrar por gênero" + +msgid "Remember selection" +msgstr "Lembrar seleção" + +msgid "Advanced view" +msgstr "Visualização avançada" + +msgid "Search genres…" +msgstr "Pesquisar géneros…" + +msgid "Clear filter" +msgstr "Limpar filtro" + +msgid "Qobuz Mixes" +msgstr "Mixes do Qobuz" + +msgid "Elevate your day with a customized selection of music." +msgstr "Eleve seu dia com uma seleção personalizada de músicas." + +msgid "Take a weekly journey with a fresh mix every Friday." +msgstr "Embarque numa viagem semanal com uma nova mistura todas as sextas-feiras." + +msgid "A fresh shuffle from your personal library." +msgstr "Um shuffle novo da sua biblioteca pessoal." + +msgid "Discover new music from your most-played playlists." +msgstr "Descubra novas músicas a partir das suas playlists mais reproduzidas." + +msgid "Open album" +msgstr "Abrir álbum" + +msgid "Open containing album" +msgstr "Abrir o álbum de origem" + +msgid "Play" +msgstr "Reproduzir" + +msgid "Search…" +msgstr "Pesquisar…" + +msgid "1 genre" +msgstr "1 género" + +msgid "{} genres" +msgstr "{} géneros" + +msgid "No results found." +msgstr "Nenhum resultado encontrado." + +msgid "Editor's Picks" +msgstr "Escolhas do Editor" + +msgid "For You" +msgstr "Para Você" + +msgid "Qobuz Playlists" +msgstr "Playlists Qobuz" + +msgid "No playlists match the selected categories." +msgstr "Nenhuma playlist corresponde às categorias selecionadas." + +msgid "Recently Played Albums" +msgstr "Álbuns reproduzidos recentemente" + +msgid "Albums you play will appear here." +msgstr "Os álbuns que reproduzir aparecerão aqui." + +msgid "Recently Played Tracks" +msgstr "Faixas reproduzidas recentemente" + +msgid "Tracks you play will appear here." +msgstr "As faixas que reproduzir aparecerão aqui." + +msgid "Popular albums" +msgstr "Álbuns populares" + +msgid "{} selected" +msgstr "{} selecionados" + +msgid "Filter by category" +msgstr "Filtrar por categoria" + +msgid "All categories" +msgstr "Todas as categorias" + +msgid "Customize" +msgstr "Personalizar" + +msgid "Enabling more sections increases load time." +msgstr "Ativar mais secções aumenta o tempo de carregamento." + +msgid "{} of {} enabled" +msgstr "{} de {} ativados" + +msgid "Reset to defaults" +msgstr "Restaurar padrão" + +msgid "Radio Stations" +msgstr "Estações de Rádio" + +msgid "RADIO" +msgstr "RÁDIO" + +msgid "Your Top Artists" +msgstr "Seus Artistas Mais Ouvidos" + +msgid "Artists to Follow" +msgstr "Artistas para Seguir" + +msgid "Pinned" +msgstr "Fixados" + +msgid "Pin" +msgstr "Fixar" + +msgid "Unpin" +msgstr "Desafixar" + +msgid "Showing {} of {} track." +msgid_plural "Showing {} of {} tracks." +msgstr[0] "Mostrando {} de {} faixa." +msgstr[1] "Mostrando {} de {} faixas." + +msgid "Spotlight" +msgstr "Em Destaque" + +msgid "Shine a light on one of your favourite artists." +msgstr "Coloque um de seus artistas favoritos sob os holofotes." + +msgid "ARTIST" +msgstr "ARTISTA" + +msgid "Top tracks" +msgstr "Principais faixas" + +msgid "By {}" +msgstr "Por {}" + +msgid "TOP TRACKS" +msgstr "PRINCIPAIS FAIXAS" + +msgid "Qobuz Radio Station" +msgstr "Rádio Qobuz" + +msgid "Available offline" +msgstr "Disponível offline" + +msgid "Tracks" +msgstr "Faixas" + +msgid "Albums" +msgstr "Álbuns" + +msgid "Artists" +msgstr "Artistas" + +msgid "Playlists" +msgstr "Playlists" + +msgid "Labels" +msgstr "Gravadoras" + +msgid "Favorite" +msgstr "Favorito" + +msgid "Library" +msgstr "Biblioteca" + +msgid "Search" +msgstr "Buscar" + +msgid "Group: Off" +msgstr "Agrupar: Desligado" + +msgid "Group: Album" +msgstr "Agrupar: Álbum" + +msgid "Group: Artist" +msgstr "Agrupar: Artista" + +msgid "Group: Name" +msgstr "Agrupar: Nome" + +msgid "Group: A-Z" +msgstr "Agrupar: A-Z" + +msgid "Title A-Z" +msgstr "Título A-Z" + +msgid "Title Z-A" +msgstr "Título Z-A" + +msgid "Artist A-Z" +msgstr "Artista A-Z" + +msgid "Couldn't load your Library." +msgstr "Não foi possível carregar sua biblioteca." + +msgid "Remove from Library" +msgstr "Remover da biblioteca" + +msgid "No tracks in your Library yet." +msgstr "Ainda não há faixas na sua biblioteca." + +msgid "No tracks match your search." +msgstr "Nenhuma faixa corresponde à sua busca." + +msgid "No albums in your Library yet." +msgstr "Ainda não há álbuns na sua biblioteca." + +msgid "No albums match your search." +msgstr "Nenhum álbum corresponde à sua pesquisa." + +msgid "No artists in your Library yet." +msgstr "Ainda não há artistas na sua biblioteca." + +msgid "No artists match your search." +msgstr "Nenhum artista corresponde à sua pesquisa." + +msgid "No playlists match your search." +msgstr "Nenhuma playlist corresponde à sua pesquisa." + +msgid "No followed playlists yet." +msgstr "Ainda não segue nenhuma playlist." + +msgid "No playlists in your Library yet." +msgstr "Ainda não há playlists na sua biblioteca." + +msgid "No labels in your Library yet." +msgstr "Ainda não há selos na sua biblioteca." + +msgid "No labels match your search." +msgstr "Nenhuma editora corresponde à sua pesquisa." + +msgid "Select an artist to see their albums." +msgstr "Selecione um artista para ver os seus álbuns." + +msgid "Couldn't load albums." +msgstr "Não foi possível carregar os álbuns." + +msgid "No albums found." +msgstr "Nenhum álbum encontrado." + +msgid "Go to artist page" +msgstr "Ir para a página do artista" + +msgid "albums" +msgstr "álbuns" + +msgid "Releases" +msgstr "Lançamentos" + +msgid "Search this label…" +msgstr "Pesquisar nesta editora…" + +msgid "No results" +msgstr "Sem resultados" + +msgid "More labels" +msgstr "Mais gravadoras" + +msgid "About" +msgstr "Sobre" + +msgid "Folders" +msgstr "Pastas" + +msgid "tracks" +msgstr "faixas" + +msgid "Play all" +msgstr "Reproduzir tudo" + +msgid "Clear session" +msgstr "Limpar sessão" + +msgid "No playable tracks in this folder." +msgstr "Nenhuma faixa reproduzível nesta pasta." + +msgid "Play this album" +msgstr "Reproduzir este álbum" + +msgid "Local Library" +msgstr "Biblioteca Local" + +msgid "Artist Z-A" +msgstr "Artista Z-A" + +msgid "Year (newest)" +msgstr "Ano (mais recente)" + +msgid "Year (oldest)" +msgstr "Ano (mais antigo)" + +msgid "No grouping" +msgstr "Sem agrupamento" + +msgid "Group by letter" +msgstr "Agrupar por letra" + +msgid "Group by artist" +msgstr "Agrupar por artista" + +msgid "By album" +msgstr "Por álbum" + +msgid "By artist" +msgstr "Por artista" + +msgid "By name" +msgstr "Por nome" + +msgid "Search artists" +msgstr "Pesquisar artistas" + +msgid "Couldn't load your local albums." +msgstr "Não foi possível carregar os seus álbuns locais." + +msgid "No albums in your local library yet." +msgstr "Ainda não há álbuns na sua biblioteca local." + +msgid "Couldn't load your local tracks." +msgstr "Não foi possível carregar as suas faixas locais." + +msgid "No tracks in your local library yet." +msgstr "Ainda não há faixas na sua biblioteca local." + +msgid "Add to Mixtape/Collection" +msgstr "Adicionar a Mixtape/Collection" + +msgid "No folders in your local library yet." +msgstr "Ainda não há pastas na sua biblioteca local." + +msgid "No folders match your search." +msgstr "Nenhuma pasta corresponde à sua pesquisa." + +msgid "Search folders" +msgstr "Pesquisar pastas" + +msgid "sel" +msgstr "sel" + +msgid "Check all" +msgstr "Selecionar tudo" + +msgid "Select a folder to see its contents." +msgstr "Selecione uma pasta para ver o seu conteúdo." + +msgid "Search albums..." +msgstr "Buscar álbuns..." + +msgid "This folder has no tracks." +msgstr "Esta pasta não tem faixas." + +msgid "No artists in your local library yet." +msgstr "Ainda não há artistas na sua biblioteca local." + +msgid "artists" +msgstr "artistas" + +msgid "Select an artist to view their albums" +msgstr "Selecione um artista para ver seus álbuns" + +msgid "No albums found" +msgstr "Nenhum álbum encontrado" + +msgid "Local Library port in progress" +msgstr "Migração da biblioteca local em curso" + +msgid "This tab is coming next." +msgstr "Este separador chega a seguir." + +msgid "Filter" +msgstr "Filtrar" + +msgid "Hi-Res" +msgstr "Hi-Res" + +msgid "CD" +msgstr "CD" + +msgid "Lossy" +msgstr "Com perdas" + +msgid "Other" +msgstr "Outra" + +msgid "Local" +msgstr "Local" + +msgid "Offline cache" +msgstr "Cache offline" + +msgid "Scene" +msgstr "Cena" + +msgid "No artists found for this scene." +msgstr "Nenhum artista encontrado para esta cena." + +msgid "QBZ" +msgstr "QBZ" + +msgid "QOBUZ™ PLAYER" +msgstr "LEITOR QOBUZ™" + +msgid "I have read the" +msgstr "Eu li os" + +msgid "Qobuz™ Terms of Service" +msgstr "Termos de Serviço do Qobuz™" + +msgid "Sign in via Browser" +msgstr "Entrar pelo Navegador" + +msgid "Use your system browser instead" +msgstr "Usar o navegador do sistema" + +msgid "No internet connection — you can start in offline mode with your local library and downloads" +msgstr "Sem ligação à internet — pode iniciar no modo offline com a sua biblioteca local e transferências" + +msgid "A network sign-in page may be blocking the connection" +msgstr "Uma página de início de sessão da rede pode estar a bloquear a ligação" + +msgid "Could not restore your session" +msgstr "Não foi possível restaurar a sua sessão" + +msgid "Start offline (no access to Qobuz™ services)" +msgstr "Iniciar offline (sem acesso aos serviços do Qobuz™)" + +msgid "QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™." +msgstr "O QBZ requer uma assinatura ativa do Qobuz™. Suas credenciais são enviadas diretamente ao Qobuz™." + +msgid "This application uses the Qobuz API but is not certified by Qobuz." +msgstr "Este aplicativo utiliza a API do Qobuz, mas não é certificado pelo Qobuz." + +msgid "Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz." +msgstr "Qobuz™ é uma marca registrada da Qobuz. O QBZ é uma aplicação de código aberto sob a licença MIT e não é afiliado, endossado ou certificado pela Qobuz." + +msgid "No tracks for this mix." +msgstr "Sem faixas para esta mistura." + +msgid "Album" +msgstr "Álbum" + +msgid "Bands & Projects" +msgstr "Bandas" + +msgid "No album appearances found." +msgstr "Nenhuma participação em álbuns encontrada." + +msgid "You're offline" +msgstr "Está offline" + +msgid "Offline mode is enabled. Disable it in Settings to use Qobuz." +msgstr "O modo offline está ativado. Desative-o nas Definições para usar o Qobuz." + +msgid "No internet connection. Your local library and downloads keep working." +msgstr "Sem ligação à internet. A sua biblioteca local e transferências continuam a funcionar." + +msgid "Open Settings" +msgstr "Abrir Definições" + +msgid "Offline Cache" +msgstr "Cache offline" + +msgid "{} track" +msgid_plural "{} tracks" +msgstr[0] "{} faixa" +msgstr[1] "{} faixas" + +msgid "Sort: A-Z" +msgstr "Ordenar: A-Z" + +msgid "Sort: Recent" +msgstr "Ordenar: Recente" + +msgid "Sort: Largest" +msgstr "Ordenar: Maior" + +msgid "Sort: Smallest" +msgstr "Ordenar: Menor" + +msgid "Failed only" +msgstr "Apenas falhados" + +msgid " selected" +msgstr " selecionados" + +msgid "Re-download" +msgstr "Transferir novamente" + +msgid "Remove" +msgstr "Remover" + +msgid "All artists" +msgstr "Todos os artistas" + +msgid "No offline tracks yet" +msgstr "Ainda não há faixas offline" + +msgid "Search playlists…" +msgstr "Pesquisar playlists…" + +msgid "1 playlist" +msgstr "1 playlist" + +msgid "{} playlists" +msgstr "{} playlists" + +msgid "Move to folder" +msgstr "Mover para pasta" + +msgid "No folder" +msgstr "Sem pasta" + +msgid "Playlist manager" +msgstr "Gerenciador de lista" + +msgid "Visible" +msgstr "Visível" + +msgid "Hidden" +msgstr "Escondido" + +msgid "All" +msgstr "Todos" + +msgid "Recent" +msgstr "Recentes" + +msgid "Play Count" +msgstr "Nº de Reproduções" + +msgid "Track Count" +msgstr "Número de faixas" + +msgid "Name A-Z" +msgstr "Nome A-Z" + +msgid "New folder" +msgstr "Nova pasta" + +msgid "{} folders, {} playlists" +msgstr "{} pastas, {} playlists" + +msgid "Use the arrows to reorder playlists" +msgstr "Use as setas para reordenar as playlists" + +msgid "Folders ({})" +msgstr "Pastas ({})" + +msgid "Playlists ({})" +msgstr "Playlists ({})" + +msgid "No playlists found." +msgstr "Nenhuma playlist encontrada." + +msgid "Artist" +msgstr "Artista" + +msgid "Playlist" +msgstr "Playlist" + +msgid "Search tracks" +msgstr "Procurar faixas" + +msgid "Sort" +msgstr "Ordenar" + +msgid "This playlist is empty." +msgstr "Esta playlist está vazia." + +msgid "Upload to Qobuz?" +msgstr "Carregar para o Qobuz?" + +msgid "This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy." +msgstr "Isto cria a playlist no Qobuz com as suas faixas do Qobuz, mantém os ficheiros locais associados e remove a cópia apenas local." + +msgid "Cancel" +msgstr "Cancelar" + +msgid "Upload" +msgstr "Carregar" + +msgid "About this playlist" +msgstr "Sobre esta playlist" + +msgid "ITEM" +msgstr "ITEM" + +msgid "TYPE" +msgstr "TIPO" + +msgid "SOURCE" +msgstr "ORIGEM" + +msgid "QUALITY" +msgstr "QUALIDADE" + +msgid "TRACKS" +msgstr "FAIXAS" + +msgid "YEAR" +msgstr "ANO" + +msgid "Folder name" +msgstr "Nome da pasta" + +msgid "Creating..." +msgstr "A criar..." + +msgid "Create" +msgstr "Criar" + +msgid "Edit folder" +msgstr "Editar pasta" + +msgid "Enter folder name" +msgstr "Insira o nome da pasta" + +msgid "Icon" +msgstr "Ícone" + +msgid "Hide from sidebar" +msgstr "Ocultar da barra lateral" + +msgid "Delete" +msgstr "Excluir" + +msgid "Save" +msgstr "Salvar" + +msgid "JUMP TO" +msgstr "IR PARA" + +msgid "Search this page…" +msgstr "Procurar nesta página…" + +msgid "Something is already playing" +msgstr "Já está algo em reprodução" + +msgid "Add this to the current queue, or clear the queue and start playing it?" +msgstr "Adicionar isto à fila atual ou limpar a fila e começar a reproduzir?" + +msgid "Clear queue and play" +msgstr "Limpar a fila e reproduzir" + +msgid "Edit metadata" +msgstr "Editar metadados" + +msgid "Album title" +msgstr "Título do álbum" + +msgid "Album artist" +msgstr "Artista do álbum" + +msgid "Year" +msgstr "Ano" + +msgid "Genre" +msgstr "Gênero" + +msgid "Catalog number" +msgstr "Número de catálogo" + +msgid "Total discs" +msgstr "Total de discos" + +msgid "Searching…" +msgstr "A procurar…" + +msgid "No results, try a different search" +msgstr "Sem resultados, tente outra pesquisa" + +msgid "Apply" +msgstr "Aplicar" + +msgid "Album folder" +msgstr "Pasta do álbum" + +msgid "Sidecar file (recommended)" +msgstr "Ficheiro sidecar (recomendado)" + +msgid "Write to audio files" +msgstr "Escrever nos ficheiros de áudio" + +msgid "This writes tags directly into your audio files." +msgstr "Isto escreve as etiquetas diretamente nos seus ficheiros de áudio." + +msgid "Direct write is unavailable for CUE-based albums." +msgstr "A escrita direta não está disponível para álbuns baseados em CUE." + +msgid "Writing" +msgstr "A escrever" + +msgid "Saving…" +msgstr "A guardar…" + +msgid "Edit playlist" +msgstr "Editar playlist" + +msgid "Playlist name" +msgstr "Nome da playlist" + +msgid "Description" +msgstr "Descrição" + +msgid "Offline-only playlist" +msgstr "Playlist apenas offline" + +msgid "New playlist" +msgstr "Nova playlist" + +msgid "Name" +msgstr "Nome" + +msgid "My Playlist" +msgstr "Minha Playlist" + +msgid "Description (optional)" +msgstr "Descrição (opcional)" + +msgid "Folder" +msgstr "Pasta" + +msgid "Make playlist public" +msgstr "Tornar playlist pública" + +msgid "Created locally — you're offline" +msgstr "Criada localmente — você está offline" + +msgid "Add to mixtape" +msgstr "Adicionar à mixtape" + +msgid "Share Qobuz link" +msgstr "Compartilhar link do Qobuz" + +msgid "Share Album.link" +msgstr "Compartilhar Album.link" + +msgid "Refresh offline copy" +msgstr "Atualizar cópia offline" + +msgid "Some of these tracks were already added" +msgstr "Algumas dessas faixas já foram adicionadas" + +msgid "This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates." +msgstr "Esta playlist já contém {} das {} faixas que está a adicionar. Adicione apenas as novas ou tudo, incluindo duplicados." + +msgid "Adding..." +msgstr "Adicionando..." + +msgid "Add all tracks" +msgstr "Adicionar todas as faixas" + +msgid "Add only new tracks" +msgstr "Adicionar apenas as novas" + +msgid "Add to Playlist" +msgstr "Adicionar à Playlist" + +msgid "Choose playlist" +msgstr "Escolher playlist" + +msgid "Adding {} local track(s)" +msgstr "A adicionar {} faixa(s) local(is)" + +msgid "Adding {} track(s)" +msgstr "A adicionar {} faixa(s)" + +msgid "+ Create new playlist" +msgstr "+ Criar nova playlist" + +msgid "Search playlists..." +msgstr "Buscar playlists..." + +msgid "Playlist Name" +msgstr "Nome da Playlist" + +msgid "Saving..." +msgstr "Salvando..." + +msgid "Create & Add" +msgstr "Criar e Adicionar" + +msgid "Add" +msgstr "Adicionar" + +msgid "No playlists found" +msgstr "Nenhuma playlist encontrada" + +msgid "{} more playlist(s)..." +msgstr "Mais {} playlist(s)..." + +msgid "Import Playlist" +msgstr "Importar Playlist" + +msgid "This feature requires internet connection" +msgstr "Este recurso requer conexão com a internet" + +msgid "Playlist URL" +msgstr "URL da Playlist" + +msgid "ALLOWED SOURCES" +msgstr "FONTES PERMITIDAS" + +msgid "Conversion progress" +msgstr "Progresso da conversão" + +msgid "Summary" +msgstr "Resumo" + +msgid "Close" +msgstr "Fechar" + +msgid "Importing..." +msgstr "Importando..." + +msgid "Import" +msgstr "Importar" + +msgid "Fetching..." +msgstr "Buscando..." + +msgid "Fetch playlist" +msgstr "Buscar playlist" + +msgid "Shuffle" +msgstr "Aleatório" + +msgid "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" +msgstr "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" + +msgid "24-bit / 44.1 kHz — Billie Eilish · LUNCH" +msgstr "24-bit / 44.1 kHz — Billie Eilish · LUNCH" + +msgid "24-bit / 96 kHz — Iron Maiden · Stratego" +msgstr "24-bit / 96 kHz — Iron Maiden · Stratego" + +msgid "24-bit / 192 kHz — Toto · Africa" +msgstr "24-bit / 192 kHz — Toto · Africa" + +msgid "Welcome" +msgstr "Boas-vindas" + +msgid "Check" +msgstr "Verificar" + +msgid "DACs" +msgstr "DACs" + +msgid "Configure" +msgstr "Configurar" + +msgid "Test" +msgstr "Testar" + +msgid "Done" +msgstr "Concluído" + +msgid "HiFi Wizard — DAC Setup" +msgstr "Assistente HiFi — Configuração do DAC" + +msgid "Guided DAC Setup" +msgstr "Configuração Guiada do DAC" + +msgid "This wizard helps you configure your DAC for bit-perfect playback on Linux. QBZ never changes your system automatically — it auto-detects your DACs, shows you the exact commands and config to copy, and lets you test playback right here." +msgstr "Este assistente ajuda-o a configurar o seu DAC para reprodução bit-perfect no Linux. O QBZ nunca altera o seu sistema automaticamente — deteta os seus DACs, mostra-lhe os comandos e a configuração exatos para copiar e permite testar a reprodução aqui mesmo." + +msgid "You are responsible for your own backups. This is a helper, not a system recovery tool." +msgstr "Você é responsável pelas suas próprias cópias de segurança. Isto é uma ajuda, não uma ferramenta de recuperação do sistema." + +msgid "What to test with" +msgstr "Com o que testar" + +msgid "The built-in test plays four tracks spanning different bit-depths and sample rates (16/44.1, 24/44.1, 24/96, 24/192) so you can see your DAC switch. If you'd rather use your own queue, add one track of each kind first so every rate is exercised." +msgstr "O teste integrado reproduz quatro faixas com diferentes profundidades de bits e taxas de amostragem (16/44.1, 24/44.1, 24/96, 24/192) para que veja o seu DAC mudar. Se preferir usar a sua própria fila, adicione primeiro uma faixa de cada tipo para que todas as taxas sejam testadas." + +msgid "I understand and accept these terms" +msgstr "Eu entendo e aceito estes termos" + +msgid "Check Audio Stack" +msgstr "Verificar Stack de Áudio" + +msgid "Sandboxed — host check unavailable" +msgstr "Em sandbox — verificação do host indisponível" + +msgid "QBZ is packaged as Flatpak/Snap and can't inspect your host audio stack. If playback already works, you're set. Pick your distribution and init system below for the right setup commands." +msgstr "O QBZ é empacotado como Flatpak/Snap e não consegue inspecionar a pilha de áudio do host. Se a reprodução já funciona, está tudo pronto. Escolha abaixo a sua distribuição e o sistema de init para obter os comandos de configuração corretos." + +msgid "Audio stack ready" +msgstr "Pilha de áudio pronta" + +msgid "Some packages are missing" +msgstr "Faltam alguns pacotes" + +msgid "Your distribution" +msgstr "A sua distribuição" + +msgid "Init system (service commands)" +msgstr "Sistema de init (comandos de serviço)" + +msgid "Which DACs do you want to configure?" +msgstr "Que DACs quer configurar?" + +msgid "Detecting your DACs…" +msgstr "A detetar os seus DAC…" + +msgid "default" +msgstr "predefinido" + +msgid "Hide manual entry" +msgstr "Ocultar entrada manual" + +msgid "Can't see your DAC?" +msgstr "Não vê o seu DAC?" + +msgid "Find your DAC's PipeWire node name and paste it here:" +msgstr "Encontre o nome do nó PipeWire do seu DAC e cole-o aqui:" + +msgid "1. Find your DAC under 'Audio > Sinks' and note its number" +msgstr "1. Encontre o seu DAC em 'Audio > Sinks' e anote o seu número" + +msgid "2. Run with that number to read the node.name" +msgstr "2. Execute com esse número para ler o node.name" + +msgid "Valid node name" +msgstr "Nome de nó válido" + +msgid "Invalid — should contain alsa_output or alsa_input" +msgstr "Inválido — deve conter alsa_output ou alsa_input" + +msgid "Review & Apply" +msgstr "Rever e aplicar" + +msgid "QBZ never writes these files" +msgstr "O QBZ nunca escreve estes ficheiros" + +msgid "QBZ already pins rate, sink and exclusive mode at runtime. These files only change system-wide behavior — copy and run them yourself if you want them. Back up first." +msgstr "O QBZ já fixa a taxa, o sink e o modo exclusivo em tempo de execução. Estes ficheiros apenas alteram o comportamento de todo o sistema — copie-os e execute-os por si próprio se os quiser. Faça primeiro uma cópia de segurança." + +msgid "1. Back up your current PipeWire/WirePlumber config" +msgstr "1. Faça uma cópia de segurança da sua configuração atual do PipeWire/WirePlumber" + +msgid "I created a backup" +msgstr "Criei uma cópia de segurança" + +msgid "Sample-rate switching (PipeWire)" +msgstr "Mudança de taxa de amostragem (PipeWire)" + +msgid "Per-app bit-perfect (PipeWire client)" +msgstr "Bit-perfect por aplicação (cliente PipeWire)" + +msgid "Pin the DAC + rates (WirePlumber)" +msgstr "Fixar o DAC + taxas (WirePlumber)" + +msgid "I created the configuration files" +msgstr "Criei os ficheiros de configuração" + +msgid "3. Restart the audio services to load the config" +msgstr "3. Reinicie os serviços de áudio para carregar a configuração" + +msgid "I restarted the audio services" +msgstr "Reiniciei os serviços de áudio" + +msgid "Test Playback" +msgstr "Testar a reprodução" + +msgid "Plays four tracks at different bit-depths and sample rates through the selected DAC. Watch your DAC's display switch rates — and confirm it below." +msgstr "Reproduz quatro faixas com diferentes profundidades de bits e taxas de amostragem através do DAC selecionado. Observe o ecrã do seu DAC a mudar de taxa — e confirme abaixo." + +msgid "Stop" +msgstr "Parar" + +msgid "Play test" +msgstr "Reproduzir teste" + +msgid "Use my current queue" +msgstr "Usar a minha fila atual" + +msgid "All set" +msgstr "Tudo pronto" + +msgid "Your DAC is ready for bit-perfect playback. If you applied the config files, restart the audio services (or log out and back in) for them to take effect." +msgstr "O seu DAC está pronto para a reprodução bit-perfect. Se aplicou os ficheiros de configuração, reinicie os serviços de áudio (ou termine sessão e volte a iniciar) para que tenham efeito." + +msgid "Config files you can create:" +msgstr "Ficheiros de configuração que pode criar:" + +msgid "Back" +msgstr "Voltar" + +msgid "Start" +msgstr "Iniciar" + +msgid "Finish" +msgstr "Concluir" + +msgid "Next" +msgstr "Próximo" + +msgid "Play now" +msgstr "Reproduzir agora" + +msgid "Start playing now?" +msgstr "Iniciar a reprodução agora?" + +msgid "Not now" +msgstr "Agora não" + +msgid "Show location" +msgstr "Mostrar localização" + +msgid "Remove from Local Library" +msgstr "Remover da biblioteca local" + +msgid "Forget download record" +msgstr "Esquecer registro do download" + +msgid "Could not open that folder." +msgstr "Não foi possível abrir essa pasta." + +msgid "Could not remove that library folder." +msgstr "Não foi possível remover essa pasta da biblioteca." + +msgid "Could not forget that download." +msgstr "Não foi possível esquecer esse download." + +msgid "Create radio" +msgstr "Criar rádio" + +msgid "Add to library" +msgstr "Adicionar à biblioteca" + +msgid "Remove from playlist" +msgstr "Remover da lista de reprodução" + +msgid "Share Song.link" +msgstr "Partilhar Song.link" + +msgid "Remove offline copy" +msgstr "Remover cópia off-line" + +msgid "Go to album" +msgstr "Ir ao álbum" + +msgid "Go to artist" +msgstr "Ir para o artista" + +msgid "Track info" +msgstr "Informações da faixa" + +msgid "selected" +msgstr "selecionado" + +msgid "Artist is blacklisted" +msgstr "O artista está na lista negra" + +msgid "Main Artist" +msgstr "Artista Principal" + +msgid "Performer" +msgstr "Intérprete" + +msgid "Composer" +msgstr "Compositor" + +msgid "Release Name" +msgstr "Nome do lançamento" + +msgid "Most popular" +msgstr "Mais populares" + +msgid "No results." +msgstr "Sem resultados." + +msgid "No results for" +msgstr "Sem resultados para" + +msgid "Top result" +msgstr "Melhor resultado" + +msgid "View more" +msgstr "Ver mais" + +msgid "QOBUZ CONNECT" +msgstr "QOBUZ CONNECT" + +msgid "Connect diagnostics" +msgstr "Diagnóstico do Connect" + +msgid "Live session topology and a rolling event log for debugging Qobuz Connect at runtime." +msgstr "Topologia da sessão em tempo real e um registo de eventos contínuo para depurar o Qobuz Connect em tempo de execução." + +msgid "Open diagnostics" +msgstr "Abrir diagnóstico" + +msgid "PLAYBACK" +msgstr "REPRODUÇÃO" + +msgid "Continue playback after track ends" +msgstr "Continuar reprodução após a faixa terminar" + +msgid "Keep playing the rest of the album or playlist instead of stopping." +msgstr "Continuar a reproduzir o resto do álbum ou da playlist em vez de parar." + +msgid "Show track playing context" +msgstr "Mostrar o contexto de reprodução da faixa" + +msgid "Display the context-stack icon in the player." +msgstr "Mostrar o ícone da pilha de contexto no reprodutor." + +msgid "Gapless playback" +msgstr "Reprodução sem intervalos" + +msgid "Play consecutive same-format tracks without a gap." +msgstr "Reproduzir faixas consecutivas no mesmo formato sem intervalo." + +msgid "SESSION" +msgstr "SESSÃO" + +msgid "Restore session on startup" +msgstr "Restaurar sessão na inicialização" + +msgid "Restore the queue and current track on the next launch." +msgstr "Restaurar a fila e a faixa atual no próximo arranque." + +msgid "Resume playback position" +msgstr "Retomar posição de reprodução" + +msgid "Also seek back to where the saved track left off." +msgstr "Também voltar ao ponto onde a faixa guardada parou." + +msgid "STREAMING" +msgstr "STREAMING" + +msgid "Stream uncached tracks" +msgstr "Transmitir faixas sem cache" + +msgid "Start uncached tracks via streaming instead of waiting for the full download." +msgstr "Iniciar as faixas sem cache por streaming em vez de esperar pelo download completo." + +msgid "Initial buffer size" +msgstr "Tamanho do buffer inicial" + +msgid "Seconds of audio buffered before streaming playback starts." +msgstr "Segundos de áudio em buffer antes de a reprodução por streaming começar." + +msgid "s" +msgstr "s" + +msgid "Streaming only" +msgstr "Apenas streaming" + +msgid "Skip writing tracks to the local cache while streaming." +msgstr "Não escrever faixas na cache local durante o streaming." + +msgid "When quality retries fail" +msgstr "Quando as tentativas de qualidade falham" + +msgid "What to do when every quality tier for a track is unavailable." +msgstr "O que fazer quando nenhum nível de qualidade de uma faixa está disponível." + +msgid "Folder settings" +msgstr "Definições da pasta" + +msgid "Folder settings saved" +msgstr "Definições da pasta guardadas" + +msgid "Could not save the folder settings." +msgstr "Não foi possível guardar as definições da pasta." + +msgid "Folder location" +msgstr "Localização da pasta" + +msgid "Change" +msgstr "Alterar" + +msgid "Checking..." +msgstr "Verificando..." + +msgid "Folder is accessible" +msgstr "A pasta está acessível" + +msgid "Folder is not accessible" +msgstr "A pasta não está acessível" + +msgid "Display name" +msgstr "Nome de exibição" + +msgid "Optional friendly name" +msgstr "Nome amigável opcional" + +msgid "Shown instead of the full path in the folder list." +msgstr "Apresentado em vez do caminho completo na lista de pastas." + +msgid "Enabled" +msgstr "Ativada" + +msgid "Disabled folders are skipped during scans." +msgstr "As pastas desativadas são ignoradas durante as análises." + +msgid "Network folder" +msgstr "Pasta de rede" + +msgid "Mark this folder as a network share (excluded when offline)." +msgstr "Marcar esta pasta como uma partilha de rede (excluída offline)." + +msgid "Network filesystem type" +msgstr "Tipo de sistema de ficheiros de rede" + +msgid "Auto-detect" +msgstr "Detectar automaticamente" + +msgid "Last scanned" +msgstr "Último escaneamento" + +msgid "Scan this folder" +msgstr "Analisar esta pasta" + +msgid "FOLDER" +msgstr "PASTA" + +msgid "LAST SCAN" +msgstr "ÚLTIMA ANÁLISE" + +msgid "STATUS" +msgstr "ESTADO" + +msgid "Unavailable" +msgstr "Indisponível" + +msgid "Active" +msgstr "Ativa" + +msgid "LIBRARY FOLDERS" +msgstr "PASTAS DA BIBLIOTECA" + +msgid "Browse..." +msgstr "Procurar..." + +msgid "Choose a music folder" +msgstr "Escolher uma pasta de música" + +msgid "Browse for a music folder, or type the full path." +msgstr "Procure uma pasta de música ou introduza o caminho completo." + +msgid "Filter folders..." +msgstr "Filtrar pastas..." + +msgid "No folders yet. Add a folder to build your local library." +msgstr "Ainda não há pastas. Adicione uma pasta para criar a sua biblioteca local." + +msgid "No folders match your filter." +msgstr "Nenhuma pasta corresponde ao seu filtro." + +msgid "Scanning" +msgstr "A analisar" + +msgid "MAINTENANCE" +msgstr "MANUTENÇÃO" + +msgid "Cleanup missing files" +msgstr "Limpar arquivos ausentes" + +msgid "Remove tracks whose files no longer exist on disk." +msgstr "Remover faixas cujos ficheiros já não existem no disco." + +msgid "Cleaning up..." +msgstr "A limpar..." + +msgid "Cleanup" +msgstr "Limpeza" + +msgid "DANGER ZONE" +msgstr "ZONA DE PERIGO" + +msgid "Clear library database" +msgstr "Limpar a base de dados da biblioteca" + +msgid "Remove all indexed tracks. Your audio files are not deleted." +msgstr "Remover todas as faixas indexadas. Os seus ficheiros de áudio não são eliminados." + +msgid "Clearing..." +msgstr "Limpando..." + +msgid "Clear all" +msgstr "Limpar tudo" + +msgid "PLEX" +msgstr "PLEX" + +msgid "Connect a Plex Media Server on your local network." +msgstr "Ligue um Plex Media Server na sua rede local." + +msgid "Server address" +msgstr "Endereço do servidor" + +msgid "Only local network servers are supported." +msgstr "Apenas servidores da rede local são suportados." + +msgid "Enter a valid server address." +msgstr "Introduza um endereço de servidor válido." + +msgid "http://127.0.0.1:32400" +msgstr "http://127.0.0.1:32400" + +msgid "Authorize" +msgstr "Autorizar" + +msgid "Generate a code and sign in to Plex in your browser." +msgstr "Gere um código e inicie sessão no Plex no seu navegador." + +msgid "Working..." +msgstr "A processar..." + +msgid "Generate code" +msgstr "Gerar código" + +msgid "Link code" +msgstr "Código de associação" + +msgid "Enter this code at the Plex sign-in page." +msgstr "Introduza este código na página de início de sessão do Plex." + +msgid "Copy code" +msgstr "Copiar código" + +msgid "Open Plex sign-in" +msgstr "Abrir início de sessão do Plex" + +msgid "Connected to Plex" +msgstr "Ligado ao Plex" + +msgid "The code expired. Generate a new one." +msgstr "O código expirou. Gere um novo." + +msgid "Ping the saved server and report what answers." +msgstr "Faz ping ao servidor guardado e indica o que responde." + +msgid "Connected to {}" +msgstr "Ligado a {}" + +msgid "Could not reach the Plex server." +msgstr "Não foi possível alcançar o servidor Plex." + +msgid "Plex is not configured." +msgstr "O Plex não está configurado." + +msgid "Enter token manually" +msgstr "Introduzir token manualmente" + +msgid "Use an existing X-Plex-Token instead of the PIN flow." +msgstr "Use um X-Plex-Token existente em vez do fluxo por PIN." + +msgid "Token" +msgstr "Token" + +msgid "X-Plex-Token" +msgstr "X-Plex-Token" + +msgid "Check connection" +msgstr "Verificar conexão" + +msgid "Verify the server is reachable." +msgstr "Verifique se o servidor está acessível." + +msgid "Libraries" +msgstr "Bibliotecas" + +msgid "Fetch your Plex music libraries." +msgstr "Obtenha as suas bibliotecas de música do Plex." + +msgid "Get libraries" +msgstr "Obter bibliotecas" + +msgid "MUSIC LIBRARIES" +msgstr "BIBLIOTECAS DE MÚSICA" + +msgid "({} tracks)" +msgstr "({} faixas)" + +msgid "Write metadata to Plex (experimental)" +msgstr "Gravar metadados no Plex (experimental)" + +msgid "Allow QBZ to write track metadata back to your Plex server." +msgstr "Permitir que o QBZ grave os metadados das faixas de volta no seu servidor Plex." + +msgid "PLEX DANGER ZONE" +msgstr "ZONA DE PERIGO DO PLEX" + +msgid "Disconnect" +msgstr "Desconectar" + +msgid "Sign out of Plex and clear the local cache." +msgstr "Terminar sessão no Plex e limpar a cache local." + +msgid "Clear cache" +msgstr "Limpar cache" + +msgid "Remove cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Remover as bibliotecas e faixas do Plex em cache. A sua sessão é mantida." + +msgid "OFFLINE MODE" +msgstr "MODO OFFLINE" + +msgid "Status" +msgstr "Status" + +msgid "Captive portal detected — sign in to the network to get online." +msgstr "Portal cativo detetado — inicie sessão na rede para ficar online." + +msgid "Offline mode enabled" +msgstr "Modo offline ativado" + +msgid "No connection" +msgstr "Sem conexão" + +msgid "Online" +msgstr "Online" + +msgid "Check now" +msgstr "Verificar agora" + +msgid "Enable Offline Mode" +msgstr "Ativar Modo Offline" + +msgid "Test the connection now instead of waiting for the next automatic check." +msgstr "Testar a ligação agora em vez de esperar pela próxima verificação automática." + +msgid "Include sign-in credentials" +msgstr "Incluir credenciais de início de sessão" + +msgid "The bundle will contain your tokens. Anyone who opens the file can sign in as you." +msgstr "O pacote conterá os seus tokens. Quem abrir o ficheiro pode iniciar sessão como você." + +msgid "Manually switch to offline mode even with internet." +msgstr "Mudar manualmente para o modo offline mesmo com internet." + +msgid "OFFLINE CACHE" +msgstr "CACHE OFFLINE" + +msgid "Manage offline cache" +msgstr "Gerenciar cache offline" + +msgid "Browse and manage your downloaded tracks and albums." +msgstr "Navegue e gerencie as suas faixas e álbuns transferidos." + +msgid "Open manager" +msgstr "Abrir gerenciador" + +msgid "Cache folder" +msgstr "Pasta de cache" + +msgid "Open the folder where offline tracks are stored on disk." +msgstr "Abrir a pasta onde as faixas offline são armazenadas no disco." + +msgid "Open folder" +msgstr "Abrir pasta" + +msgid "Clear offline cache" +msgstr "Limpar cache offline" + +msgid "Remove every downloaded track. This cannot be undone." +msgstr "Remover todas as faixas transferidas. Esta ação não pode ser desfeita." + +msgid "LYRICS" +msgstr "LETRAS" + +msgid "Clear lyrics cache" +msgstr "Limpar cache de letras" + +msgid "{} entries using {}" +msgstr "{} entradas usando {}" + +msgid "Manage" +msgstr "Gerenciar" + +msgid "Artist Blacklist" +msgstr "Lista de Bloqueio de Artistas" + +msgid "{} artist(s) blocked" +msgstr "{} artista(s) bloqueado(s)" + +msgid "{} artist(s) blocked (disabled)" +msgstr "{} artista(s) bloqueado(s) (desativado)" + +msgid "Settings" +msgstr "Configurações" + +msgid "Audio" +msgstr "Áudio" + +msgid "Playback" +msgstr "Reprodução" + +msgid "Appearance" +msgstr "Aparência" + +msgid "Offline" +msgstr "Offline" + +msgid "Blacklist" +msgstr "Lista de Bloqueio" + +msgid "Integrations" +msgstr "Integrações" + +msgid "Developer" +msgstr "Programador" + +msgid "Loading settings..." +msgstr "A carregar definições..." + +msgid "Added {}" +msgstr "Adicionado {}" + +msgid "Blacklisted artists are hidden from search results, radio, suggestions, and similar artists." +msgstr "Artistas bloqueados são ocultados dos resultados de busca, rádio, sugestões e artistas similares." + +msgid "Clear All" +msgstr "Limpar Tudo" + +msgid "{} artists" +msgstr "{} artistas" + +msgid "Blacklist filtering is disabled. Blacklisted artists are shown everywhere until you re-enable it." +msgstr "A filtragem da lista negra está desativada. Os artistas na lista negra são mostrados em todo o lado até a reativar." + +msgid "Loading blacklist…" +msgstr "A carregar lista negra…" + +msgid "No blacklisted artists" +msgstr "Nenhum artista bloqueado" + +msgid "To hide an artist, go to their page and use the menu." +msgstr "Para ocultar um artista, vá à página dele e utilize o menu." + +msgid "No results for \"{}\"" +msgstr "Sem resultados para \"{}\"" + +msgid "Clear Blacklist?" +msgstr "Limpar Lista de Bloqueio?" + +msgid "This will remove all {} blacklisted artists. This cannot be undone." +msgstr "Isto irá remover todos os {} artistas da lista negra. Esta ação não pode ser desfeita." + +msgid "SCROBBLERS" +msgstr "SCROBBLERS" + +msgid "Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services." +msgstr "Envie as suas reproduções para o Last.fm e o ListenBrainz. Funciona com faixas do Qobuz, locais e do Plex. Este interruptor também decide se as Recomendações podem ler o seu histórico de audição desses serviços." + +msgid "LAST.FM" +msgstr "LAST.FM" + +msgid "Scrobble to Last.fm" +msgstr "Fazer scrobbling no Last.fm" + +msgid "Signed in as {}." +msgstr "Sessão iniciada como {}." + +msgid "Connect your Last.fm account to enable scrobbling." +msgstr "Conecte a sua conta Last.fm para ativar o scrobbling." + +msgid "Connect" +msgstr "Conectar" + +msgid "Authorize QBZ in your browser, then click Finish." +msgstr "Autorize o QBZ no seu navegador e depois clique em Concluir." + +msgid "Connect Last.fm" +msgstr "Conectar Last.fm" + +msgid "Open authorize page" +msgstr "Abrir página de autorização" + +msgid "Disconnect Last.fm" +msgstr "Desligar Last.fm" + +msgid "Sign out of Last.fm." +msgstr "Terminar sessão no Last.fm." + +msgid "LISTENBRAINZ" +msgstr "LISTENBRAINZ" + +msgid "Scrobble to ListenBrainz" +msgstr "Fazer scrobbling no ListenBrainz" + +msgid "Paste your ListenBrainz user token to enable scrobbling." +msgstr "Cole o seu token de utilizador do ListenBrainz para ativar o scrobbling." + +msgid "User token" +msgstr "Token de utilizador" + +msgid "From listenbrainz.org/settings." +msgstr "De listenbrainz.org/settings." + +msgid "ListenBrainz token" +msgstr "Token do ListenBrainz" + +msgid "Save token" +msgstr "Guardar token" + +msgid "Validates the token against ListenBrainz." +msgstr "Valida o token no ListenBrainz." + +msgid "Validating..." +msgstr "A validar..." + +msgid "Connect ListenBrainz" +msgstr "Conectar ListenBrainz" + +msgid "Disconnect ListenBrainz" +msgstr "Desligar ListenBrainz" + +msgid "Sign out of ListenBrainz." +msgstr "Terminar sessão no ListenBrainz." + +msgid "DISCORD" +msgstr "DISCORD" + +msgid "Discord Rich Presence" +msgstr "Discord Rich Presence" + +msgid "Show what you're listening to as your Discord status. Opt-in — Discord must be running." +msgstr "Mostra o que está a ouvir como o seu estado do Discord. Opcional — o Discord tem de estar em execução." + +msgid "Flatpak socket access" +msgstr "Acesso ao socket do Flatpak" + +msgid "" +"Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" +msgstr "" +"Instalação Flatpak e a presença não aparece? Conceda acesso ao socket IPC do Discord e, em seguida, reinicie o QBZ:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" + +msgid "Streaming quality" +msgstr "Qualidade de streaming" + +msgid "The quality tier QBZ requests for playback." +msgstr "O nível de qualidade que o QBZ solicita para a reprodução." + +msgid "Limit quality to device" +msgstr "Limitar a qualidade ao dispositivo" + +msgid "Cap streaming quality at the output device's maximum sample rate." +msgstr "Limita a qualidade de streaming à frequência de amostragem máxima do dispositivo de saída." + +msgid "Maximum sample rate" +msgstr "Frequência de amostragem máxima" + +msgid "The highest sample rate streamed tracks may use." +msgstr "A frequência de amostragem mais alta que as faixas em streaming podem usar." + +msgid "OUTPUT" +msgstr "SAÍDA" + +msgid "Audio backend" +msgstr "Backend de áudio" + +msgid "The audio stack QBZ routes playback through." +msgstr "A pilha de áudio através da qual o QBZ encaminha a reprodução." + +msgid "Output device" +msgstr "Dispositivo de saída" + +msgid "The DAC or sound device that receives audio." +msgstr "O DAC ou dispositivo de som que recebe o áudio." + +msgid "ALSA plugin" +msgstr "Plugin ALSA" + +msgid "How ALSA opens the device — hw is bit-perfect, plughw converts." +msgstr "Como o ALSA abre o dispositivo — hw é bit-perfect, plughw converte." + +msgid "Hardware volume control" +msgstr "Controlo de volume por hardware" + +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Controla o volume por meio de um mixer disponibilizado pelo dispositivo ALSA selecionado. Se não estiver disponível, a reprodução direta permanece fixa em 100%." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Este dispositivo ALSA não possui um controle de volume por hardware compatível. A reprodução direta permanece fixa em 100%." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "O QBZ não conseguiu liberar completamente o dispositivo de áudio anterior. Consulte o log de áudio para obter detalhes." + +msgid "BIT-PERFECT" +msgstr "BIT-PERFECT" + +msgid "Exclusive mode" +msgstr "Modo exclusivo" + +msgid "Lock the device so no other app can resample it." +msgstr "Bloqueia o dispositivo para que nenhuma outra aplicação o possa reamostrar." + +msgid "Reserve DAC while running" +msgstr "Reservar o DAC durante a execução" + +msgid "Hold the device reserved so other apps can't grab it." +msgstr "Mantém o dispositivo reservado para que outras aplicações não o possam ocupar." + +msgid "DAC passthrough" +msgstr "Passthrough do DAC" + +msgid "Send the bitstream untouched to the DAC." +msgstr "Envia o fluxo de bits intacto para o DAC." + +msgid "Force bit-perfect" +msgstr "Forçar bit-perfect" + +msgid "Pin the PipeWire quantum and rate for the active track." +msgstr "Fixa o quantum e a frequência do PipeWire para a faixa ativa." + +msgid "Allow quality fallback" +msgstr "Permitir fallback de qualidade" + +msgid "Drop to a lower tier when the requested one is unavailable." +msgstr "Baixa para um nível inferior quando o solicitado não está disponível." + +msgid "HiFi Wizard" +msgstr "Assistente HiFi" + +msgid "Auto-detect your DACs and set up bit-perfect playback, step by step." +msgstr "Deteta automaticamente os seus DAC e configura a reprodução bit-perfect, passo a passo." + +msgid "Open Wizard" +msgstr "Abrir assistente" + +msgid "STARTUP" +msgstr "ARRANQUE" + +msgid "Sync audio settings on startup" +msgstr "Sincronizar configurações de áudio na inicialização" + +msgid "Reload saved audio settings into the player when QBZ launches." +msgstr "Recarrega as definições de áudio guardadas no leitor quando o QBZ é iniciado." + +msgid "Lock output device" +msgstr "Bloquear dispositivo de saída" + +msgid "Keep external routing intact — skip switching the default sink." +msgstr "Mantém o encaminhamento externo intacto — não altera o sink predefinido." + +msgid "THEME" +msgstr "TEMA" + +msgid "Theme" +msgstr "Tema" + +msgid "Album header gradient" +msgstr "Gradiente no cabeçalho do álbum" + +msgid "Use artwork-derived blur as a backdrop in album and artist detail views." +msgstr "Utiliza um desfoque derivado da capa como fundo nas vistas de detalhe de álbum e artista." + +msgid "Intelligent Search" +msgstr "Pesquisa inteligente" + +msgid "Smart search cache, ranking, and the search preview dropdown. On by default." +msgstr "Cache de pesquisa inteligente, classificação e o menu pendente de pré-visualização de pesquisa. Ativado por predefinição." + +msgid "Generate a color theme from your system wallpaper or a custom image" +msgstr "Gerar um tema de cores a partir do seu papel de parede ou de uma imagem personalizada" + +msgid "Select Image..." +msgstr "Selecionar Imagem..." + +msgid "Detected: " +msgstr "Detetado: " + +msgid "Experimental: theme may not match your system exactly." +msgstr "Experimental: o tema pode não corresponder exatamente ao seu sistema." + +msgid "Regenerate" +msgstr "Regenerar" + +msgid "TYPOGRAPHY & LANGUAGE" +msgstr "TIPOGRAFIA E IDIOMA" + +msgid "Language" +msgstr "Idioma" + +msgid "UI Scale" +msgstr "Escala da Interface" + +msgid "Immersive search" +msgstr "Pesquisa imersiva" + +msgid "What selecting a result in the Immersive search does. Disabled turns the in-immersive search off." +msgstr "O que faz a seleção de um resultado na pesquisa imersiva. Desativado desliga a pesquisa dentro do modo imersivo." + +msgid "Immersive default view" +msgstr "Visualização padrão do imersivo" + +msgid "Which immersive view opens by default. 'Remember last' restores your last view." +msgstr "Que vista imersiva abre por predefinição. «Recordar a última» restaura a sua última vista." + +msgid "LIBRARY & VISUALS" +msgstr "BIBLIOTECA E VISUAIS" + +msgid "Show navigation in sidebar" +msgstr "Mostrar navegação na barra lateral" + +msgid "Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar." +msgstr "Move as secções Descobrir, Biblioteca, Biblioteca local e O meu QBZ do cabeçalho para a barra lateral." + +msgid "My QBZ" +msgstr "My QBZ" + +msgid "Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default." +msgstr "Muda o nome do hub O meu QBZ. Deixe o nome em branco (ou prima repor) para restaurar a predefinição." + +msgid "Show playlist cover collages in sidebar" +msgstr "Mostrar colagens de capas das playlists na barra lateral" + +msgid "Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images." +msgstr "Renderiza uma miniatura 2×2 das capas das faixas ao lado de cada playlist. Desative em máquinas modestas para poupar as imagens extras." + +msgid "Show album artwork in Local Library track list" +msgstr "Mostrar capa do álbum na lista de faixas da Biblioteca local" + +msgid "Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost." +msgstr "Exibe a miniatura da capa do álbum ao lado de cada faixa nas visualizações de Faixas e Pastas. Desligado por padrão — bibliotecas grandes pagam um custo de decodificação de imagem por linha." + +msgid "NOTIFICATIONS" +msgstr "NOTIFICAÇÕES" + +msgid "In-app toasts notifications" +msgstr "Notificações internas do app" + +msgid "System Notifications" +msgstr "Notificações do Sistema" + +msgid "WINDOW TITLE" +msgstr "TÍTULO DA JANELA" + +msgid "Show track in window title" +msgstr "Mostrar faixa no título" + +msgid "Title template" +msgstr "Modelo do título" + +msgid "Placeholders: {{artist}}, {{track}}, {{album}}" +msgstr "Marcadores: {{artist}}, {{track}}, {{album}}" + +msgid "TITLE BAR" +msgstr "BARRA DE TÍTULO" + +msgid "Use system title bar" +msgstr "Usar barra de título do sistema" + +msgid "Replace QBZ custom title bar with your system's native window decorations. Changing this will restart the app." +msgstr "Substituir a barra de título personalizada do QBZ pelas decorações de janela nativas do seu sistema. Alterar isso reiniciará o app." + +msgid "Hide title bar" +msgstr "Ocultar barra de título" + +msgid "Hide the title bar completely (for tiling window manager users)" +msgstr "Ocultar completamente a barra de título (para usuários de gerenciadores de janela tiling)" + +msgid "WINDOW CONTROLS" +msgstr "CONTROLOS DA JANELA" + +msgid "Window controls position" +msgstr "Posição dos controles da janela" + +msgid "Place the window control buttons on the left or right side of the title bar" +msgstr "Posicionar os botões de controle da janela no lado esquerdo ou direito da barra de título" + +msgid "Show window controls" +msgstr "Mostrar controles da janela" + +msgid "Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these." +msgstr "Mostrar botões minimizar, maximizar e fechar na barra de título. Desative se o seu gerenciador de janelas já os fornece." + +msgid "PLAYER VIEWS" +msgstr "VISTAS DO LEITOR" + +msgid "Show volume +/- buttons" +msgstr "Mostrar botões de volume +/-" + +msgid "Add discrete plus and minus buttons next to the volume slider in the player bar." +msgstr "Adiciona botões individuais de mais e menos junto ao controlo deslizante de volume na barra do leitor." + +msgid "Mini player default view" +msgstr "Visualização padrão do mini player" + +msgid "Startup page" +msgstr "Página inicial" + +msgid "Choose which page to show when the app starts" +msgstr "Escolha qual página mostrar quando o app iniciar" + +msgid "Show Purchases" +msgstr "Mostrar Compras" + +msgid "Show the Purchases section in the sidebar for browsing and downloading your purchased music" +msgstr "Mostrar a seção Compras na barra lateral para navegar e baixar suas músicas compradas" + +msgid "MENU BAR" +msgstr "BARRA DE MENUS" + +msgid "SYSTEM TRAY" +msgstr "BANDEJA DO SISTEMA" + +msgid "Enable tray icon" +msgstr "Ativar ícone na bandeja" + +msgid "Show icon in system tray (requires restart)" +msgstr "Mostrar ícone na bandeja do sistema (requer reinicialização)" + +msgid "Close to tray" +msgstr "Fechar para a bandeja" + +msgid "Keep playing in the menu bar instead of quitting when you close the window" +msgstr "Continua a reproduzir na barra de menus em vez de sair quando fecha a janela" + +msgid "Keep playing in the tray instead of quitting when you close the window" +msgstr "Continua a reproduzir na bandeja em vez de sair quando fecha a janela" + +msgid "Hide Dock icon when closed to menu bar" +msgstr "Ocultar o ícone da Dock ao fechar para a barra de menus" + +msgid "Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)" +msgstr "Executar como aplicação apenas na barra de menus enquanto a janela estiver fechada. Desativado mantém o ícone do Dock (como o Spotify)" + +msgid "Tray icon variant" +msgstr "Variante do ícone" + +msgid "Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo" +msgstr "Escolha um ícone monocromático para a sua barra (Plasma, a barra superior sempre escura do GNOME) ou o logotipo vinil colorido" + +msgid "IMMERSIVE" +msgstr "IMERSIVO" + +msgid "Background effect" +msgstr "Efeito de fundo" + +msgid "Controls how the blurred artwork background is rendered in immersive mode." +msgstr "Controla como o fundo desfocado da capa é renderizado no modo imersivo." + +msgid "Immersive Panels" +msgstr "Painéis imersivos" + +msgid "Enable or disable individual immersive view panels" +msgstr "Ative ou desative painéis individuais da visualização imersiva" + +msgid "Disabled panels are hidden from the visualizer menu so you don't land on them by mistake." +msgstr "Painéis desativados são ocultados do menu do visualizador para que você não acabe neles por engano." + +msgid "Animation Frame Rate" +msgstr "Taxa de Quadros da Animação" + +msgid "Per-animation FPS control for immersive mode" +msgstr "Controle de FPS por animação no modo imersivo" + +msgid "Configure frame rate per animation. Lower values save CPU. Requires re-entering immersive mode to apply." +msgstr "Configure a taxa de quadros por animação. Valores mais baixos economizam CPU. Requer entrar novamente no modo imersivo para aplicar." + +msgid "PLAYLISTS" +msgstr "PLAYLISTS" + +msgid "No playlists yet." +msgstr "Ainda não há playlists." + +msgid "Manage playlists" +msgstr "Gerenciar playlists" + +msgid "Remove from queue" +msgstr "Remover da fila" + +msgid "Stop after this" +msgstr "Parar depois desta" + +msgid "Remove all after" +msgstr "Remover tudo depois" + +msgid "History" +msgstr "Histórico" + +msgid "NOW PLAYING" +msgstr "A REPRODUZIR AGORA" + +msgid "UP NEXT ({} of {}, {} left)" +msgstr "A SEGUIR ({} de {}, {} restante(s))" + +msgid "Page {} of {}" +msgstr "Página {} de {}" + +msgid "Your queue is empty" +msgstr "A sua fila está vazia" + +msgid "Play an album or track to get started" +msgstr "Reproduza um álbum ou faixa para começar" + +msgid "No tracks match your search" +msgstr "Nenhuma faixa corresponde à sua pesquisa" + +msgid "RECENTLY PLAYED" +msgstr "REPRODUZIDO RECENTEMENTE" + +msgid "Nothing played yet" +msgstr "Ainda nada reproduzido" + +msgid "Search queue" +msgstr "Pesquisar na fila" + +msgid "Auto-follow" +msgstr "Rolagem automática" + +msgid "Font" +msgstr "Fonte" + +msgid "System" +msgstr "Sistema" + +msgid "LINE Seed JP" +msgstr "LINE Seed JP" + +msgid "Montserrat" +msgstr "Montserrat" + +msgid "Noto Sans" +msgstr "Noto Sans" + +msgid "Source Sans 3" +msgstr "Source Sans 3" + +msgid "Size" +msgstr "Tamanho" + +msgid "S" +msgstr "S" + +msgid "M" +msgstr "M" + +msgid "L" +msgstr "L" + +msgid "XL" +msgstr "XL" + +msgid "Active color" +msgstr "Cor ativa" + +msgid "Uppercase" +msgstr "Maiúsculas" + +msgid "Dimming" +msgstr "Atenuação" + +msgid "Soft" +msgstr "Suave" + +msgid "Strong" +msgstr "Forte" + +msgid "Copy lyrics" +msgstr "Copiar letra" + +msgid "Reset" +msgstr "Redefinir" + +msgid "Previous" +msgstr "Anterior" + +msgid "Pause" +msgstr "Pausar" + +msgid "Repeat" +msgstr "Repetir" + +msgid "Add to…" +msgstr "Adicionar a…" + +msgid "Add album to collection" +msgstr "Adicionar álbum à coleção" + +msgid "Remove album from collection" +msgstr "Remover álbum da coleção" + +msgid "Remove from library" +msgstr "Remover da biblioteca" + +msgid "{} (not playable)" +msgstr "{} (não reproduzível)" + +msgid "Cast" +msgstr "Transmitir" + +msgid "Connected to" +msgstr "Ligado a" + +msgid "Streaming {} · {}" +msgstr "A transmitir {} · {}" + +msgid "No devices found" +msgstr "Nenhum dispositivo encontrado" + +msgid "Looking for Chromecast & DLNA on your network" +msgstr "A procurar Chromecast e DLNA na sua rede" + +msgid "Display settings" +msgstr "Opções de exibição" + +msgid "Fetching lyrics..." +msgstr "Buscando letras..." + +msgid "No lyrics available" +msgstr "Letras não disponíveis" + +msgid "Missing lyrics? You can contribute them to LRCLIB:" +msgstr "Letras em falta? Pode contribuir com elas no LRCLIB:" + +msgid "Lyrics unavailable offline" +msgstr "Letras indisponíveis offline" + +msgid "Lyrics source" +msgstr "Fonte das letras" + +msgid "Qobuz Connect" +msgstr "Qobuz Connect" + +msgid "Connected" +msgstr "Conectado" + +msgid "Disconnected" +msgstr "Desconectado" + +msgid "Playing on: {}" +msgstr "A reproduzir em: {}" + +msgid "Looking for devices…" +msgstr "A procurar dispositivos…" + +msgid "Make sure your renderer is on the same network" +msgstr "Certifique-se de que o seu renderer está na mesma rede" + +msgid "Play here" +msgstr "Reproduzir aqui" + +msgid "Audio settings" +msgstr "Definições de áudio" + +msgid "Normalization" +msgstr "Normalização" + +msgid "Level loudness across tracks" +msgstr "Nivelar o volume entre as faixas" + +msgid "Gapless" +msgstr "Sem intervalos" + +msgid "Seamless track transitions" +msgstr "Transições suaves entre faixas" + +msgid "Now Playing view" +msgstr "Vista A reproduzir agora" + +msgid "New" +msgstr "Nova" + +msgid "Classic" +msgstr "Clássica" + +msgid "Miniplayer" +msgstr "Mini-leitor" + +msgid "Immersive" +msgstr "Imersivo" + +msgid "Unmute" +msgstr "Ativar som" + +msgid "Mute" +msgstr "Silenciar" + +msgid "Volume" +msgstr "Volume" + +msgid "Qobuz Connect: On" +msgstr "Qobuz Connect: Ativado" + +msgid "Qobuz Connect: Off" +msgstr "Qobuz Connect: Desativado" + +msgid "Volume down" +msgstr "Diminuir o volume" + +msgid "Volume up" +msgstr "Aumentar o volume" + +msgid "Nothing playing" +msgstr "Nada em reprodução" + +msgid "Go to where this is playing from" +msgstr "Ir para a origem da reprodução" + +msgid "Casting" +msgstr "A transmitir" + +msgid "Output mode" +msgstr "Modo de saída" + +msgid "Move to root" +msgstr "Mover para raiz" + +msgid "No folders yet." +msgstr "Ainda não há pastas." + +msgid "Discover" +msgstr "Descobrir" + +msgid "Mixtapes" +msgstr "Mixtapes" + +msgid "Collections" +msgstr "Collections" + +msgid "Search playlists" +msgstr "Buscar playlists" + +msgid "Sort by" +msgstr "Ordenar por" + +msgid "Name (A-Z)" +msgstr "Nome (A-Z)" + +msgid "# of tracks" +msgstr "N.º de faixas" + +msgid "Refresh" +msgstr "Atualizar" + +msgid "Logged out" +msgstr "Sessão terminada" + +msgid "Offline (manual)" +msgstr "Offline (manual)" + +msgid "Offline (hard)" +msgstr "Offline (forçado)" + +msgid "You're signed out. Sign-in needs a connection." +msgstr "A sua sessão foi terminada. O início de sessão requer ligação." + +msgid "Connection available — sign back in to Qobuz." +msgstr "Ligação disponível — inicie sessão novamente no Qobuz." + +msgid "You're signed out — sign back in to Qobuz." +msgstr "A sua sessão foi terminada — inicie sessão novamente no Qobuz." + +msgid "No internet connection — your local library and downloads keep working." +msgstr "Sem ligação à Internet — a sua biblioteca local e as transferências continuam a funcionar." + +msgid "Sign in" +msgstr "Iniciar sessão" + +msgid "Offline mode" +msgstr "Modo offline" + +msgid "Enter" +msgstr "Enter" + +msgid "Guest" +msgstr "Convidado" + +msgid "Not signed in" +msgstr "Sessão não iniciada" + +msgid "Keyboard Shortcuts" +msgstr "Atalhos de Teclado" + +msgid "Documentation" +msgstr "Documentação" + +msgid "Report an Issue" +msgstr "Comunicar um problema" + +msgid "Log Out" +msgstr "Terminar sessão" + +msgid "Create Mixtape" +msgstr "Criar Mixtape" + +msgid "Create Collection" +msgstr "Criar Collection" + +msgid "NAME" +msgstr "NOME" + +msgid "KIND" +msgstr "TIPO" + +msgid "+ New Mixtape" +msgstr "+ Novo Mixtape" + +msgid "+ New Collection" +msgstr "+ Nova Collection" + +msgid "Rename" +msgstr "Renomear" + +msgid "Edit description" +msgstr "Editar descrição" + +msgid "Delete \"{}\"? This cannot be undone." +msgstr "Eliminar \"{}\"? Esta ação não pode ser desfeita." + +msgid "All ({})" +msgstr "Todos ({})" + +msgid "Random queue" +msgstr "Fila aleatória" + +msgid "Create a random queue with songs from this collection. How many songs do you want?" +msgstr "Cria uma fila aleatória com músicas desta coleção. Quantas músicas queres?" + +msgid "Number of songs" +msgstr "Número de músicas" + +msgid "{} songs available" +msgstr "{} músicas disponíveis" + +msgid "No mixtapes yet" +msgstr "Ainda sem mixtapes" + +msgid "Position" +msgstr "Posição" + +msgid "Items" +msgstr "Itens" + +msgid "Recently updated" +msgstr "Atualizados recentemente" + +msgid "No results found" +msgstr "Nenhum resultado encontrado" + +msgid "No collections yet" +msgstr "Ainda sem collections" + +msgid "All kinds" +msgstr "Todos os tipos" + +msgid "Artist Collections" +msgstr "Artist Collections" + +msgid "Remove from collection" +msgstr "Remover da coleção" + +msgid "Not found" +msgstr "Não encontrado" + +msgid "Upload cover" +msgstr "Enviar capa" + +msgid "Clear custom cover" +msgstr "Remover capa personalizada" + +msgid "Album shuffle" +msgstr "Aleatório por álbum" + +msgid "In order" +msgstr "Em ordem" + +msgid "Convert to Collection" +msgstr "Converter em Collection" + +msgid "Convert to Mixtape" +msgstr "Converter em Mixtape" + +msgid "No items yet. Add albums, tracks, or playlists from their detail pages." +msgstr "Ainda não há itens. Adicione álbuns, faixas ou listas de reprodução a partir das respetivas páginas de detalhes." + +msgid "1 filter" +msgstr "1 filtro" + +msgid "{} filters" +msgstr "{} filtros" + +msgid "Type" +msgstr "Tipo" + +msgid "All types" +msgstr "Todos" + +msgid "Track" +msgstr "Faixa" + +msgid "Qobuz" +msgstr "Qobuz" + +msgid "Plex" +msgstr "Plex" + +msgid "Item" +msgstr "Item" + +msgid "Already added" +msgstr "Já adicionado" + +msgid "ADD TO MIXTAPE/COLLECTION" +msgstr "ADICIONAR À MIXTAPE/COLEÇÃO" + +msgid "No matches." +msgstr "Sem correspondências." + +msgid "No mixtapes or collections yet." +msgstr "Ainda não há mixtapes nem coleções." + +msgid "EP" +msgstr "EP" + +msgid "Single" +msgstr "Single" + +msgid "Live" +msgstr "Ao vivo" + +msgid "Compilation" +msgstr "Coletânea" + +msgid "Use auto-detected" +msgstr "Usar detecção automática" + +msgid "alternate" +msgstr "alternativa" + +msgid "compilation" +msgstr "coletânea" + +msgid "BUILD ARTIST COLLECTION" +msgstr "CRIAR COLEÇÃO DE ARTISTA" + +msgid "ORDER BY" +msgstr "ORDENAR POR" + +msgid "Release date" +msgstr "Data de lançamento" + +msgid "Manual" +msgstr "Manual" + +msgid "Loading..." +msgstr "Carregando..." + +msgid "ALBUM" +msgstr "ÁLBUM" + +msgid "{} of {} album selected" +msgid_plural "{} of {} albums selected" +msgstr[0] "{} de {} álbum selecionado" +msgstr[1] "{} de {} álbuns selecionados" + +msgid "Now Playing" +msgstr "Tocando Agora" + +msgid "No lyrics" +msgstr "Sem letra" + +msgid "No track selected" +msgstr "Nenhuma faixa selecionada" + +msgid "Loading suggestions..." +msgstr "A carregar sugestões..." + +msgid "Failed to load suggestions" +msgstr "Falha ao carregar as sugestões" + +msgid "Recommended tracks" +msgstr "Faixas recomendadas" + +msgid "Exit Immersive" +msgstr "Sair do Imersivo" + +msgid "FOCUS" +msgstr "FOCO" + +msgid "Plasma" +msgstr "Plasma" + +msgid "Aurora" +msgstr "Aurora" + +msgid "SPLIT" +msgstr "DIVIDIR" + +msgid "Track Info" +msgstr "Informações da Faixa" + +msgid "Suggestions" +msgstr "Sugestões" + +msgid "UP NEXT" +msgstr "A SEGUIR" + +msgid "Tracks you play will appear here" +msgstr "As faixas que reproduzir aparecerão aqui" + +msgid "No track playing" +msgstr "Nenhuma faixa em reprodução" + +msgid "Queue is empty" +msgstr "A fila está vazia" + +msgid "Search albums & tracks…" +msgstr "Buscar álbuns e faixas…" + +msgid "Acclaimed Releases" +msgstr "Lançamentos aclamados" + +msgid "No award-winning releases yet." +msgstr "Ainda não há lançamentos premiados." + +msgid "Award" +msgstr "Prêmio" + +msgid "Unfollow award" +msgstr "Deixar de seguir" + +msgid "Follow award" +msgstr "Seguir prêmio" + +msgid "See All" +msgstr "Ver Tudo" + +msgid "Other awards" +msgstr "Mais prêmios" + +msgid "{} item needs attention before bit-perfect playback will work." +msgid_plural "{} items need attention before bit-perfect playback will work." +msgstr[0] "{} item requer atenção antes de a reprodução bit-perfect funcionar." +msgstr[1] "{} itens requerem atenção antes de a reprodução bit-perfect funcionar." + +msgid "Removed {} artist from blacklist" +msgid_plural "Removed {} artists from blacklist" +msgstr[0] "{} artista removido da lista negra" +msgstr[1] "{} artistas removidos da lista negra" + +msgid "Removed {} folder" +msgid_plural "Removed {} folders" +msgstr[0] "{} pasta removida" +msgstr[1] "{} pastas removidas" + +msgid "Scan complete ({} file skipped)" +msgid_plural "Scan complete ({} files skipped)" +msgstr[0] "Análise concluída ({} ficheiro ignorado)" +msgstr[1] "Análise concluída ({} ficheiros ignorados)" + +msgid "{} album" +msgid_plural "{} albums" +msgstr[0] "{} álbum" +msgstr[1] "{} álbuns" + +msgid "{} item" +msgid_plural "{} items" +msgstr[0] "{} item" +msgstr[1] "{} itens" + +msgid "{} duplicate skipped" +msgid_plural "{} duplicates skipped" +msgstr[0] "{} duplicado ignorado" +msgstr[1] "{} duplicados ignorados" + +msgid "Removed {} item" +msgid_plural "Removed {} items" +msgstr[0] "{} item removido" +msgstr[1] "{} itens removidos" + +msgid "Caching {} track offline…" +msgid_plural "Caching {} tracks offline…" +msgstr[0] "A guardar {} faixa offline…" +msgstr[1] "A guardar {} faixas offline…" + +msgid "(Re)start the PipeWire audio services" +msgstr "(Re)iniciar os serviços de áudio do PipeWire" + +msgid "({} local)" +msgstr "({} local)" + +msgid "176.4 kHz" +msgstr "176.4 kHz" + +msgid "192 kHz" +msgstr "192 kHz" + +msgid "352.8 kHz" +msgstr "352.8 kHz" + +msgid "384 kHz" +msgstr "384 kHz" + +msgid "44.1 kHz" +msgstr "44.1 kHz" + +msgid "48 kHz" +msgstr "48 kHz" + +msgid "88.2 kHz" +msgstr "88.2 kHz" + +msgid "96 kHz" +msgstr "96 kHz" + +msgid "A fresh mix every week." +msgstr "Uma mistura nova todas as semanas." + +msgid "Add a folder before scanning" +msgstr "Adicione uma pasta antes de analisar" + +msgid "Added to queue" +msgstr "Adicionado à fila" + +msgid "Added {} to {}" +msgstr "{} adicionado a {}" + +msgid "Added {} to {} ({})" +msgstr "{} adicionado a {} ({})" + +msgid "Adding tracks to playlist..." +msgstr "Adicionando faixas à playlist..." + +msgid "Adding tracks: {} / {}" +msgstr "A adicionar faixas: {} / {}" + +msgid "Album folder not found on disk" +msgstr "Pasta do álbum não encontrada no disco" + +msgid "Album metadata saved" +msgstr "Metadados do álbum salvos" + +msgid "Album title is required" +msgstr "O título do álbum é obrigatório" + +msgid "Albums of the Week" +msgstr "Álbuns da Semana" + +msgid "Albums on Local Library" +msgstr "Álbuns na biblioteca local" + +msgid "Already in {}" +msgstr "Já está em {}" + +msgid "Always skip track" +msgstr "Sempre pular faixa" + +msgid "Always try lowest quality" +msgstr "Sempre tentar qualidade mais baixa" + +msgid "April" +msgstr "Abril" + +msgid "Are you absolutely sure?" +msgstr "Tem a certeza absoluta?" + +msgid "Artist Spotlight" +msgstr "Artista em destaque" + +msgid "Artists on Local Library" +msgstr "Artistas na biblioteca local" + +msgid "Ask me" +msgstr "Perguntar-me" + +msgid "August" +msgstr "Agosto" + +msgid "Authorize QBZ in your browser, then click \"Finish\"" +msgstr "Autorize o QBZ no seu navegador e depois clique em \"Concluir\"" + +msgid "Available tracks only — offline" +msgstr "Apenas faixas disponíveis — offline" + +msgid "Bit-perfect (Hardware / Digital)" +msgstr "Bit-perfect (Hardware / Digital)" + +msgid "Blacklist disabled" +msgstr "Lista negra desativada" + +msgid "Blacklist enabled" +msgstr "Lista negra ativada" + +msgid "Booklet open task failed: {}" +msgstr "Falha ao abrir o livreto: {}" + +msgid "COLLECTION" +msgstr "COLLECTION" + +msgid "Cache cleared" +msgstr "Cache limpo" + +msgid "Cached for offline" +msgstr "Guardado para offline" + +msgid "Cannot convert this kind" +msgstr "Não é possível converter este tipo" + +msgid "Checked {} tracks - all OK" +msgstr "{} faixas verificadas - tudo OK" + +msgid "Checking playlist link..." +msgstr "A verificar a ligação da lista de reprodução..." + +msgid "Checking your audio stack…" +msgstr "A verificar a sua pilha de áudio…" + +msgid "Couldn't load the test tracks (offline?)" +msgstr "Não foi possível carregar as faixas de teste (offline?)" + +msgid "Choose a cover image" +msgstr "Escolha uma imagem de capa" + +msgid "Choose a folder to play" +msgstr "Selecione uma pasta para reproduzir" + +msgid "Choose a {} icon" +msgstr "Escolha um ícone de {}" + +msgid "Cleaning up missing files..." +msgstr "A limpar ficheiros em falta..." + +msgid "Cleanup failed" +msgstr "Falha na limpeza" + +msgid "Clear Plex cache?" +msgstr "Limpar a cache do Plex?" + +msgid "Clear library database?" +msgstr "Limpar a base de dados da biblioteca?" + +msgid "Code copied" +msgstr "Código copiado" + +msgid "Compilations" +msgstr "Compilações" + +msgid "Connected as {}" +msgstr "Ligado como {}" + +msgid "Connected to {} (v{})" +msgstr "Ligado a {} (v{})" + +msgid "Continue Listening" +msgstr "Continuar a ouvir" + +msgid "Converted" +msgstr "Convertido" + +msgid "Couldn't add folder" +msgstr "Não foi possível adicionar a pasta" + +msgid "Couldn't change folder location (path may already exist)" +msgstr "Não foi possível alterar a localização da pasta (o caminho pode já existir)" + +msgid "Couldn't clean up missing files" +msgstr "Não foi possível limpar os ficheiros em falta" + +msgid "Couldn't clear the Plex cache" +msgstr "Não foi possível limpar a cache do Plex" + +msgid "Couldn't clear the library database" +msgstr "Não foi possível limpar a base de dados da biblioteca" + +msgid "Couldn't clear the offline cache" +msgstr "Não foi possível limpar a cache offline" + +msgid "Couldn't create the Qobuz playlist" +msgstr "Não foi possível criar a playlist do Qobuz" + +msgid "Couldn't load library folders" +msgstr "Não foi possível carregar as pastas da biblioteca" + +msgid "Couldn't load that album" +msgstr "Não foi possível carregar esse álbum" + +msgid "Couldn't load that playlist" +msgstr "Não foi possível carregar essa playlist" + +msgid "Couldn't load that track" +msgstr "Não foi possível carregar essa faixa" + +msgid "Couldn't load this album" +msgstr "Não foi possível carregar este álbum" + +msgid "Couldn't load this artist" +msgstr "Não foi possível carregar este artista" + +msgid "Couldn't load this collection" +msgstr "Não foi possível carregar esta coleção" + +msgid "Couldn't load this playlist" +msgstr "Não foi possível carregar esta playlist" + +msgid "Couldn't open that folder" +msgstr "Não foi possível abrir essa pasta" + +msgid "Couldn't save folder settings" +msgstr "Não foi possível guardar as definições da pasta" + +msgid "Couldn't save metadata: {}" +msgstr "Não foi possível guardar os metadados: {}" + +msgid "Cover removed" +msgstr "Capa removida" + +msgid "Cover updated" +msgstr "Capa atualizada" + +msgid "Creating playlist..." +msgstr "Criando playlist..." + +msgid "Critics' Picks" +msgstr "Escolhas da Crítica" + +msgid "DAC: {} · {} · {} ch" +msgstr "DAC: {} · {} · {} can." + +msgid "December" +msgstr "Dezembro" + +msgid "Defaults" +msgstr "Predefinições" + +msgid "Disconnect from Plex?" +msgstr "Desligar do Plex?" + +msgid "EPs & Singles" +msgstr "EPs & Singles" + +msgid "Enable PipeWire in your NixOS configuration" +msgstr "Ativar o PipeWire na sua configuração do NixOS" + +msgid "Enter a title or artist to search" +msgstr "Introduza um título ou artista para pesquisar" + +msgid "Enter code {} at the Plex sign-in page" +msgstr "Introduza o código {} na página de início de sessão do Plex" + +msgid "Error: {}" +msgstr "Erro: {}" + +msgid "Error: {} (did you authorize in the browser?)" +msgstr "Erro: {} (autorizou no navegador?)" + +msgid "Essentials by Genre" +msgstr "Essenciais por género" + +msgid "Every track needs a title" +msgstr "Cada faixa precisa de um título" + +msgid "Failed to cast track" +msgstr "Falha ao transmitir a faixa" + +msgid "Failed to change play mode" +msgstr "Falha ao alterar o modo de reprodução" + +msgid "Failed to clear blacklist" +msgstr "Falha ao limpar a lista negra" + +msgid "Failed to clear lyrics cache" +msgstr "Falha ao limpar a cache de letras" + +msgid "Failed to create temp dir: {}" +msgstr "Falha ao criar o diretório temporário: {}" + +msgid "Failed to decode booklet page: {}" +msgstr "Falha ao descodificar a página do livreto: {}" + +msgid "Failed to delete" +msgstr "Falha ao eliminar" + +msgid "Failed to fetch booklet: {}" +msgstr "Falha ao obter o livreto: {}" + +msgid "Failed to fetch metadata" +msgstr "Falha ao obter os metadados" + +msgid "Failed to read booklet: {}" +msgstr "Falha ao ler o livreto: {}" + +msgid "Failed to remove artist" +msgstr "Falha ao remover o artista" + +msgid "Failed to remove cover" +msgstr "Falha ao remover a capa" + +msgid "Failed to remove items" +msgstr "Falha ao remover os itens" + +msgid "Failed to rename" +msgstr "Falha ao renomear" + +msgid "Failed to save description" +msgstr "Falha ao guardar a descrição" + +msgid "Failed to toggle blacklist" +msgstr "Falha ao alternar a lista negra" + +msgid "Failed to upload cover" +msgstr "Falha ao enviar a capa" + +msgid "Failed to write booklet: {}" +msgstr "Falha ao escrever o livreto: {}" + +msgid "Library Albums" +msgstr "Álbuns da sua biblioteca" + +msgid "February" +msgstr "Fevereiro" + +msgid "File not available — is the drive mounted?" +msgstr "Ficheiro indisponível — a unidade está montada?" + +msgid "Files not available — is the drive mounted?" +msgstr "Ficheiros indisponíveis — a unidade está montada?" + +msgid "Folder opened" +msgstr "Pasta aberta" + +msgid "Found {} tracks from {}." +msgstr "Encontradas {} faixas de {}." + +msgid "From the same artist" +msgstr "Do mesmo artista" + +msgid "From your most-played playlists." +msgstr "Das suas playlists mais reproduzidas." + +msgid "Ideal Discography" +msgstr "Discografia Ideal" + +msgid "Image" +msgstr "Imagem" + +msgid "Import failed: {}" +msgstr "Falha na importação: {}" + +msgid "Imported {} of {} tracks into QBZ." +msgstr "Importadas {} de {} faixas para o QBZ." + +msgid "Install the ALSA bridge so playback can reach PipeWire" +msgstr "Instale a ponte ALSA para que a reprodução chegue ao PipeWire" + +msgid "Install the PipeWire audio stack" +msgstr "Instale a pilha de áudio PipeWire" + +msgid "Install the PipeWire tools (pw-dump)" +msgstr "Instale as ferramentas do PipeWire (pw-dump)" + +msgid "Install the Pulse compatibility tools (optional fallback)" +msgstr "Instale as ferramentas de compatibilidade do Pulse (alternativa opcional)" + +msgid "January" +msgstr "Janeiro" + +msgid "July" +msgstr "Julho" + +msgid "June" +msgstr "Junho" + +msgid "Last.fm disconnected" +msgstr "Last.fm desligado" + +msgid "Last.fm sign-in failed to start" +msgstr "Não foi possível iniciar a sessão no Last.fm" + +msgid "Library database cleared" +msgstr "Base de dados da biblioteca limpa" + +msgid "ListenBrainz disconnected" +msgstr "ListenBrainz desligado" + +msgid "Listening suggestions" +msgstr "Sugestões de audição" + +msgid "Loaded {} cached tracks" +msgstr "{} faixas em cache carregadas" + +msgid "Log in to cache tracks offline" +msgstr "Inicie sessão para guardar faixas offline" + +msgid "Lossless: {}" +msgstr "LOSSLESS: {}" + +msgid "Lyrics cache cleared" +msgstr "Cache de letras limpa" + +msgid "Lyrics copied" +msgstr "Letra copiada" + +msgid "MIXTAPE" +msgstr "MIXTAPE" + +msgid "March" +msgstr "Março" + +msgid "Matching tracks: {} / {} ({} found)" +msgstr "Faixas correspondentes: {} / {} ({} encontradas)" + +msgid "May" +msgstr "Maio" + +msgid "Middle-click to pause" +msgstr "Clique no botão do meio para pausar" + +msgid "Middle-click to play" +msgstr "Clique no botão do meio para reproduzir" + +msgid "Mixed queue — not cast to Qobuz Connect" +msgstr "Fila mista — não transmitida para o Qobuz Connect" + +msgid "More From Your Library" +msgstr "Mais da sua Biblioteca" + +msgid "More Labels" +msgstr "Mais editoras" + +msgid "Most Streamed" +msgstr "Mais reproduzidas" + +msgid "" +"Music Player\n" +"Nothing playing" +msgstr "" +"Leitor de música\n" +"Nada em reprodução" + +msgid "Network folder detected" +msgstr "Pasta de rede detetada" + +msgid "Never" +msgstr "Nunca" + +msgid "New Releases" +msgstr "Novidades" + +msgid "Next Track" +msgstr "Próxima Faixa" + +msgid "No limit" +msgstr "Sem limite" + +msgid "No matching tracks found." +msgstr "Nenhuma faixa correspondente encontrada." + +msgid "No sinks detected — reinstall the ALSA UCM profiles, then reboot" +msgstr "Nenhuma saída detetada — reinstale os perfis UCM do ALSA e reinicie" + +msgid "No top tracks available for this artist" +msgstr "Não há faixas em destaque disponíveis para este artista" + +msgid "No tracks available offline" +msgstr "Nenhuma faixa disponível offline" + +msgid "Not available offline" +msgstr "Indisponível offline" + +msgid "Nothing playable in this playlist right now" +msgstr "Nada reproduzível nesta playlist neste momento" + +msgid "November" +msgstr "Novembro" + +msgid "October" +msgstr "Outubro" + +msgid "Offline cache cleared" +msgstr "Cache offline limpo" + +msgid "Offline cache is full — free space or raise the limit" +msgstr "O cache offline está cheio — liberte espaço ou aumente o limite" + +msgid "Offline caching failed" +msgstr "Falha ao guardar em cache offline" + +msgid "Offline listening period expired — reconnect to verify your subscription" +msgstr "O período de audição offline expirou — reconecte-se para verificar a sua subscrição" + +msgid "On Local Library" +msgstr "Na biblioteca local" + +msgid "Opened folder ({} files skipped)" +msgstr "Pasta aberta ({} ficheiros ignorados)" + +msgid "Other Outputs" +msgstr "Outras saídas" + +msgid "PLAYLIST" +msgstr "PLAYLIST" + +msgid "Paste your ListenBrainz user token first" +msgstr "Cole primeiro o seu token de utilizador do ListenBrainz" + +msgid "Play/Pause" +msgstr "Reproduzir/Pausar" + +msgid "Playing next" +msgstr "A seguir" + +msgid "Playing {} of {}" +msgstr "A reproduzir {} de {}" + +msgid "Playlist ({} local)" +msgstr "Playlist ({} local)" + +msgid "Playlist created in Qobuz™." +msgstr "Playlist criada no Qobuz™." + +msgid "Playlist uploaded to Qobuz" +msgstr "Playlist enviada para o Qobuz" + +msgid "Playlist: {}" +msgstr "Playlist: {}" + +msgid "Plex link failed to start" +msgstr "Não foi possível iniciar a ligação ao Plex" + +msgid "Plugin Hardware" +msgstr "Hardware com plugin" + +msgid "Press Accolades" +msgstr "Elogios da Crítica" + +msgid "Previous Track" +msgstr "Faixa Anterior" + +msgid "Purchase Only" +msgstr "Apenas compra" + +msgid "QBZ requesting {} · {}-bit" +msgstr "QBZ a solicitar {} · {}-bit" + +msgid "Qobuz Connect is unavailable while offline" +msgstr "O Qobuz Connect está indisponível offline" + +msgid "Qobuz Connect renderer disconnected" +msgstr "Recetor do Qobuz Connect desligado" + +msgid "Qobuz Connect renderer unreachable" +msgstr "Recetor do Qobuz Connect inacessível" + +msgid "Qobuzissimes" +msgstr "Qobuzissimes" + +msgid "Quit QBZ" +msgstr "Sair do QBZ" + +msgid "Rate limited, try again shortly" +msgstr "Limite de pedidos atingido, tente novamente em breve" + +msgid "Recently Played" +msgstr "Tocadas Recentemente" + +msgid "Rediscover Your Library" +msgstr "Redescubra a sua biblioteca" + +msgid "Release Watch" +msgstr "Radar de Novidades" + +msgid "Removed \"{}\" ({})" +msgstr "«{}» removido ({})" + +msgid "Removed album from offline" +msgstr "Álbum removido do modo offline" + +msgid "Removed from offline" +msgstr "Removido do modo offline" + +msgid "Removed {} of {} tracks" +msgstr "Removidas {} de {} faixas" + +msgid "Scan cancelled" +msgstr "Escaneamento cancelado" + +msgid "Scan complete" +msgstr "Escaneamento concluído" + +msgid "Scan failed" +msgstr "Falha na verificação" + +msgid "Scanning track paths..." +msgstr "A verificar caminhos das faixas..." + +msgid "Scroll to adjust volume" +msgstr "Desloca para ajustar o volume" + +msgid "Search failed: {}" +msgstr "Falha na pesquisa: {}" + +msgid "Searching Qobuz catalog..." +msgstr "Pesquisando catálogo do Qobuz..." + +msgid "Select music folder" +msgstr "Selecionar pasta de música" + +msgid "September" +msgstr "Setembro" + +msgid "Show/Hide Window" +msgstr "Mostrar/Ocultar janela" + +msgid "Similar to {}" +msgstr "Semelhante a {}" + +msgid "Skipped: {}" +msgstr "Ignorado: {}" + +msgid "Some tracks can't be cast to Qobuz Connect" +msgstr "Algumas faixas não podem ser enviadas para o Qobuz Connect" + +msgid "Song Radio" +msgstr "Rádio da Música" + +msgid "Split into {} playlists (Qobuz 2000-track limit)" +msgstr "Dividido em {} listas de reprodução (limite de 2000 faixas do Qobuz)" + +msgid "Start the sign-in first" +msgstr "Inicie primeiro o início de sessão" + +msgid "Starting…" +msgstr "A iniciar…" + +msgid "System default" +msgstr "Predefinição do sistema" + +msgid "TRACK" +msgstr "FAIXA" + +msgid "These items resolved to 0 playable tracks" +msgstr "Estes itens não resultaram em faixas reproduzíveis" + +msgid "This action cannot be undone." +msgstr "Esta ação não pode ser anulada." + +msgid "This album has no playable tracks" +msgstr "Este álbum não tem faixas reproduzíveis" + +msgid "This collection resolved to 0 playable tracks" +msgstr "Esta coleção não resultou em faixas reproduzíveis" + +msgid "This item resolved to 0 playable tracks" +msgstr "Este item não resultou em faixas reproduzíveis" + +msgid "This modifies your audio files on disk and cannot be undone." +msgstr "Isto modifica os seus ficheiros de áudio no disco e não pode ser anulado." + +msgid "This playlist has no playable tracks" +msgstr "Esta lista de reprodução não tem faixas reproduzíveis" + +msgid "This removes ALL indexed tracks from the database. Your audio files are NOT deleted. You will need to re-scan your folders afterward." +msgstr "Isto remove TODAS as faixas indexadas da base de dados. Os seus ficheiros de áudio NÃO são eliminados. Terá de verificar novamente as suas pastas depois." + +msgid "This removes cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Isto remove as bibliotecas e faixas do Plex em cache. O seu início de sessão é mantido." + +msgid "This signs out of Plex and clears the locally cached libraries and tracks." +msgstr "Isto termina a sessão no Plex e limpa as bibliotecas e faixas em cache local." + +msgid "This track is no longer available" +msgstr "Esta faixa já não está disponível" + +msgid "This track is not yet available" +msgstr "Esta faixa ainda não está disponível" + +msgid "Track count differs from the result; titles applied by position" +msgstr "O número de faixas difere do resultado; os títulos foram aplicados por posição" + +msgid "Track not available offline" +msgstr "Faixa não disponível offline" + +msgid "Track not castable to Qobuz Connect" +msgstr "Faixa não pode ser enviada para o Qobuz Connect" + +msgid "Track unavailable on Qobuz Connect" +msgstr "Faixa indisponível no Qobuz Connect" + +msgid "Tracks matched: {} / {}" +msgstr "Faixas correspondentes: {} / {}" + +msgid "Unavailable Plex track" +msgstr "Faixa do Plex indisponível" + +msgid "Unavailable track" +msgstr "Faixa indisponível" + +msgid "Unknown" +msgstr "Desconhecido" + +msgid "Unknown Artist" +msgstr "Artista desconhecido" + +msgid "Unknown source" +msgstr "Fonte desconhecida" + +msgid "Upcoming" +msgstr "Em breve" + +msgid "Upload incomplete — local playlist kept" +msgstr "Envio incompleto — playlist local mantida" + +msgid "Various Artists" +msgstr "Vários artistas" + +msgid "Waiting for the DAC to start playing…" +msgstr "Aguardando o DAC iniciar a reprodução…" + +msgid "Write tags to audio files?" +msgstr "Gravar etiquetas nos ficheiros de áudio?" + +msgid "Writing tags to files isn't supported for CUE albums; use sidecar mode" +msgstr "A gravação de etiquetas em ficheiros não é suportada para álbuns CUE; use o modo sidecar" + +msgid "Year must be a number" +msgstr "O ano deve ser um número" + +msgid "You're offline — try again when connected" +msgstr "Está offline — tente novamente quando estiver ligado" + +msgid "Your audio stack is ready for bit-perfect playback." +msgstr "A sua pilha de áudio está pronta para reprodução bit-perfect." + +msgid "Your queue is empty — add some tracks first, or press Play test." +msgstr "A sua fila está vazia — adicione algumas faixas primeiro ou prima Reproduzir teste." + +msgid "by {}" +msgstr "por {}" + +msgid "hw (Direct Hardware)" +msgstr "hw (Hardware direto)" + +msgid "pcm (Most compatible)" +msgstr "pcm (Mais compatível)" + +msgid "plughw (Auto-convert)" +msgstr "plughw (Conversão automática)" + +msgid "{} + {} more" +msgstr "{} + {} mais" + +msgid "{} albums · {} tracks" +msgstr "{} álbuns · {} faixas" + +msgid "{} h {} min" +msgstr "{} h {} min" + +msgid "{} libraries found" +msgstr "{} bibliotecas encontradas" + +msgid "{} min" +msgstr "{} min" + +msgid "{} removed from blacklist" +msgstr "{} removido da lista negra" + +msgid "{} tracks loaded" +msgstr "{} faixas carregadas" + +msgid "{} tracks · {}" +msgstr "{} faixas · {}" + +msgid "{} {}" +msgstr "{} {}" + +msgid "{} {}, {}" +msgstr "{} {}, {}" + +msgid "{} — Complete Discography" +msgstr "{} — Discografia completa" + +msgid "· Unlimited" +msgstr "· Ilimitado" + +msgid "· of {}" +msgstr "· de {}" + +# --- Purchases (Slice 13) --- +msgid "My Purchases" +msgstr "Minhas Compras" + +msgid "Purchases are downloaded as DRM-free files. Availability depends on your Qobuz region." +msgstr "As compras são baixadas como arquivos sem DRM. A disponibilidade depende da sua região do Qobuz." + +msgid "Search purchases…" +msgstr "Pesquisar compras…" + +msgid "No purchases yet" +msgstr "Nenhuma compra ainda" + +msgid "No purchased tracks yet" +msgstr "Nenhuma faixa comprada ainda" + +msgid "Filters" +msgstr "Filtros" + +msgid "Availability" +msgstr "Disponibilidade" + +msgid "Hide unavailable" +msgstr "Ocultar indisponíveis" + +msgid "CD Quality" +msgstr "Qualidade CD" + +msgid "MP3" +msgstr "MP3" + +msgid "Downloads" +msgstr "Downloads" + +msgid "Hide downloaded" +msgstr "Ocultar baixados neste computador" + +msgid "Group: A–Z" +msgstr "Agrupar: A-Z" + +msgid "Sort: Date" +msgstr "Ordenar: Data" + +msgid "Sort: Artist" +msgstr "Ordenar: Artista" + +msgid "Sort: Album" +msgstr "Ordenar: Álbum" + +msgid "Sort: Quality" +msgstr "Ordenar: Qualidade" + +msgid "Date" +msgstr "Data" + +msgid "Download all" +msgstr "Baixar tudo" + +msgid "Download complete" +msgstr "Download concluído" + +msgid "Download cancelled ({}/{})" +msgstr "Download cancelado ({}/{})" + +msgid "Downloading {}/{}..." +msgstr "Baixando {}/{}..." + +msgid "Select format" +msgstr "Selecionar formato" + +msgid "Purchased on" +msgstr "Comprado em" + +msgid "Add these downloaded tracks to your local library." +msgstr "Adicionar estas faixas baixadas à sua biblioteca local." + +msgid "{} tracks" +msgstr "{} faixas" + +msgid "Choose folder" +msgstr "Escolher pasta" + +msgid "Couldn't add to library" +msgstr "Não foi possível adicionar à biblioteca" + +msgid "Added to library" +msgstr "Adicionado à biblioteca" + +msgid "Purchased from Qobuz" +msgstr "Compra do Qobuz" + +msgid "Couldn't load purchases. Check your connection." +msgstr "Não foi possível carregar as compras. Verifique sua conexão." + +msgid "This track has no album to download" +msgstr "Esta faixa não tem álbum para baixar" + +msgid "Failed to start download. Please try again." +msgstr "Falha ao iniciar o download. Tente novamente." + +msgid "No downloadable formats available" +msgstr "Nenhum formato disponível para download" + +msgid "Download goodies" +msgstr "Baixar extras" + +# Playlist "Suggested Songs" section (T8) +msgid "Suggested songs" +msgstr "Músicas sugeridas" + +msgid "Suggest songs for this playlist" +msgstr "Sugerir músicas para esta playlist" + +msgid "Finding similar artists..." +msgstr "Procurando artistas semelhantes..." + +msgid "Refresh suggestions" +msgstr "Atualizar sugestões" + +msgid "Not interested" +msgstr "Sem interesse" + +# Slint polish batch (radio / playlist follow-copy / MusicBrainz / reco) +msgid "QBZ Radio" +msgstr "Rádio QBZ" + +msgid "Qobuz Radio" +msgstr "Rádio Qobuz" + +msgid "Create QBZ radio" +msgstr "Criar rádio QBZ" + +msgid "Create Qobuz radio" +msgstr "Criar rádio Qobuz" + +msgid "Follow on Qobuz" +msgstr "Seguir no Qobuz" + +msgid "Following on Qobuz" +msgstr "Seguindo no Qobuz" + +msgid "Copy to your library" +msgstr "Copiar para a sua biblioteca" + +msgid "METADATA" +msgstr "METADADOS" + +msgid "MusicBrainz" +msgstr "MusicBrainz" + +msgid "Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections." +msgstr "Ative as relações entre artistas e os metadados aprimorados do MusicBrainz. Sem telemetria — ele apenas associa lançamentos para enriquecer as páginas de artistas e as sugestões de músicas das playlists. Desative para ocultar essas seções." + +msgid "No tracks to add" +msgstr "Nenhuma faixa para adicionar" + +msgid "Failed to add tracks" +msgstr "Falha ao adicionar as faixas" + +msgid "Copied to your library" +msgstr "Copiado para a sua biblioteca" + +msgid "Failed to copy playlist" +msgstr "Falha ao copiar a playlist" + +msgid "Playlist has no tracks to copy" +msgstr "A playlist não tem faixas para copiar" + +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{} faixa adicionada" +msgstr[1] "{} faixas adicionadas" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{} faixa adicionada a {}" +msgstr[1] "{} faixas adicionadas a {}" + +msgid "Added {} track to queue" +msgid_plural "Added {} tracks to queue" +msgstr[0] "{} faixa adicionada à fila" +msgstr[1] "{} faixas adicionadas à fila" + +msgid "Unfollow on Qobuz" +msgstr "Deixar de seguir no Qobuz" + +msgid "Stops in" +msgstr "Para em" + +msgid "Cancel Timer" +msgstr "Cancelar temporizador" + +msgid "Stop playback after:" +msgstr "Parar a reprodução após:" + +msgid "30 min" +msgstr "30 min" + +msgid "1 hr" +msgstr "1 h" + +msgid "2 hr" +msgstr "2 h" + +msgid "3 hr" +msgstr "3 h" + +msgid "5 hr" +msgstr "5 h" + +msgid "Custom…" +msgstr "Personalizado…" + +msgid "min" +msgstr "min" + +msgid "Set Timer" +msgstr "Definir temporizador" + +msgid "Cancel stop after this" +msgstr "Cancelar parar depois desta" + +msgid "LOGS" +msgstr "REGISTOS" + +msgid "Application logs" +msgstr "Registos da aplicação" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Veja o registo da aplicação, copie-o (segredos ocultados) ou carregue-o para partilhar num issue do GitHub." + +msgid "Open log viewer" +msgstr "Abrir o visualizador de registos" + +msgid "Setting" +msgstr "Definição" + +msgid "Saved" +msgstr "Guardado" + +msgid "Runtime" +msgstr "Em execução" + +msgid "Diagnostics" +msgstr "Diagnóstico" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Comparação entre a configuração guardada e a ativa." + +msgid "Exported" +msgstr "Exportado" + +msgid "Export to clipboard" +msgstr "Exportar para a área de transferência" + +msgid "Cast Discovery" +msgstr "Deteção de Cast" + +msgid "Scanning…" +msgstr "A procurar…" + +msgid "Scan for devices" +msgstr "Procurar dispositivos" + +msgid "Graphics" +msgstr "Gráficos" + +msgid "Environment" +msgstr "Ambiente" + +msgid "Logs" +msgstr "Registos" + +msgid "View logs" +msgstr "Ver registos" + +msgid "Upload failed." +msgstr "O carregamento falhou." + +msgid "All levels" +msgstr "Todos os níveis" + +msgid "Error" +msgstr "Erro" + +msgid "Warn" +msgstr "Aviso" + +msgid "Info" +msgstr "Info" + +msgid "Debug" +msgstr "Depuração" + +msgid "Trace" +msgstr "Rastreio" + +msgid "Search logs…" +msgstr "Procurar nos registos…" + +msgid "Auto-tail" +msgstr "Seguir automático" + +msgid "Time" +msgstr "Hora" + +msgid "Level" +msgstr "Nível" + +msgid "Target" +msgstr "Módulo" + +msgid "Message" +msgstr "Mensagem" + +msgid "No log entries." +msgstr "Sem registos." + +msgid "Copied" +msgstr "Copiado" + +msgid "Copy all" +msgstr "Copiar tudo" + +msgid "Copy diagnostics bundle" +msgstr "Copiar o pacote de diagnóstico" + +msgid "Uploading…" +msgstr "A enviar…" + +msgid "Upload (public)" +msgstr "Carregar (público)" + +msgid "Open log file" +msgstr "Abrir o ficheiro de registo" + +msgid "Report an issue" +msgstr "Reportar um problema" + +msgid "Go to logs" +msgstr "Ir para os logs" + +msgid "Create issue report" +msgstr "Criar relato" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Para facilitar a resolução de bugs e problemas, é recomendado compartilhar os logs da aplicação. Tivemos o cuidado de mascarar os logs enviados para que nenhum dado sensível seja incluído. O processo é manual, nunca automático, para que você possa verificar o que é compartilhado." + +msgid "Cache window" +msgstr "Janela de cache" + +msgid "24 hours" +msgstr "24 horas" + +msgid "36 hours" +msgstr "36 horas" + +msgid "48 hours" +msgstr "48 horas" + +msgid "72 hours" +msgstr "72 horas" + +msgid "Refresh now" +msgstr "Atualizar agora" + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback without browser sample rate limitations." +msgstr "Um cliente nativo de streaming de música para Linux, projetado para audiófilos que precisam de reprodução Hi-Fi perfeita, sem limitações de taxa de amostragem do navegador." + +msgid "Acknowledgments" +msgstr "Agradecimentos" + +msgid "Animated background renderer" +msgstr "Renderizador de fundos animados" + +msgid "Audio playback engine" +msgstr "Mecanismo de reprodução de áudio" + +msgid "Author" +msgstr "Autor" + +msgid "Build" +msgstr "Construir" + +msgid "Build Info" +msgstr "Informações de construção" + +msgid "Codename" +msgstr "Codinome" + +msgid "Contributors" +msgstr "Colaboradores" + +msgid "Hatred towards all those companies that discriminate against the Linux community and don't provide us with a decent client for their product." +msgstr "Ódio contra todas aquelas empresas que discriminam a comunidade Linux e não nos fornecem um cliente decente para seus produtos." + +msgid "Icon library" +msgstr "Biblioteca de ícones" + +msgid "License" +msgstr "Licença" + +msgid "Lyrics API" +msgstr "API de letras" + +msgid "Made with" +msgstr "Feito com" + +msgid "Music link aggregation API" +msgstr "API de agregação de links de música" + +msgid "Music metadata database" +msgstr "Banco de dados de metadados musicais" + +msgid "Music streaming service and API" +msgstr "Serviço de streaming de música e API" + +msgid "Platform" +msgstr "Plataforma" + +msgid "Release" +msgstr "Liberar" + +msgid "Release notes are not available." +msgstr "As notas de versão não estão disponíveis." + +msgid "Synced lyrics provider" +msgstr "Provedor de letras sincronizadas" + +msgid "UI framework" +msgstr "Estrutura de IU" + +msgid "Version" +msgstr "Versão" + +msgid "Website" +msgstr "Site" + +msgid "What's new" +msgstr "O que há de novo" + +msgid "What's new in v{}" +msgstr "O que há de novo em v{}" + +msgid "in" +msgstr "em" + +msgid "QBZ uses your Last.fm and ListenBrainz account data when they're connected (if they aren't, we recommend connecting them to enrich this section). Here you set how often that data is fetched — it defaults to 48 hours, or you can request an update right now." +msgstr "O QBZ usa os dados das suas contas do Last.fm e do ListenBrainz quando estão conectadas (se não estiverem, recomendamos conectá-las para enriquecer esta seção). Aqui você define com que frequência esses dados são obtidos — o padrão é 48 horas, ou você pode solicitar uma atualização agora mesmo." + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback." +msgstr "Um cliente nativo de streaming de música para Linux, projetado para audiófilos que precisam de reprodução Hi-Fi perfeita." + +msgid "Built with Rust and Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, and Lucide." +msgstr "Desenvolvido com Rust e Slint e com tecnologia Rodio e Symphonia. Com agradecimentos a Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh e Lucide." + +# Recommendations (Discover external-reco) + JACK not-bit-perfect note +msgid "RECOMMENDATIONS" +msgstr "RECOMENDAÇÕES" + +msgid "Show Recommendations in Discover" +msgstr "Mostrar Recomendações em Descobrir" + +msgid "A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases." +msgstr "Uma aba Descobrir personalizada com base no que você ouve. Para melhores resultados, conecte Last.fm e ListenBrainz abaixo — o MusicBrainz é usado automaticamente para combinar lançamentos." + +msgid "Recommendations" +msgstr "Recomendações" + +msgid "More like the artists you love" +msgstr "Mais dos artistas que você ama" + +msgid "Based on what you've been into lately" +msgstr "Com base no que você tem ouvido ultimamente" + +msgid "Weekly Exploration" +msgstr "Exploração semanal" + +msgid "Weekly Jams" +msgstr "Pérolas da semana" + +msgid "Popular artists on Qobuz" +msgstr "Artistas populares no Qobuz" + +msgid "No recommendations yet" +msgstr "Ainda não há recomendações" + +msgid "Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations." +msgstr "Conecte Last.fm ou ListenBrainz nas Configurações, ou ouça mais música, para obter recomendações personalizadas." + +msgid "JACK is not bit-perfect" +msgstr "JACK não é bit-perfect" + +msgid "The JACK backend routes audio through the JACK graph, which resamples to the graph's sample rate. For bit-perfect playback use ALSA (direct) or PipeWire with passthrough." +msgstr "O backend JACK roteia o áudio pelo grafo do JACK, que faz resample para a taxa do grafo. Para reprodução bit-perfect, use ALSA (direto) ou PipeWire com passthrough." + +msgid "Extra large" +msgstr "Extra grande" + +msgid "INTERFACE" +msgstr "INTERFACE" + +msgid "Interface size" +msgstr "Tamanho da interface" + +msgid "Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)" +msgstr "Dimensiona toda a interface, como o zoom do navegador. Pequeno mostra mais conteúdo na tela, Grande e Extra grande melhoram a legibilidade (requer reinicialização)" + +msgid "Interface size changed — restart QBZ to apply" +msgstr "Tamanho da interface alterado — reinicie o QBZ para aplicar" + +msgid "Extra small" +msgstr "Extra pequeno" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Entrar com o seu navegador" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Continue no seu navegador web — o QBZ está aguardando você concluir o login." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "O navegador pode abrir em segundo plano sem tomar o foco — verifique as suas outras janelas." + +msgid "Signing you in…" +msgstr "Entrando…" + +msgid "Sign-in failed" +msgstr "Falha ao entrar" + +msgid "About QBZ" +msgstr "Sobre o QBZ" + +msgid "Already used by" +msgstr "Já usado por" + +msgid "Auto (recommended)" +msgstr "Auto (recomendado)" + +msgid "Discrete GPU (higher performance)" +msgstr "GPU dedicada (mais desempenho)" + +msgid "Integrated GPU (lower power)" +msgstr "GPU integrada (menos consumo)" + +msgid "Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)" +msgstr "Auto escolhe o melhor renderizador para o seu hardware gráfico. Só mude se o app estiver lento ou renderizando incorretamente (requer reiniciar)" + +msgid "Customize Shortcuts" +msgstr "Personalizar atalhos" + +msgid "GPU" +msgstr "GPU" + +msgid "GPU (compatibility)" +msgstr "GPU (compatibilidade)" + +msgid "Go" +msgstr "Ir" + +msgid "Line Bed" +msgstr "Line Bed" + +msgid "Tunnel Flow" +msgstr "Tunnel Flow" + +msgid "Open Music Link" +msgstr "Abrir link de música" + +msgid "Open Playlist Importer" +msgstr "Abrir importador de playlists" + +msgid "Open Qobuz Link" +msgstr "Abrir link do Qobuz" + +msgid "Paste a Qobuz, Spotify, Apple Music, Tidal, Deezer or song.link URL" +msgstr "Cole uma URL do Qobuz, Spotify, Apple Music, Tidal, Deezer ou song.link" + +msgid "Place the Purchases entry in the custom title bar instead of the sidebar" +msgstr "Coloca a entrada Compras na barra de título personalizada em vez da barra lateral" + +msgid "Press keys…" +msgstr "Pressione as teclas…" + +msgid "Purchases" +msgstr "Compras" + +msgid "Purchases in title bar" +msgstr "Compras na barra de título" + +msgid "RENDERER" +msgstr "RENDERIZADOR" + +msgid "Rendering backend" +msgstr "Backend de renderização" + +msgid "Reset All to Defaults" +msgstr "Redefinir tudo" + +msgid "Resolving…" +msgstr "Resolvendo…" + +msgid "Software" +msgstr "Software" + +msgid "Spectral Ribbon" +msgstr "Spectral Ribbon" + +msgid "This looks like a playlist." +msgstr "Isto parece ser uma playlist." + +msgid "Wave Bed" +msgstr "Wave Bed" + +msgid "What's New" +msgstr "Novidades" + +msgid "modified" +msgstr "modificado" + +msgid "Block this album" +msgstr "Bloquear este álbum" + +msgid "Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed." +msgstr "Álbuns bloqueados ficam ocultos da busca, da descoberta e das listagens — mesmo quando o artista é permitido." + +msgid "No blocked albums" +msgstr "Nenhum álbum bloqueado" + +msgid "This will remove all {} blocked albums. This cannot be undone." +msgstr "Isto removerá todos os {} álbuns bloqueados. Isso não pode ser desfeito." + +msgid "To block an album, open its menu and choose Block this album." +msgstr "Para bloquear um álbum, abra o menu dele e escolha Bloquear este álbum." + +msgid "Unblock album" +msgstr "Desbloquear álbum" + +msgid "{} albums" +msgstr "{} álbuns" + +msgid "{} artists, {} albums blocked" +msgstr "{} artistas, {} álbuns bloqueados" + +msgid "{} artists, {} albums blocked (disabled)" +msgstr "{} artistas, {} álbuns bloqueados (desativado)" + +msgid "Keep your system's native window decorations. Turn off to use the QBZ header as the title bar, with its own window controls and drag support. Takes effect after restarting QBZ." +msgstr "Mantém as decorações de janela nativas do sistema. Desative para usar o cabeçalho do QBZ como barra de título, com seus próprios controles de janela e suporte a arrastar. Tem efeito após reiniciar o QBZ." + +msgid "Frameless window without window controls or header drag (for tiling window manager users)" +msgstr "Janela sem moldura, sem controles de janela nem arrastar pelo cabeçalho (para usuários de gerenciadores de janelas em mosaico)" + +msgid "Title bar mode changed — restart QBZ to apply" +msgstr "Modo da barra de título alterado — reinicie o QBZ para aplicar" + +# Compact header navigation (opt-in, 2026-07-03) +msgid "Compact header navigation" +msgstr "Navegação compacta no cabeçalho" + +msgid "Use the icon-only section navigation in the header even while the sidebar is open." +msgstr "Usa a navegação de secções apenas com ícones no cabeçalho mesmo com a barra lateral aberta." + +# DSD support Phase 1 (2026-07-03) +msgid "DSD: {}" +msgstr "DSD: {}" + +msgid "Cannot play DSD file" +msgstr "Não é possível reproduzir o ficheiro DSD" + +# DSD Phases 2-3: delivery mode (2026-07-03) +msgid "DSD playback" +msgstr "Reprodução DSD" + +msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC." +msgstr "Como as faixas DSD chegam ao DAC. AVISO: escolha DoP ou Nativo apenas se o seu DAC o suportar — em qualquer outro DAC reproduzem-se como ruído alto. O volume fica fixo no modo DoP/Nativo. O Nativo requer ainda suporte do kernel para o DAC." + +msgid "Convert to PCM (works everywhere)" +msgstr "Converter para PCM (funciona em todo o lado)" + +msgid "DoP — DSD over PCM (bit-perfect)" +msgstr "DoP — DSD sobre PCM (bit-perfect)" + +msgid "Native DSD (kernel support required)" +msgstr "DSD nativo (requer suporte do kernel)" + +msgid "Date added" +msgstr "Data de adição" + +msgid "Renderer setting reverted to Auto — the previous start didn't finish" +msgstr "Configuração do renderizador revertida para Automático — a inicialização anterior não foi concluída" + +msgid "Renderer switched to GPU (compatibility) — the GPU renderer failed to start" +msgstr "Renderizador alterado para GPU (compatibilidade) — o renderizador GPU não conseguiu iniciar" + +msgid "Audio output error" +msgstr "Erro de saída de áudio" + +msgid "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default" +msgstr "O acesso direto a dispositivos ALSA está bloqueado no Flatpak/Snap — mude o backend de áudio para PipeWire ou o padrão do sistema" + +msgid "Auto theme generation failed" +msgstr "Falha ao gerar o tema automático" + +msgid "Play all tracks" +msgstr "Reproduzir todas as faixas" + +msgid "Shuffle all tracks" +msgstr "Aleatório: todas as faixas" + +msgid "Select multiple tracks" +msgstr "Selecionar várias faixas" + +msgid "Play a random album" +msgstr "Reproduzir um álbum aleatório" + +msgid "Open a random artist" +msgstr "Abrir um artista aleatório" + +msgid "Play a random playlist" +msgstr "Reproduzir uma playlist aleatória" + +msgid "Open a random label" +msgstr "Abrir um selo aleatório" + +msgid "Start from current theme" +msgstr "Partir do tema atual" + +msgid "Copy the colors of the currently applied theme into the editor as a starting point." +msgstr "Copia as cores do tema aplicado atualmente para o editor como ponto de partida." + +msgid "Use current colors" +msgstr "Usar cores atuais" + +msgid "Dark theme" +msgstr "Tema escuro" + +msgid "Set the overall light or dark polarity. Affects derived shades, borders and overlays." +msgstr "Define a polaridade geral clara ou escura. Afeta os tons derivados, as bordas e as sobreposições." + +msgid "SURFACES" +msgstr "SUPERFÍCIES" + +msgid "Background" +msgstr "Fundo" + +msgid "Surface" +msgstr "Superfície" + +msgid "Elevated" +msgstr "Elevado" + +msgid "TEXT" +msgstr "TEXTO" + +msgid "Text" +msgstr "Texto" + +msgid "Secondary text" +msgstr "Texto secundário" + +msgid "ACCENT" +msgstr "DESTAQUE" + +msgid "Accent" +msgstr "Destaque" + +msgid "Danger" +msgstr "Perigo" + +msgid "Warning" +msgstr "Aviso" + +msgid "Success" +msgstr "Sucesso" + +msgid "OTHER" +msgstr "OUTROS" + +msgid "Border" +msgstr "Borda" + +# Unavailable-track auto-skip (Tauri #467 parity) +msgid "No available tracks to play" +msgstr "Nenhuma faixa disponível para reprodução" +# Always-visible log uploader entry (settings sub-nav footer) +msgid "Share logs" +msgstr "Compartilhar registros" +# Guest local-only mode disclaimer line (login screen, #553) +msgid "QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library)." +msgstr "O QBZ pode ser usado como reprodutor offline sem conta Qobuz™ (sem acesso à biblioteca Qobuz™)." +# Renderer degradation ladder toast (generic: GL or software rung) +msgid "Renderer switched automatically — the previous renderer failed to start" +msgstr "Renderizador alterado automaticamente — o anterior não pôde iniciar" + +# QConnect startup behavior (Settings > Playback, ported from Tauri) +msgid "Auto-connect Qobuz Connect on startup" +msgstr "Ligar o Qobuz Connect automaticamente ao iniciar" +msgid "Choose whether Qobuz Connect activates automatically when QBZ launches." +msgstr "Escolha se o Qobuz Connect é ativado automaticamente quando o QBZ inicia." +msgid "Remember state" +msgstr "Lembrar estado" +msgid "On by default" +msgstr "Ligado por padrão" +msgid "Off by default" +msgstr "Desligado por padrão" + +msgid "QBZ recovered from repeated startup crashes — some restored state was reset" +msgstr "O QBZ se recuperou de falhas repetidas na inicialização — parte do estado restaurado foi redefinida" + +# QConnect device name (Settings > Playback, ported from Tauri) +msgid "Qobuz Connect device name" +msgstr "Nome do dispositivo no Qobuz Connect" + +msgid "The name other Qobuz Connect apps see for this device. Applies on the next connection." +msgstr "O nome que outros aplicativos Qobuz Connect veem para este dispositivo. Aplicado na próxima conexão." + +# Quality downgrade tooltip (#590 follow-up) +msgid "Source: {}" +msgstr "Fonte: {}" + +msgid "Output: {}" +msgstr "Saída: {}" + +# Local Library header Plex sync button (#573) +msgid "Sync Plex library" +msgstr "Sincronizar biblioteca do Plex" + +# Library Albums sort options (#566) +msgid "Recently added" +msgstr "Adicionados recentemente" + +msgid "First added" +msgstr "Adicionados primeiro" + +msgid "Random" +msgstr "Aleatório" + + +# Offline cache clear — communicate it does not remove downloads (#606) +msgid "Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above." +msgstr "Libera dados em cache. Seus álbuns baixados são mantidos — remova-os no gerenciador offline acima." + +msgid "Couldn't clear the cache" +msgstr "Não foi possível limpar o cache" + + +# Task 18 — Settings > Developer > Export settings modal (daemon 04 §4.2) +msgid "SETTINGS PORTABILITY" +msgstr "PORTABILIDADE DAS DEFINIÇÕES" + +msgid "Export settings…" +msgstr "Exportar definições…" + +msgid "Save a portable bundle of your settings to move to another machine or the qbzd daemon." +msgstr "Guarda um pacote portátil das suas definições para as levar para outra máquina ou para o daemon qbzd." + +msgid "Export…" +msgstr "Exportar…" + +msgid "Export settings" +msgstr "Exportar definições" + +msgid "Save your settings as a bundle to import on the qbzd daemon or another machine." +msgstr "Guarde as suas definições como um pacote para importar no daemon qbzd ou noutra máquina." + +msgid "Include Qobuz login and integration tokens" +msgstr "Incluir o início de sessão do Qobuz e os tokens de integrações" + +msgid "Anyone with the file can use your accounts. It is saved readable only by you; delete it after importing." +msgstr "Qualquer pessoa que consiga ler o ficheiro pode usar as suas contas. É guardado de forma legível apenas para si; elimine-o após a importação." + +msgid "Export" +msgstr "Exportar" + +msgid "Bundle saved. On the daemon box:" +msgstr "Pacote guardado. Na máquina do daemon:" + +msgid "Could not export settings" +msgstr "Não foi possível exportar as definições" + + +# Share menu Song.link/Album.link toasts (#514) +msgid "Fetching Song.link..." +msgstr "Obtendo Song.link..." + +msgid "Fetching Album.link..." +msgstr "Obtendo Album.link..." + +msgid "Link copied" +msgstr "Link copiado" + +msgid "Failed to copy link" +msgstr "Falha ao copiar o link" + +# Dynamic app background (Appearance settings) +msgid "Ambient" +msgstr "Ambiente" + +msgid "Blurred art" +msgstr "Capa desfocada" + +msgid "Dynamic background" +msgstr "Fundo dinâmico" + +msgid "Animated album-art background behind the whole app. High resource use — GPU accelerated." +msgstr "Fundo animado com a capa do álbum atrás de toda a aplicação. Uso elevado de recursos — acelerado por GPU." + +# Preferred GPU (Renderer settings) +msgid "Preferred GPU" +msgstr "GPU preferida" + +msgid "Which GPU renders the app. Only the GPUs actually present are listed. Choosing one other than the default also switches the renderer to Vulkan — the only backend Qt can select a GPU on (requires restart)" +msgstr "Qual GPU renderiza o app. Só são listadas as GPUs realmente presentes. Escolher uma diferente da padrão também muda o renderizador para Vulkan — o único backend em que o Qt consegue selecionar GPU (requer reiniciar)" + +msgid "Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)" +msgstr "Qual GPU renderiza o app. Em um laptop híbrido, escolher a GPU dedicada transfere a carga do fundo dinâmico para fora da integrada (mais fria, mas com maior consumo). Auto é recomendado (requer reinicialização)" + +msgid "discrete" +msgstr "dedicada" + +msgid "integrated" +msgstr "integrada" + +msgid "Preferred GPU changed — restart QBZ to apply" +msgstr "GPU preferida alterada — reinicie o QBZ para aplicar" + +msgid "Selected GPU is unavailable in this display session — using Auto" +msgstr "A GPU selecionada não está disponível nesta sessão gráfica — será usado Auto" + +msgid "Renderer changed — restart QBZ to apply" +msgstr "Renderizador alterado — reinicie o QBZ para aplicar" + +msgid "Search your library" +msgstr "Pesquisar na sua biblioteca" + +msgid "From catalog" +msgstr "Do catálogo" + +msgid "In library" +msgstr "Na biblioteca" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Adicionar aos favoritos" + +msgid "Remove from favorites" +msgstr "Remover dos favoritos" + +msgid "Start radio" +msgstr "Iniciar rádio" + +msgid "Booklet" +msgstr "Livreto" + +msgid "Add to Mixtape" +msgstr "Adicionar à Mixtape" + +msgid "Album info" +msgstr "Informações do álbum" + +msgid "More options" +msgstr "Mais opções" + +msgid "Follow artist" +msgstr "Seguir artista" + +msgid "Unfollow" +msgstr "Deixar de seguir" + +msgid "Artist network" +msgstr "Rede do artista" + +msgid "Upload to Qobuz" +msgstr "Enviar para o Qobuz" + +msgid "Select tracks" +msgstr "Selecionar faixas" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "O Qobuz está recusando solicitações temporariamente — aguardando. Tente novamente em breve." + + +msgid "Flatpak" +msgstr "Flatpak" + +msgid "Snap" +msgstr "Snap" + +msgid "FLATPAK SANDBOX" +msgstr "SANDBOX FLATPAK" + +msgid "SNAP SANDBOX" +msgstr "SANDBOX SNAP" + +msgid "QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates." +msgstr "O QBZ está a correr dentro de uma sandbox Flatpak. Algumas funcionalidades precisam de permissões concedidas uma única vez — execute os comandos num terminal e reinicie o QBZ. As permissões mantêm-se entre atualizações." + +msgid "QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ." +msgstr "O QBZ está a correr dentro de uma sandbox Snap. Algumas interfaces de áudio precisam de ser ligadas uma única vez — execute estes comandos num terminal e reinicie o QBZ." + +msgid "Exclusive DAC access (bit-perfect)" +msgstr "Acesso exclusivo ao DAC (bit-perfect)" + +msgid "Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected." +msgstr "Recomendado para o modo exclusivo: permite ao QBZ pedir ao PipeWire que entregue o DAC limpamente (reserva de dispositivo via D-Bus). Sem isto, o PipeWire mantém o DAC e outras aplicações continuam a misturar através dele mesmo com ALSA + hw + Exclusivo selecionado." + +msgid "Music on NAS or external drives" +msgstr "Música em NAS ou discos externos" + +msgid "Grants access to a folder outside the sandbox. Repeat the command for each folder you need." +msgstr "Concede acesso a uma pasta fora da sandbox. Repita o comando para cada pasta necessária." + +msgid "Required audio connections" +msgstr "Ligações de áudio obrigatórias" + +msgid "Optional — external drives & NAS" +msgstr "Opcional — discos externos e NAS" + +msgid "Copy" +msgstr "Copiar" + +msgid "Cut" +msgstr "Recortar" + +msgid "Paste" +msgstr "Colar" + +msgid "Copied!" +msgstr "Copiado!" + +msgid "Limited by your streaming quality setting" +msgstr "Limitado pela sua definição de qualidade de streaming" + +msgid "Qobuz has no higher quality for this track" +msgstr "A Qobuz não oferece qualidade superior para esta faixa" + +msgid "offline copy — cap not applied" +msgstr "cópia offline — limite não aplicado" + +msgid "cached copy — cap not applied" +msgstr "cópia em cache — limite não aplicado" + +msgid "Limited by your output device's limit" +msgstr "Limitado pelo limite do seu dispositivo de saída" + +msgid "Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting." +msgstr "Limita a qualidade de streaming solicitada ao limite do seu dispositivo de saída. Aplica-se apenas à reprodução local, nunca à transmissão." + +msgid "Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly." +msgstr "Não foi possível ler os limites exatos do seu dispositivo neste sistema, por isso assume-se um conjunto comum. O limite aplica-se na mesma, mas pode não corresponder exatamente ao seu hardware." + +msgid "Detected device limit" +msgstr "Limite detetado do dispositivo" + +msgid "Quality for this device" +msgstr "Qualidade para este dispositivo" + +msgid "Follow app setting ({})" +msgstr "Seguir a definição da app ({})" + +msgid "Hi-Res — 24-bit, up to 96 kHz" +msgstr "Hi-Res — 24-bit, até 96 kHz" + +msgid "CD — 16-bit / 44.1 kHz" +msgstr "CD — 16-bit / 44.1 kHz" + +msgid "MP3 320" +msgstr "MP3 320" + +msgid "Qobuz only offers these tiers — there is no 48 kHz option. The step below Hi-Res is CD 44.1 kHz." +msgstr "A Qobuz só oferece estes níveis — não há opção de 48 kHz. O nível abaixo de Hi-Res é CD 44.1 kHz." + +msgid "Limited by the quality cap for {}" +msgstr "Limitado pelo limite de qualidade para {}" + +msgid "capped for this device" +msgstr "limitado para este dispositivo" + +msgid "Albums by folder" +msgstr "Álbuns por pasta" + +msgid "Albums by metadata" +msgstr "Álbuns por metadados" + +msgid "ALBUMS VIEW" +msgstr "VISTA DE ÁLBUNS" + +msgid "Album grouping" +msgstr "Agrupamento de álbuns" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Pastas: um cartão por pasta de álbum — ideal para compilações e box sets. Metadados: dividir por etiquetas de álbum + artista." + +msgid "+{} more artists" +msgstr "+{} artistas mais" + +msgid "Show less" +msgstr "Mostrar menos" +# Most Played Albums rail title +msgid "Most Played Albums" +msgstr "Álbuns mais tocados" + +# Most Played Albums View-all — per-card play count +msgid "{} plays" +msgstr "{} reproduções" + +# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast +msgid "Show lyrics translation" +msgstr "Mostrar tradução da letra" + +msgid "Hide lyrics translation" +msgstr "Ocultar tradução da letra" + +msgid "Translation language" +msgstr "Idioma da tradução" + +msgid "Auto (account language)" +msgstr "Automático (idioma da conta)" + +msgid "Translation is not available right now. Please try again later." +msgstr "A tradução não está disponível de momento. Tente novamente mais tarde." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Artistas dispensados só saem das suas Recomendações — continuam aparecendo na busca e em todo o resto." + +msgid "No dismissed artists" +msgstr "Nenhum artista dispensado" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Para deixar de ver um artista em Recomendações, abra o menu do seu cartão e escolha Não tenho interesse." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} não aparecerá mais em Recomendações" + +msgid "Artist dismissed from Recommendations" +msgstr "Artista dispensado das Recomendações" + +msgid "{} restored to Recommendations" +msgstr "{} restaurado em Recomendações" + +msgid "LIBRARY › ALL" +msgstr "BIBLIOTECA › TUDO" + +msgid "Show local files & Plex" +msgstr "Mostrar ficheiros locais e Plex" + +msgid "Include your favorited local files and Plex items in the Library All feed." +msgstr "Inclui os seus ficheiros locais e itens do Plex favoritos no feed Tudo da biblioteca." + +msgid "Play indicator animation" +msgstr "Animação do indicador de reprodução" + +msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU." +msgstr "Anima a linha em reprodução com barras de equalizador. Desligado (padrão) mostra um ícone de pausa estático com uma marca de destaque na borda — mais leve para a CPU." + +msgid "Invert swipe navigation direction" +msgstr "Inverter a direção do gesto de navegação" + +msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward." +msgstr "Troca o deslizar com dois dedos no touchpad: esquerda volta, direita avança." + +msgid "Local items in Library All" +msgstr "Itens locais na Biblioteca Tudo" + +msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track." +msgstr "Somente favoritos: o que você marcou. Toda a biblioteca local: cada álbum, artista e faixa das pastas e do Plex." + +msgid "Favorited items only" +msgstr "Somente favoritos" + +msgid "Entire local library" +msgstr "Toda a biblioteca local" + +msgid "Select multiple albums" +msgstr "Selecionar vários álbuns" + +msgid "Play later" +msgstr "Tocar depois" + +msgid "Added to play later" +msgstr "Adicionado para depois" + +msgid "Next in queue" +msgstr "A seguir na fila" + +msgid "Next up" +msgstr "A seguir" + +msgid "Removed {} tracks from favorites" +msgstr "{} faixas removidas dos favoritos" + +msgid "Removing {} tracks from favorites…" +msgstr "Removendo {} faixas dos favoritos…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Álbum \"{}\" bloqueado" + +msgid "Album \"{}\" unblocked" +msgstr "Álbum \"{}\" desbloqueado" + +msgid "Failed to block album" +msgstr "Falha ao bloquear o álbum" + +msgid "Failed to unblock album" +msgstr "Falha ao desbloquear o álbum" + +msgid "Failed to clear album blacklist" +msgstr "Falha ao limpar a lista negra de álbuns" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "{} álbum removido da lista negra" +msgstr[1] "{} álbuns removidos da lista negra" + +msgid "Playlist imported" +msgstr "Playlist importada" + +msgid "Playlist import failed" +msgstr "Falha ao importar a playlist" + +msgid "Not logged in to Qobuz" +msgstr "Você não está conectado ao Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Excluir pasta" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Excluir a pasta “{}”? Suas playlists voltam para a raiz." + +msgid "Choose a folder image" +msgstr "Escolha uma imagem para a pasta" + +msgid "Images" +msgstr "Imagens" + +msgid "Failed to create folder" +msgstr "Falha ao criar a pasta" + +msgid "Failed to save folder" +msgstr "Falha ao salvar a pasta" + +msgid "Failed to delete folder" +msgstr "Falha ao excluir a pasta" + +msgid "Failed to rename playlist" +msgstr "Falha ao renomear a playlist" + +msgid "Failed to delete playlist" +msgstr "Falha ao excluir a playlist" + +msgid "Couldn't create the playlist" +msgstr "Não foi possível criar a playlist" + +# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard, +# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. +msgid "Open playlist" +msgstr "Abrir playlist" + +# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut +# action labels + category names for the cheatsheet and the customize editor. +# Slint's Rust-side labels were never extracted (the port inherits the gap); +# this block fixes it for both frontends. en is EXCLUDED on purpose +# (sparse-override convention, qbz-i18n/src/lib.rs:120-125). + +msgid "Play / Pause" +msgstr "Reproduzir / Pausar" + +msgid "Go Back" +msgstr "Voltar" + +msgid "Go Forward" +msgstr "Avançar" + +msgid "Toggle Sidebar" +msgstr "Alternar barra lateral" + +msgid "Immersive Mode" +msgstr "Modo imersivo" + +msgid "Close / Dismiss" +msgstr "Fechar / Dispensar" + +msgid "Show Shortcuts" +msgstr "Mostrar atalhos" + +msgid "Toggle Mini Player" +msgstr "Alternar mini player" + +msgid "Seek Forward (5s)" +msgstr "Avançar 5 s" + +msgid "Seek Back (5s)" +msgstr "Recuar 5 s" + +msgid "Seek Forward (10s)" +msgstr "Avançar 10 s" + +msgid "Seek Back (10s)" +msgstr "Recuar 10 s" + +msgid "Micro View" +msgstr "Vista micro" + +msgid "Compact View" +msgstr "Vista compacta" + +msgid "Artwork View" +msgstr "Vista de capa" + +msgid "Listen list" +msgstr "Lista de escuta" + +msgid "Lyrics View" +msgstr "Vista de letra" + +msgid "Navigation" +msgstr "Navegação" + +msgid "Interface" +msgstr "Interface" + +msgid "Mini Player" +msgstr "Mini player" + + +# Kiosk shell (2026-08-02 kiosk-port contract) + +msgid "Cover" +msgstr "Capa" + +msgid "Radio" +msgstr "Rádio" + +msgid "Up Next" +msgstr "A seguir" + +# Kiosk <-> Desktop profile toggle (view-mode menu) + +msgid "Kiosk mode" +msgstr "Modo quiosque" + +msgid "Desktop mode" +msgstr "Modo desktop" + +# AlbumView cover menu / lightbox (Qt port, 2026-08-10) + +msgid "View cover" +msgstr "Ver capa" + +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Ver imagem" + +msgid "Could not load the image" +msgstr "Não foi possível carregar a imagem" + +# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's +# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen +# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter", +# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On", +# "Bands & Projects", "Load more", "Loading…", "Artist Scene", +# "No artists found for this scene.", "No album appearances found." and +# "Select an artist to see their albums." (period-only delta) already exist. +# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7, +# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them +# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every +# call site substitutes explicitly, e.g. .replace("{artist}", name). + +msgid "Based on {artist} · {genres}" +msgstr "Baseado em {artist} · {genres}" + +msgid "Searching MusicBrainz for {genre} artists..." +msgstr "Procurando artistas de {genre} no MusicBrainz..." + +msgid "Verifying availability on Qobuz..." +msgstr "Verificando disponibilidade no Qobuz..." + +msgid "Preparing results..." +msgstr "Preparando resultados..." + +msgid "No matching artists from this scene are currently available on Qobuz." +msgstr "Nenhum artista correspondente desta cena está disponível no Qobuz no momento." + +msgid "Loading albums..." +msgstr "Carregando álbuns..." + +msgid "Failed to load albums" +msgstr "Falha ao carregar os álbuns" + +msgid "Failed to load appearances" +msgstr "Falha ao carregar as participações" + +msgid "Looking up {name}..." +msgstr "Procurando {name}..." + +msgid "Failed to look up musician" +msgstr "Falha ao procurar o músico" + +msgid "Browse view" +msgstr "Visualização de exploração" + +msgid "Grid view" +msgstr "Visualização em grade" + +msgid "Alphabetical (A-Z)" +msgstr "Alfabético (A-Z)" + +msgid "This musician appears in album credits but has limited catalog information available in Qobuz." +msgstr "Este músico aparece nos créditos do álbum, mas há poucas informações de catálogo disponíveis no Qobuz." + +msgid "Unable to verify this musician's catalog. The information shown is based on album credits." +msgstr "Não foi possível verificar o catálogo deste músico. As informações exibidas vêm dos créditos do álbum." + +msgid "This musician appears in album credits." +msgstr "Este músico aparece nos créditos do álbum." + +msgid "Discography" +msgstr "Discografia" + +msgid "Live Albums" +msgstr "Álbuns ao vivo" + +msgid "Select an artist to see their albums" +msgstr "Selecione um artista para ver os seus álbuns" + +msgid "{} / {} artists" +msgstr "{} / {} artistas" + +# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17 +# §8-6). "Unavailable" and "This track is no longer available" already +# exist in this file and are REUSED by the render and the reactive skip toast +# — nothing below twins them. +# +# The skipped-tracks toast is a plural PAIR, per the convention already set by +# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build +# drops N rows at once, and a singular-only string reads wrong in every locale +# with plural rules. Russian carries all three forms. +# +# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled +# state (contract §5.3/F5): the track left the catalogue but the user owns a +# downloaded copy, so it still plays. It cannot fold into "This track is no +# longer available" — that sentence would be a lie on a row playing right now. +# +# Em dash is U+2014 with spaces on both sides, as everywhere else in this file. +msgid "Find available version" +msgstr "Procurar versão disponível" + +msgid "Replace unavailable track" +msgstr "Substituir faixa indisponível" + +msgid "No available version found" +msgstr "Não foi encontrada nenhuma versão disponível" + +msgid "Track replaced" +msgstr "Faixa substituída" + +msgid "Could not replace the track" +msgstr "Falha ao substituir a faixa" + +msgid "The replacement was added at the end of the playlist" +msgstr "A substituição foi adicionada ao fim da playlist" + +msgid "{} track skipped — no longer available" +msgid_plural "{} tracks skipped — no longer available" +msgstr[0] "{} faixa ignorada — já não está disponível" +msgstr[1] "{} faixas ignoradas — já não estão disponíveis" + +msgid "No longer on Qobuz — playing your downloaded copy" +msgstr "Já não está no Qobuz — a reproduzir a sua cópia transferida" + +msgid "Exact match" +msgstr "Correspondência exata" + +msgid "Low confidence" +msgstr "Baixa confiança" + +msgid "Replace" +msgstr "Substituir" + +msgid "Replacing..." +msgstr "Substituindo..." + +msgid "The replacement takes the unavailable track's place in the playlist." +msgstr "A substituição ocupa o lugar da faixa indisponível na playlist." + +# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19) +msgid "Items per carousel" +msgstr "Itens por carrossel" + +# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19) +msgid "Recently Played Playlists" +msgstr "Playlists reproduzidas recentemente" + +msgid "Playlists you play will appear here." +msgstr "As playlists que você reproduzir aparecerão aqui." + +# Applied-filters tooltip (2026-08-19) +msgid "+{} more" +msgstr "+{} mais" + +msgid "+{} more filters" +msgstr "+{} filtros a mais" + +msgid "Applied filters" +msgstr "Filtros aplicados" + +# Applied-filters tooltip: group labels (2026-08-19) +msgid "Category" +msgstr "Categoria" + +# Applied-filters tooltip: Library source switches (2026-08-19) +msgid "Favorites" +msgstr "Favoritos" + +# Discover configurator: Refresh content (2026-08-19) +msgid "Refresh content" +msgstr "Atualizar conteúdo" + +# Appearance: click-on-section opens the first tab (2026-08-19) +msgid "Click on menu item navigates to first tab" +msgstr "Clicar em uma seção abre a primeira aba" + +msgid "Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu" +msgstr "Clicar em uma seção na barra lateral ou na barra de título abre-a na primeira aba — Discover em Home, Library em All — em vez de apenas mostrar o menu" + +# Discover configurator: per-rail default item count (2026-08-19) +msgid "Default ({})" +msgstr "Padrão ({})" + +# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources + +msgid "Streaming URL" +msgstr "URL de streaming" + +msgid "Playlist file" +msgstr "Ficheiro de playlist" + +msgid "JSON file" +msgstr "Ficheiro JSON" + +msgid "ListenBrainz" +msgstr "ListenBrainz" + +msgid "Last.fm" +msgstr "Last.fm" + +msgid "Choose file…" +msgstr "Escolher ficheiro…" + +msgid "No file selected" +msgstr "Nenhum ficheiro selecionado" + +msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog." +msgstr "Apenas a lista de faixas é lida — os ficheiros de áudio referenciados nunca são abertos, copiados nem adicionados à sua biblioteca local. Cada entrada é procurada no catálogo Qobuz." + +msgid "JSON is read best-effort — check the track count below before importing." +msgstr "O JSON é lido da melhor forma possível — verifique o número de faixas antes de importar." + +msgid "Could not read the file." +msgstr "Não foi possível ler o ficheiro." + +msgid "No tracks found in the file." +msgstr "Não foram encontradas faixas no ficheiro." + +msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON." +msgstr "Formato de ficheiro não reconhecido — use XSPF, PLS, M3U/M3U8 ou JSON." + +msgid "This looks like a live-stream manifest, not a playlist." +msgstr "Isto parece um manifesto de transmissão em direto, não uma playlist." + +msgid "That file is too large to import." +msgstr "Esse ficheiro é demasiado grande para importar." + +msgid "Could not recognize a track list in this JSON." +msgstr "Não foi possível reconhecer uma lista de faixas neste JSON." + +msgid "Username or profile URL" +msgstr "Utilizador ou URL de perfil" + +msgid "Username or playlist URL" +msgstr "Utilizador ou URL da playlist" + +msgid "Play your library" +msgstr "Reproduzir a sua biblioteca" + +msgid "Play your mix" +msgstr "Reproduzir a sua mistura" + +msgid "Play your recommendations" +msgstr "Reproduzir as suas recomendações" + +msgid "Station" +msgstr "Estação" + +msgid "No playlists found for {}." +msgstr "Não foram encontradas playlists para {}." + +msgid "Enter a public username." +msgstr "Introduza um utilizador público." + +msgid "Duplicates: {}" +msgstr "Duplicados: {}" + +msgid "Media" +msgstr "Mídia" + +msgid "Open audio CD" +msgstr "Abrir CD de áudio" + +msgid "Audio CD" +msgstr "CD de áudio" + +msgid "No optical drive found." +msgstr "Nenhuma unidade óptica encontrada." + +msgid "No disc in the drive." +msgstr "Nenhum disco na unidade." + +msgid "That disc has no audio tracks." +msgstr "Esse disco não tem faixas de áudio." + +msgid "Could not read the disc." +msgstr "Não foi possível ler o disco." + +msgid "Track {}" +msgstr "Faixa {}" + +msgid "Open SACD image…" +msgstr "Abrir imagem SACD…" + +msgid "Choose a disc image" +msgstr "Escolha uma imagem de disco" + +msgid "That image has no stereo audio area." +msgstr "Essa imagem não tem área de áudio estéreo." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Este disco está comprimido em DST, que o QBZ ainda não reproduz." + +msgid "That file is not a disc image." +msgstr "Esse arquivo não é uma imagem de disco." + +msgid "That file is not a SACD image." +msgstr "Esse arquivo não é uma imagem SACD." + +msgid "That SACD image is damaged or unsupported." +msgstr "Essa imagem SACD está danificada ou não é compatível." + +msgid "SACD" +msgstr "SACD" + +msgid "Library Explorer" +msgstr "Explorador da biblioteca" + +msgid "Genres" +msgstr "Gêneros" + +msgid "All genres" +msgstr "Todos os gêneros" + +msgid "All albums" +msgstr "Todos os álbuns" + +msgid "No albums match these filters" +msgstr "Nenhum álbum corresponde a estes filtros" + +msgid "Try a different genre, artist, album, source or quality." +msgstr "Tente outro gênero, artista, álbum, fonte ou qualidade." + +msgid "Favorites only" +msgstr "Somente favoritos" + +msgid "Where should the ripped album go?" +msgstr "Onde salvar o álbum extraído?" + +msgid "Ripping" +msgstr "Extraindo" + +msgid "Nothing to rip." +msgstr "Nada para extrair." + +msgid "Ripped {} track to your library" +msgid_plural "Ripped {} tracks to your library" +msgstr[0] "{} faixa extraída para sua biblioteca" +msgstr[1] "{} faixas extraídas para sua biblioteca" + +# Discs: rip wizard, metadata lookup and the disc tooltips (2026-08-21) + +msgid "Play a folder, CD or disc image without adding it to your library" +msgstr "Reproduza uma pasta, CD ou imagem de disco sem adicioná-la à sua biblioteca" + +msgid "Reading the disc…" +msgstr "Lendo o disco…" + +msgid "Re-scan your folders and media servers for new music" +msgstr "Verificar novamente suas pastas e servidores de mídia em busca de músicas novas" + +msgid "Manage the folders your library is built from" +msgstr "Gerencie as pastas que formam a sua biblioteca" + +msgid "Play the whole disc from the first track" +msgstr "Reproduzir o disco inteiro a partir da primeira faixa" + +msgid "Play it in a shuffled order" +msgstr "Reproduzir em ordem aleatória" + +msgid "Look this disc up and correct its details" +msgstr "Pesquisar este disco e corrigir seus dados" + +msgid "Copy this CD to your computer as FLAC files" +msgstr "Copiar este CD para o computador como arquivos FLAC" + +msgid "Close this disc and go back to your library" +msgstr "Fechar este disco e voltar para a sua biblioteca" + +msgid "Correct disc details" +msgstr "Corrigir dados do disco" + +msgid "SACD image" +msgstr "Imagem SACD" + +msgid "Close without changing anything" +msgstr "Fechar sem alterar nada" + +msgid "Community database. Knows discs by their table of contents." +msgstr "Base de dados comunitária. Reconhece discos pela tabela de conteúdo." + +msgid "Collector database. Stronger on pressings, editions and catalogue numbers." +msgstr "Base de dados de colecionadores. Melhor em prensagens, edições e números de catálogo." + +msgid "Search this provider" +msgstr "Pesquisar neste provedor" + +msgid "The provider is rate-limiting right now. Try again in a moment." +msgstr "O provedor está limitando as consultas agora. Tente de novo em instantes." + +msgid "No releases matched. Try fewer words, or the other provider." +msgstr "Nenhum resultado. Tente com menos palavras, ou com o outro provedor." + +msgid "Search for the release this disc actually is." +msgstr "Pesquise a edição que este disco realmente é." + +msgid "Remove my correction" +msgstr "Remover minha correção" + +msgid "Forget the saved details for this disc and look it up fresh next time" +msgstr "Esquecer os dados salvos deste disco e pesquisar de novo na próxima vez" + +msgid "Applying..." +msgstr "Aplicando..." + +msgid "Use these details" +msgstr "Usar estes dados" + +msgid "Rename this disc and remember it for next time" +msgstr "Renomear este disco e lembrar dele na próxima vez" + +msgid "Rip this CD" +msgstr "Extrair este CD" + +msgid "What will happen" +msgstr "O que vai acontecer" + +msgid "Track names" +msgstr "Nomes das faixas" + +msgid "Your library" +msgstr "Sua biblioteca" + +msgid "Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags." +msgstr "Cada faixa do disco é lida e gravada como arquivo FLAC: uma pasta por álbum, um arquivo por faixa, com tags." + +msgid "Format: FLAC (lossless) — the only format QBZ writes" +msgstr "Formato: FLAC (sem perdas) — o único formato que o QBZ grava" + +msgid "Where the album goes" +msgstr "Onde o álbum vai ficar" + +msgid "Choose folder…" +msgstr "Escolher pasta…" + +msgid "Change…" +msgstr "Alterar…" + +msgid "Pick the folder the album folder will be created in" +msgstr "Escolha a pasta onde a pasta do álbum será criada" + +msgid "Nothing chosen yet" +msgstr "Nada escolhido ainda" + +msgid "This folder is already part of your Local Library." +msgstr "Esta pasta já faz parte da sua Biblioteca Local." + +msgid "This folder is outside your Local Library, so the album will not appear there on its own." +msgstr "Esta pasta está fora da sua Biblioteca Local, então o álbum não vai aparecer lá sozinho." + +msgid "Choose a destination first and this step will know what to offer." +msgstr "Escolha um destino primeiro e esta etapa vai saber o que oferecer." + +msgid "Re-scan that folder when the rip finishes" +msgstr "Verificar novamente essa pasta quando a extração terminar" + +msgid "Only that folder, not your whole library." +msgstr "Só essa pasta, não a biblioteca inteira." + +msgid "Do nothing" +msgstr "Não fazer nada" + +msgid "The album appears the next time you scan." +msgstr "O álbum aparece na próxima verificação." + +msgid "Add this folder to Local Library and scan it" +msgstr "Adicionar esta pasta à Biblioteca Local e verificá-la" + +msgid "Future rips into the same folder will be picked up too." +msgstr "As próximas extrações para a mesma pasta também serão detectadas." + +msgid "The files are written; the library will not know about them." +msgstr "Os arquivos são gravados; a biblioteca não vai saber deles." + +msgid "A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool." +msgstr "Um extrator simples, feito para colocar seus CDs na biblioteca rapidamente. Para verificação AccurateRip e opções avançadas, use uma ferramenta dedicada." + +msgid "Close without ripping anything" +msgstr "Fechar sem extrair nada" + +msgid "Go back a step" +msgstr "Voltar uma etapa" + +msgid "Start ripping" +msgstr "Iniciar a extração" + +msgid "Go to the next step" +msgstr "Ir para a próxima etapa" + +msgid "Read the disc and write the FLAC files" +msgstr "Ler o disco e gravar os arquivos FLAC" + +msgid "Choose a destination folder first" +msgstr "Escolha uma pasta de destino primeiro" + +msgid "No disc is open." +msgstr "Nenhum disco está aberto." + +msgid "Couldn't load that release." +msgstr "Não foi possível carregar essa edição." + +msgid "Disc details updated" +msgstr "Dados do disco atualizados" + +msgid "Correction removed. Re-open the disc to look it up again." +msgstr "Correção removida. Abra o disco de novo para pesquisá-lo outra vez." + +msgid "Only a physical CD can be ripped." +msgstr "Só é possível extrair um CD físico." + +msgid "Choose where the album should go first." +msgstr "Escolha primeiro onde o álbum vai ficar." + +msgid "Couldn't add that folder to the library" +msgstr "Não foi possível adicionar essa pasta à biblioteca" + +# Rip progress panel, cancel and partial rips (2026-08-21) + +msgid "Writing FLAC files to" +msgstr "Gravando arquivos FLAC em" + +msgid "Hide this panel — the rip keeps going" +msgstr "Ocultar este painel — a extração continua" + +msgid "Ripping — don't eject the disc until this finishes." +msgstr "Extraindo — não ejete o disco até terminar." + +msgid "Finished" +msgstr "Concluído" + +msgid "Files already written stay on disk. Delete them yourself if you don't want them." +msgstr "Os arquivos já gravados ficam no disco. Se você não os quiser, apague-os manualmente." + +msgid "Stop ripping" +msgstr "Parar a extração" + +msgid "Stopping…" +msgstr "Parando…" + +msgid "Stop after the current track. Nothing is deleted." +msgstr "Parar depois da faixa atual. Nada é apagado." + +msgid "Rip stopped. The files already written are still there." +msgstr "Extração parada. Os arquivos já gravados continuam lá." + +msgid "See what the rip is doing, or stop it" +msgstr "Ver o que a extração está fazendo, ou pará-la" + +msgid "Pick at least one track to rip." +msgstr "Escolha pelo menos uma faixa para extrair." + +# Ephemeral pane: per-disc blocks and the track filter (2026-08-21) + +msgid "Disc {}" +msgstr "Disco {}" + +msgid "Filter tracks" +msgstr "Filtrar faixas" + +msgid "No track matches that." +msgstr "Nenhuma faixa corresponde." + +msgid "NAVIGATION" +msgstr "NAVEGAÇÃO" + +msgid "SEARCH" +msgstr "PESQUISA" + +msgid "PLAYER & VISUALS" +msgstr "REPRODUTOR E VISUAIS" + +msgid "WINDOW" +msgstr "JANELA" + +msgid "SYSTEM INTEGRATION" +msgstr "INTEGRAÇÃO COM O SISTEMA" + +msgid "The typeface used across the app. System follows your operating system; the other choices are bundled with QBZ (requires restart)" +msgstr "A fonte usada em todo o app. Sistema segue o sistema operacional; as outras opções acompanham o QBZ (requer reiniciar)" + +msgid "Library folders" +msgstr "Pastas da biblioteca" + +msgid "{} folders" +msgstr "{} pastas" + +msgid "No Qobuz account? You can still use QBZ as a player for your own music library" +msgstr "Sem conta Qobuz? Você ainda pode usar o QBZ como reprodutor da sua própria música" + +msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working." +msgstr "Você está usando o QBZ sem conta Qobuz. Sua biblioteca local e seus downloads continuam funcionando." + +msgid "Continuous playback" +msgstr "Reprodução contínua" + +msgid "Open" +msgstr "Abrir" + +msgid "Folder view" +msgstr "Vista de pastas" + +msgid "Open media" +msgstr "Abrir mídia" + +msgid "Open folder…" +msgstr "Abrir pasta…" + +# Ordem das abas da biblioteca local e início sem sessão (2026-08-24) +msgid "LIBRARY TABS" +msgstr "ABAS DA BIBLIOTECA" + +msgid "Tab order and default" +msgstr "Ordem das abas e padrão" + +msgid "Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session." +msgstr "Escolha a ordem das abas da biblioteca local. A primeira abre por padrão, inclusive quando o QBZ inicia sem uma sessão do Qobuz." + +msgid "Local Library tabs" +msgstr "Abas da biblioteca local" + +msgid "Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session." +msgstr "Reordene as abas da biblioteca local. A primeira abre por padrão e é exibida ao iniciar sem uma sessão do Qobuz." + +msgid "Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu" +msgstr "Clicar em uma seção na barra lateral ou de título abre a primeira aba — incluindo o padrão escolhido para a biblioteca local — em vez de mostrar apenas o menu" + +# Posição dos filtros de gênero da biblioteca local (2026-08-24) +msgid "GENRES VIEW" +msgstr "VISUALIZAÇÃO POR GÊNEROS" + +msgid "Genre filters position" +msgstr "Posição dos filtros de gênero" + +msgid "Place the chained genre filters above, beside or below the album results." +msgstr "Posicione os filtros de gênero encadeados acima, ao lado ou abaixo dos resultados de álbuns." + +msgid "Top" +msgstr "Acima" + +msgid "Bottom" +msgstr "Abaixo" + +msgid "Album metadata saved." +msgstr "Metadados do álbum salvos." + +msgid "Album title is required." +msgstr "O título do álbum é obrigatório." + +msgid "Apply to form" +msgstr "Aplicar ao formulário" + +msgid "CUE-based albums can only be edited with a sidecar." +msgstr "Álbuns baseados em CUE só podem ser editados com um arquivo sidecar." + +msgid "CUE-based albums use sidecar metadata." +msgstr "Álbuns baseados em CUE usam metadados sidecar." + +msgid "Canonical" +msgstr "Canônica" + +msgid "Close without saving" +msgstr "Fechar sem salvar" + +msgid "Couldn't inspect album metadata." +msgstr "Não foi possível inspecionar os metadados do álbum." + +msgid "Couldn't load that metadata result." +msgstr "Não foi possível carregar esse resultado de metadados." + +msgid "Couldn't open the metadata source." +msgstr "Não foi possível abrir a fonte de metadados." + +msgid "Detected" +msgstr "Detectadas" + +msgid "Disc number" +msgstr "Número do disco" + +msgid "Editing the selected physical version" +msgstr "Editando a versão física selecionada" + +msgid "Enter an album title or artist to search." +msgstr "Digite um título de álbum ou artista para pesquisar." + +msgid "Every track needs a title." +msgstr "Cada faixa precisa de um título." + +msgid "File names are shown for reference and cannot be changed here." +msgstr "Os nomes dos arquivos são exibidos como referência e não podem ser alterados aqui." + +msgid "Find metadata" +msgstr "Buscar metadados" + +msgid "ID3 writing" +msgstr "Gravação ID3" + +msgid "Metadata editing is available for local file versions only." +msgstr "A edição de metadados está disponível apenas para versões de arquivos locais." + +msgid "Metadata search failed." +msgstr "A pesquisa de metadados falhou." + +msgid "No local album version is selected." +msgstr "Nenhuma versão local do álbum está selecionada." + +msgid "None" +msgstr "Nenhuma" + +msgid "One or more audio files are unavailable." +msgstr "Um ou mais arquivos de áudio não estão disponíveis." + +msgid "Open source" +msgstr "Abrir fonte" + +msgid "QBZ will preflight every file, update its canonical tag layer, then read it back to verify the change. Keep a backup if these files cannot be replaced." +msgstr "O QBZ verificará cada arquivo, atualizará sua camada de tags canônica e a lerá novamente para confirmar a alteração. Mantenha um backup se esses arquivos não puderem ser substituídos." + +msgid "Save changes to" +msgstr "Salvar alterações em" + +msgid "Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled." +msgstr "Alguns arquivos contêm camadas de tags conflitantes. A camada canônica prevalece, a menos que a sincronização esteja ativada." + +msgid "Synchronize compatible tag layers" +msgstr "Sincronizar camadas de tags compatíveis" + +msgid "Tag layers" +msgstr "Camadas de tags" + +msgid "The canonical tag is not writable for every file." +msgstr "A tag canônica não pode ser gravada em todos os arquivos." + +msgid "The metadata editor is no longer open." +msgstr "O editor de metadados não está mais aberto." + +msgid "The metadata form could not be read." +msgstr "Não foi possível ler o formulário de metadados." + +msgid "The track list changed; reopen the editor." +msgstr "A lista de faixas mudou; reabra o editor." + +msgid "The track list contains a duplicate row." +msgstr "A lista de faixas contém uma linha duplicada." + +msgid "Track number" +msgstr "Número da faixa" + +msgid "Unknown metadata persistence mode." +msgstr "Modo de persistência de metadados desconhecido." + +msgid "Write and verify" +msgstr "Gravar e verificar" + +msgid "Write metadata into audio files?" +msgstr "Gravar os metadados nos arquivos de áudio?" + +msgid "Year must be a valid number." +msgstr "O ano deve ser um número válido." + +msgid "{} must be a positive number." +msgstr "{} deve ser um número positivo." + +msgid "The ephemeral session changed; reopen the metadata editor." +msgstr "A sessão efêmera mudou; reabra o editor de metadados." + +msgid "Album artists" +msgstr "Artistas do álbum" + +msgid "Album artists (ordered; separate with semicolons)" +msgstr "Artistas do álbum (em ordem; separe com ponto e vírgula)" + +msgid "Artist credit" +msgstr "Crédito de artista" + +msgid "Artists (ordered; semicolon separated)" +msgstr "Artistas (em ordem; separados por ponto e vírgula)" + +msgid "Artwork is larger than 25 MiB." +msgstr "A imagem da capa é maior que 25 MiB." + +msgid "Artwork search failed." +msgstr "A busca de capas falhou." + +msgid "Artwork source did not provide a secure URL." +msgstr "A fonte da capa não forneceu uma URL segura." + +msgid "Choose album artwork" +msgstr "Escolher capa do álbum" + +msgid "Choose another file" +msgstr "Escolher outro arquivo" + +msgid "Choose file" +msgstr "Escolher arquivo" + +msgid "Compilation / Various Artists" +msgstr "Coletânea / Vários artistas" + +msgid "Composers" +msgstr "Compositores" + +msgid "Couldn't download that artwork." +msgstr "Não foi possível baixar essa capa." + +msgid "Couldn't stage artwork" +msgstr "Não foi possível preparar a capa" + +msgid "Current artwork" +msgstr "Capa atual" + +msgid "Discogs release ID" +msgstr "ID do lançamento no Discogs" + +msgid "Enter an album title to find artwork." +msgstr "Digite o título do álbum para buscar capas." + +msgid "File" +msgstr "Arquivo" + +msgid "Find artwork" +msgstr "Buscar capa" + +msgid "Local file" +msgstr "Arquivo local" + +msgid "MusicBrainz album artist IDs" +msgstr "IDs dos artistas do álbum no MusicBrainz" + +msgid "MusicBrainz artist IDs" +msgstr "IDs dos artistas no MusicBrainz" + +msgid "MusicBrainz recording ID" +msgstr "ID da gravação no MusicBrainz" + +msgid "MusicBrainz release ID" +msgstr "ID do lançamento no MusicBrainz" + +msgid "MusicBrainz release-group ID" +msgstr "ID do grupo de lançamentos no MusicBrainz" + +msgid "MusicBrainz track ID" +msgstr "ID da faixa no MusicBrainz" + +msgid "Performers" +msgstr "Intérpretes" + +msgid "Revert selection" +msgstr "Reverter seleção" + +msgid "Selected track details" +msgstr "Detalhes da faixa selecionada" + +msgid "That artwork result is no longer available." +msgstr "Esse resultado de capa não está mais disponível." + +msgid "The selected artwork changed; choose it again." +msgstr "A capa selecionada mudou; escolha-a novamente." + +msgid "The selected artwork has unsupported dimensions." +msgstr "A capa selecionada tem dimensões incompatíveis." + +msgid "The selected file is not a supported image." +msgstr "O arquivo selecionado não é uma imagem compatível." + +msgid "Goniometer" +msgstr "Goniômetro" + +msgid "Oscilloscope" +msgstr "Osciloscópio" + +msgid "Track waveform" +msgstr "Forma de onda da faixa" + +msgid "Show the full-track waveform in the player seek bar." +msgstr "Mostra a forma de onda completa da faixa na barra de busca do player." + +msgid "Compact header" +msgstr "Cabeçalho compacto" + +msgid "Show less data in album headers." +msgstr "Mostrar menos dados nos cabeçalhos de álbuns." + +msgid "Show full album header" +msgstr "Mostrar cabeçalho completo do álbum" + +msgid "Show compact album header" +msgstr "Mostrar cabeçalho compacto do álbum" + +msgid "Immediate scrobbling" +msgstr "Scrobbling imediato" + +msgid "Send scrobbles immediately while manual offline mode is enabled." +msgstr "Enviar scrobbles imediatamente enquanto o modo offline manual estiver ativado." + +msgid "Accumulated scrobbling" +msgstr "Scrobbling acumulado" + +msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old." +msgstr "Colocar scrobbles na fila enquanto a rede estiver indisponível e enviá-los quando ela voltar. O Last.fm aceita scrobbles de até 2 semanas." + +msgid "Scrobble while logged out" +msgstr "Scrobbling sem login" + +msgid "Continue sending plays to connected scrobblers without a Qobuz session." +msgstr "Continuar enviando reproduções aos serviços conectados sem uma sessão do Qobuz." + +msgid "Focus on playing" +msgstr "Focar na reprodução" + +msgid "Go to top" +msgstr "Ir para o topo" + +msgid "Quick view" +msgstr "Vista rápida" + +msgid "Could not load album" +msgstr "Não foi possível carregar o álbum" + +msgid "This album has no tracks." +msgstr "Este álbum não tem faixas." + +msgid "Reactive Rings" +msgstr "Anéis reativos" + +msgid "Windows — yes, but read this first" +msgstr "Windows — sim, mas leia isto primeiro" + +msgid "Don't show this again" +msgstr "Não mostrar novamente" + +msgid "I understand" +msgstr "Eu entendo" +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker + +msgid "Volume Up" +msgstr "Aumentar Volume" + +msgid "Volume Down" +msgstr "Diminuir Volume" + +msgid "Mute / Unmute" +msgstr "Silenciar / Reativar Som" + +msgid "Toggle Shuffle" +msgstr "Alternar Aleatório" + +msgid "Cycle Repeat" +msgstr "Alternar Repetição" + +msgid "Like Track" +msgstr "Curtir Faixa" + +msgid "Seek Forward" +msgstr "Avançar" + +msgid "Seek Back" +msgstr "Recuar" + +msgid "Keymap" +msgstr "Mapa de Teclas" + +msgid "Vim" +msgstr "Vim" + +# Local Library QoL: Explorer, scan progress, and per-track metadata editor +msgid "All years" +msgstr "Todos os anos" + +msgid "Explorer columns" +msgstr "Colunas do explorador" + +msgid "Genre and year" +msgstr "Gênero e ano" + +msgid "Years" +msgstr "Anos" + +msgid "Edit track metadata" +msgstr "Editar metadados da faixa" + +msgid "Building library index" +msgstr "Criando o índice da biblioteca" + +msgid "Local folders" +msgstr "Pastas locais" + +msgid "Next track" +msgstr "Próxima faixa" + +msgid "Overall" +msgstr "Geral" + +msgid "Previous track" +msgstr "Faixa anterior" + +msgid "Scanning library" +msgstr "Verificando a biblioteca" + +msgid "The track is no longer in the library." +msgstr "A faixa não está mais na biblioteca." + +msgid "Updating library index" +msgstr "Atualizando o índice da biblioteca" + +msgid "Edit album" +msgstr "Editar álbum" + +msgid "Editing a local metadata sidecar" +msgstr "Editando um sidecar de metadados local" + +msgid "Media-server metadata is stored in a local sidecar." +msgstr "Os metadados do servidor de mídia são armazenados em um sidecar local." + +msgid "Media-server metadata can only be edited with a sidecar." +msgstr "Os metadados do servidor de mídia só podem ser editados por meio de um sidecar." + +msgid "Network file" +msgstr "Arquivo de rede" + +msgid "Offline download" +msgstr "Download off-line" + +msgid "File path" +msgstr "Caminho do arquivo" + +msgid "Containing folder" +msgstr "Pasta que contém" + +msgid "Media item ID" +msgstr "ID do item de mídia" + +msgid "Album ID" +msgstr "ID do álbum" + +msgid "Server" +msgstr "Servidor" + +msgid "Duration" +msgstr "Duração" + +msgid "Format" +msgstr "Formato" + +msgid "Channels" +msgstr "Canais" + +msgid "File size" +msgstr "Tamanho do arquivo" + +msgid "Location" +msgstr "Localização" + +msgid "The media item is no longer available." +msgstr "O item de mídia não está mais disponível." + +msgid "The local album folder is not currently available." +msgstr "A pasta local do álbum não está disponível no momento." + +msgid "Everything is already available offline" +msgstr "Tudo já está disponível off-line" + +msgid "Some tracks are already available offline" +msgstr "Algumas faixas já estão disponíveis off-line" + +msgid "{} of {} tracks in “{}” are already available offline." +msgstr "{} de {} faixas de “{}” já estão disponíveis off-line." + +msgid "Download the entire playlist again" +msgstr "Baixar a playlist inteira novamente" + +msgid "Download the entire album again" +msgstr "Baixar o álbum inteiro novamente" + +msgid "Re-downloads every track and can repair corrupt offline copies." +msgstr "Baixa novamente todas as faixas e pode reparar cópias off-line corrompidas." + +msgid "Only missing tracks" +msgstr "Apenas faixas ausentes" + +msgid "Keeps existing offline copies and downloads tracks that are not available yet." +msgstr "Mantém as cópias off-line existentes e baixa as faixas que ainda não estão disponíveis." + +msgid "PRIVACY" +msgstr "PRIVACIDADE" + +msgid "Listening history" +msgstr "Histórico de audição" + +msgid "Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler." +msgstr "Mantém um registo local do que reproduz: faixa, origem, quanto ouviu e quando. Fica nesta máquina, por conta, e nunca sai daqui a menos que ative um scrobbler." + +msgid "Clear listening history" +msgstr "Limpar histórico de audição" + +msgid "Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected." +msgstr "Elimina todas as reproduções registadas nesta máquina para esta conta. Os scrobbles já enviados não são afetados." + +msgid "Listening history cleared." +msgstr "Histórico de audição limpo." + +msgid "Couldn't clear listening history." +msgstr "Não foi possível limpar o histórico de audição." + +msgid "Checking ALSA mixer controls…" +msgstr "A verificar os controlos do misturador ALSA…" + +msgid "This device exposes multiple playback controls. Choose which one QBZ should use." +msgstr "Este dispositivo apresenta vários controlos de reprodução. Escolha qual o QBZ deve usar." + +msgid "The saved mixer control is no longer available. Choose another control." +msgstr "O controlo do misturador guardado já não está disponível. Escolha outro controlo." + +msgid "QBZ does not have permission to read this device's ALSA mixer." +msgstr "O QBZ não tem permissão para ler o misturador ALSA deste dispositivo." + +msgid "The ALSA mixer is busy. Close the app using it and try again." +msgstr "O misturador ALSA está ocupado. Feche a aplicação que o está a usar e tente novamente." + +msgid "The selected ALSA device is no longer available." +msgstr "O dispositivo ALSA selecionado já não está disponível." + +msgid "This ALSA device does not expose a mixer." +msgstr "Este dispositivo ALSA não disponibiliza um misturador." + +msgid "QBZ could not write the selected ALSA mixer control. Direct playback remains fixed at 100%." +msgstr "O QBZ não conseguiu escrever no controlo do misturador ALSA selecionado. A reprodução direta permanece fixa em 100%." + +msgid "Selected mixer control" +msgstr "Controlo do misturador selecionado" + +msgid "Choose mixer control" +msgstr "Escolher controlo do misturador" + +msgid "Change mixer control" +msgstr "Alterar controlo do misturador" + +msgid "Qobuz Connect could not shut down safely. Quit and reopen QBZ, then try logging out again." +msgstr "Não foi possível encerrar o Qobuz Connect com segurança. Feche e reabra o QBZ e tente terminar sessão novamente." + +msgid "Show filter panels" +msgstr "Mostrar os painéis de filtros" + +msgid "Hide filter panels" +msgstr "Ocultar os painéis de filtros" + +msgid "Already in" +msgstr "Já está em" + +msgid "Playlist index updating..." +msgstr "Atualizando o índice de playlists..." + +msgid "{} of {}" +msgstr "{} de {}" + +msgid "Last playlist" +msgstr "Última playlist" + +msgid "OK" +msgstr "OK" + +msgid "Sponsors" +msgstr "Patrocinadores" + +msgid "And all those anonymous sponsors..." +msgstr "E todos esses patrocinadores anônimos..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "O Qobuz Connect não consegue resolver faixas locais. {} faixa foi ignorada e não foi adicionada à fila. Desative o Qobuz Connect para reproduzi-la." +msgstr[1] "O Qobuz Connect não consegue resolver faixas locais. {} faixas foram ignoradas e não foram adicionadas à fila. Desative o Qobuz Connect para reproduzi-las." + +msgid "Qobuz Connect device" +msgstr "Dispositivo Qobuz Connect" + +msgid "Continue playback on %1" +msgstr "Continuar a reprodução em %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Usar a fila do Qobuz Connect e manter esse dispositivo ativo." + +msgid "Continue playback on this device" +msgstr "Continuar a reprodução neste dispositivo" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Usar a fila do Qobuz Connect e substituir a fila local." + +msgid "Continue this device's current playback" +msgstr "Continuar a reprodução atual deste dispositivo" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Substituir a fila e o estado de reprodução do Qobuz Connect pela fila e posição atuais deste dispositivo." + +msgid "Cancel Qobuz Connect" +msgstr "Cancelar o Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Manter as duas filas inalteradas e continuar a reprodução local atual." + +msgid "Choose how to continue" +msgstr "Escolher como continuar" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Este dispositivo e outro reprodutor do Qobuz Connect estão reproduzindo. Escolha qual fila e dispositivo devem continuar." + +msgid "Ask every time" +msgstr "Perguntar sempre" + +msgid "Continue on the active renderer" +msgstr "Continuar no reprodutor ativo" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Continuar aqui com a fila do Qobuz Connect" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Continuar a reprodução local e substituir a fila do Qobuz Connect" + +msgid "When playback conflicts" +msgstr "Quando houver conflito de reprodução" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Perguntar sempre ou escolher automaticamente qual fila e dispositivo devem continuar." + +msgid "Purchased" +msgstr "Comprado" + +msgid "Sort: Release date" +msgstr "Ordenar: Data de lançamento" + +msgid "Until the purchased file is downloaded, Play streams the best quality Qobuz offers for this album. Once it is on disk, Play uses your copy." +msgstr "Enquanto o ficheiro comprado não for descarregado, Reproduzir transmite a melhor qualidade que a Qobuz oferece para este álbum. Assim que estiver no disco, Reproduzir usa a sua cópia." + +msgid "Open in Local Library" +msgstr "Abrir na Biblioteca local" + +msgid "Import / Export" +msgstr "Importar / Exportar" + +msgid "This bundle holds the app's own settings: audio, playback, appearance, integrations. It does not contain your Qobuz favorites, playlists, blacklist or purchases." +msgstr "Este pacote contém as configurações do app: áudio, reprodução, aparência, integrações. Não inclui seus favoritos, playlists, blacklist nem compras do Qobuz." + +msgid "BLACKLIST" +msgstr "BLACKLIST" + +msgid "Export blacklist…" +msgstr "Exportar blacklist…" + +msgid "Save your blocked artists and albums as a file you can hand to another QBZ user." +msgstr "Salva seus artistas e álbuns bloqueados em um arquivo que você pode passar para outro usuário do QBZ." + +msgid "Import blacklist…" +msgstr "Importar blacklist…" + +msgid "Add blocked artists and albums from a file. Nothing already on your blacklist is removed." +msgstr "Adiciona artistas e álbuns bloqueados a partir de um arquivo. Nada que já esteja na sua blacklist é removido." + +msgid "Import…" +msgstr "Importar…" + +msgid "Import failed." +msgstr "Falha na importação." + +msgid "Not a QBZ blacklist file." +msgstr "Não é um arquivo de blacklist do QBZ." + +msgid "Choose a blacklist file" +msgstr "Escolha um arquivo de blacklist" + +msgid "Imported {} artists and {} albums ({} already blocked)." +msgstr "{} artistas e {} álbuns importados ({} já bloqueados)." + +msgid "Member" +msgstr "Membro" + +msgid "QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™." +msgstr "O QBZ requer uma conta Qobuz™. Sem assinatura ativa, a reprodução fica limitada a prévias de 30 segundos; favoritos, playlists e compras continuam funcionando. Suas credenciais são enviadas diretamente ao Qobuz™." + +msgid "ACCOUNT MIGRATION" +msgstr "MIGRAÇÃO DE CONTA" + +msgid "Moves favorites, playlists and followed playlists from one Qobuz account into another, adding only what is missing and deleting nothing on either side. It does not move subscriptions or purchases: those stay with the Qobuz account that holds them." +msgstr "Leva favoritos, playlists e playlists seguidas de uma conta Qobuz para outra, adicionando só o que falta e sem apagar nada de nenhum lado. Não migra assinaturas nem compras: elas ficam na conta Qobuz que as possui." + +msgid "Create migration snapshot" +msgstr "Criar snapshot de migração" + +msgid "Sign in with the account you are moving FROM, then save its favorites and playlists to a file in its QBZ profile." +msgstr "Entre com a conta DA QUAL você está saindo e salve seus favoritos e playlists em um arquivo no perfil QBZ dela." + +msgid "Create…" +msgstr "Criar…" + +msgid "Migrate into this account" +msgstr "Migrar para esta conta" + +msgid "Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing." +msgstr "Entre com a conta PARA a qual você está indo e escolha um snapshot abaixo. Pode repetir sem risco: uma segunda execução não adiciona nada." + +msgid "No snapshots yet. Sign in with the old account and create one first." +msgstr "Ainda não há snapshots. Entre com a conta antiga e crie um primeiro." + +msgid "{} favorites · {} playlists · {} followed" +msgstr "{} favoritos · {} playlists · {} seguidas" + +msgid "this account" +msgstr "esta conta" + +msgid "Migrate…" +msgstr "Migrar…" + +msgid "Account {} · {}" +msgstr "Conta {} · {}" + +msgid "Reading favorites and playlists…" +msgstr "Lendo favoritos e playlists…" + +msgid "Reading this account…" +msgstr "Lendo esta conta…" + +msgid "Adding favorites…" +msgstr "Adicionando favoritos…" + +msgid "Adding playlists…" +msgstr "Adicionando playlists…" + +msgid "Following playlists…" +msgstr "Seguindo playlists…" + +msgid "{} / {} — {}" +msgstr "{} / {} — {}" + +msgid "Snapshot failed." +msgstr "Falha no snapshot." + +msgid "Migration failed." +msgstr "Falha na migração." + +msgid "Saved {} favorites, {} playlists and {} followed playlists to {}" +msgstr "{} favoritos, {} playlists e {} playlists seguidas salvos em {}" + +msgid "That snapshot was taken from this same account." +msgstr "Esse snapshot é desta mesma conta." + +msgid "Nothing to migrate: everything is already in this account." +msgstr "Nada para migrar: tudo já está nesta conta." + +msgid "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there." +msgstr "{} favoritos, {} playlists ({} faixas) e {} playlists seguidas adicionados; {} já estavam lá." + +msgid "{} items failed; see the log." +msgstr "{} itens falharam; veja o log." + +msgid "Also copy the local profile" +msgstr "Copiar também o perfil local" + +msgid "Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer." +msgstr "Pastas da biblioteca, pastas e ordem das playlists, itens fixados, blacklist e preferências do perfil QBZ da conta antiga neste computador." + +msgid "Media server connections" +msgstr "Conexões com servidores de mídia" + +msgid "Plex, Jellyfin and Subsonic settings, including their credentials." +msgstr "Configurações de Plex, Jellyfin e Subsonic, incluindo as credenciais." + +msgid "Scrobbler accounts" +msgstr "Contas de scrobbling" + +msgid "Last.fm and ListenBrainz settings, including their credentials." +msgstr "Configurações de Last.fm e ListenBrainz, incluindo as credenciais." + +msgid "The listen log and the events behind offline recommendations." +msgstr "O registro de reprodução e os eventos por trás das recomendações offline." + +msgid "Local profile: {} rows copied; {} playlist rows had no match." +msgstr "Perfil local: {} linhas copiadas; {} linhas de playlist sem correspondência." + +msgid "The library already had folders: run a rescan to index the added ones." +msgstr "A biblioteca já tinha pastas: execute uma nova varredura para indexar as adicionadas." + +msgid "Restart QBZ to see the migrated local profile." +msgstr "Reinicie o QBZ para ver o perfil local migrado." + +msgid "The local profile could not be copied; see the log." +msgstr "Não foi possível copiar o perfil local; veja o log." +msgid "Delete…" +msgstr "Excluir…" + +msgid "Delete migration snapshot?" +msgstr "Excluir instantâneo de migração?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Isso remove apenas o pacote de migração. O perfil do QBZ e os dados de usuário da conta antiga são mantidos." + +msgid "Delete snapshot" +msgstr "Excluir instantâneo" + +msgid "Migration snapshot deleted." +msgstr "Instantâneo de migração excluído." + +msgid "Could not delete the migration snapshot." +msgstr "Não foi possível excluir o instantâneo de migração." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} está atualizando a biblioteca ({} itens verificados). Aguarde a conclusão e sincronize novamente." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "Nenhuma conexão Plex, Jellyfin ou Subsonic estava salva no perfil de origem, portanto nenhuma pôde ser copiada." + +msgid "Media server connections copied: {} of {}." +msgstr "Conexões de servidores de mídia copiadas: {} de {}." + +msgid "Copying local profile…" +msgstr "Copiando o perfil local…" + +msgid "Refreshing migrated data…" +msgstr "Atualizando os dados migrados…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Isso acontece apenas uma vez. Aguarde a conclusão e não feche a janela." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Você pode fechar este painel; a migração continuará em segundo plano." + +msgid "From" +msgstr "Origem" + +msgid "To" +msgstr "Destino" + +msgid "Step {} of {}" +msgstr "Etapa {} de {}" + +msgid "Elapsed: {}" +msgstr "Tempo decorrido: {}" + +msgid "Remaining: {}" +msgstr "Restante: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Revise os perfis de origem salvos, escolha o que copiar e confirme a conta de destino conectada." + +msgid "Migrate account data" +msgstr "Migrar dados da conta" + +msgid "Start migration" +msgstr "Iniciar migração" + +msgid "What is a migration profile?" +msgstr "O que é um perfil de migração?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "É um pacote de uso único criado a partir da conta de origem. Ele preserva os favoritos e playlists do Qobuz para adicioná-los à conta conectada agora. Os dados locais opcionais são copiados diretamente do perfil antigo do QBZ neste computador. Excluir o pacote não apaga o perfil antigo nem os dados de nenhuma conta." + +msgid "SAVED SOURCE PROFILES" +msgstr "PERFIS DE ORIGEM SALVOS" + +msgid "No saved migration profiles are available." +msgstr "Não há perfis de migração salvos disponíveis." + +msgid "Selected" +msgstr "Selecionado" + +msgid "Select" +msgstr "Selecionar" + +msgid "MIGRATION DIRECTION" +msgstr "DIREÇÃO DA MIGRAÇÃO" + +msgid "Select a source profile above" +msgstr "Selecione um perfil de origem acima" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Conta Qobuz conectada · ID {}" + +msgid "WHAT TO COPY" +msgstr "O QUE COPIAR" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "A migração é aditiva: ela não remove favoritos nem playlists de nenhuma conta." + +msgid "That library folder is disabled." +msgstr "Essa pasta da biblioteca está desativada." + +msgid "That folder overlaps an existing library folder." +msgstr "Essa pasta se sobrepõe a uma pasta existente da biblioteca." + +msgid "This purchase is not available for download." +msgstr "Esta compra não está disponível para download." + +msgid "Could not save playback preference." +msgstr "Não foi possível salvar a preferência de reprodução." + +msgid "Qobuz · stream/offline copy" +msgstr "Qobuz · streaming/cópia offline" + +msgid "Local purchase" +msgstr "Compra local" + +msgid "Play with" +msgstr "Reproduzir com" + +msgid "Download purchase" +msgstr "Baixar compra" + +msgid "1 track purchased" +msgstr "1 faixa comprada" + +msgid "{} tracks purchased" +msgstr "{} faixas compradas" + +msgid "This copy cannot be continued right now." +msgstr "Não é possível continuar esta cópia agora." + +msgid "Library folder refreshed." +msgstr "Pasta da biblioteca atualizada." + +msgid "Download another copy" +msgstr "Baixar outra cópia" + +msgid "Complete" +msgstr "Completa" + +msgid "Partial" +msgstr "Parcial" + +msgid "Missing files" +msgstr "Arquivos ausentes" + +msgid "Changed files" +msgstr "Arquivos alterados" + +msgid "Unreadable files" +msgstr "Arquivos ilegíveis" + +msgid "Local purchase copies" +msgstr "Cópias locais da compra" + +msgid "{} of {} tracks" +msgstr "{} de {} faixas" + +msgid "Continue copy" +msgstr "Continuar cópia" + +msgid "Add to Local Library" +msgstr "Adicionar à biblioteca local" + +msgid "In Local Library" +msgstr "Na biblioteca local" + +msgid "{} of {} tracks available in this folder." +msgstr "{} de {} faixas disponíveis nesta pasta." + +msgid "{} failed" +msgstr "{} com falha" + +msgid "{} cancelled" +msgstr "{} canceladas" + +msgid "Download finished" +msgstr "Download concluído" + +msgid "Shuffle: On" +msgstr "Aleatório: ativado" + +msgid "Shuffle: Off" +msgstr "Aleatório: desativado" + +msgid "Repeat: Off" +msgstr "Repetir: desativado" + +msgid "Repeat: All" +msgstr "Repetir: todas" + +msgid "Repeat: One" +msgstr "Repetir: uma" + +msgid "Show album artwork in Queue track list" +msgstr "Mostrar capas de álbum na lista de faixas da fila" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Substitui os números das faixas da fila por miniaturas das capas. Desligado por padrão." + +msgid "Show album artwork in Library track list" +msgstr "Mostrar capas de álbum na lista de faixas da Biblioteca" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Exibe a miniatura da capa entre o número e o título da faixa. Desligado por padrão." + +msgid "Play random" +msgstr "Reproduzir aleatório" + +msgid "Play selected" +msgstr "Reproduzir seleção" + +msgid "Cancel selection" +msgstr "Cancelar seleção" + +msgid "QOBUZ LINKS" +msgstr "LINKS DO QOBUZ" + +msgid "Open Qobuz links in QBZ" +msgstr "Abrir links do Qobuz no QBZ" + +msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here." +msgstr "Torna o QBZ o manipulador dos links qobuzapp:// vindos do navegador. Desligado, o app oficial do Qobuz fica com eles; os links qbz:// do QBZ sempre abrem aqui." + +msgid "Look for better replacement" +msgstr "Procurar uma substituição melhor" + +msgid "Remove all favorites from this release" +msgstr "Remover todos os favoritos deste lançamento" + +msgid "Remove all favorites from this release?" +msgstr "Remover todos os favoritos deste lançamento?" + +msgid "Remove the album and every track from this release from your Library?" +msgstr "Remover da sua Biblioteca o álbum e todas as faixas deste lançamento?" + +msgid "No replacement releases found" +msgstr "Nenhum lançamento substituto encontrado" + +msgid "Choosing a result opens the release. Your existing favorites are not changed." +msgstr "Escolher um resultado abre o lançamento. Seus favoritos atuais não são alterados." + +msgid "No favorites from this release to remove" +msgstr "Não há favoritos deste lançamento para remover" + +msgid "Favorites removed from this release" +msgstr "Favoritos deste lançamento removidos" + +msgid "Could not remove favorites from this release" +msgstr "Não foi possível remover os favoritos deste lançamento" + +msgid "Some favorites could not be removed" +msgstr "Não foi possível remover alguns favoritos" + +msgid "Replace in Library" +msgstr "Substituir na Biblioteca" + +msgid "Replacing favorite..." +msgstr "Substituindo favorito..." + +msgid "The selected release replaces the unavailable album favorite." +msgstr "O lançamento selecionado substitui o álbum favorito indisponível." + +msgid "The matching track from the selected release replaces the unavailable favorite." +msgstr "A faixa correspondente do lançamento selecionado substitui o favorito indisponível." + +msgid "Could not open the selected release" +msgstr "Não foi possível abrir o lançamento selecionado" + +msgid "Could not find a matching track in this release" +msgstr "Não foi possível encontrar uma faixa correspondente neste lançamento" + +msgid "Could not add the replacement favorite" +msgstr "Não foi possível adicionar o favorito substituto" + +msgid "Favorite replaced" +msgstr "Favorito substituído" + +msgid "The replacement was added, but the unavailable favorite could not be removed" +msgstr "A substituição foi adicionada, mas o favorito indisponível não pôde ser removido" + +msgid "Failed to send playback to the selected device" +msgstr "Não foi possível enviar a reprodução para o dispositivo selecionado" + +msgid "Move up" +msgstr "Mover para cima" + +msgid "Move down" +msgstr "Mover para baixo" + +msgid "Download" +msgstr "Baixar" + +msgid "Edit" +msgstr "Editar" + +msgid "Grid" +msgstr "Grade" + +msgid "List" +msgstr "Lista" + +msgid "Tree" +msgstr "Árvore" + +msgid "Forward" +msgstr "Avançar" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po new file mode 100644 index 000000000..5a14112cf --- /dev/null +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -0,0 +1,6864 @@ +msgid "" +msgstr "" +"Project-Id-Version: qbz-slint\n" +"PO-Revision-Date: 2026-09-05 19:52-0600\n" +"Last-Translator: QBZ contributors\n" +"Language-Team: QBZ ru translators\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" + +msgid "System Colors" +msgstr "Системные цвета" + +msgid "Wallpaper Sync" +msgstr "Синхронизация с обоями" + +msgid "Custom Image" +msgstr "Своё изображение" + +msgid "Auto" +msgstr "Авто" + +msgid "Disabled" +msgstr "Отключено" + +msgid "Replace current queue" +msgstr "Заменить текущую очередь" + +msgid "Play next" +msgstr "Воспроизвести далее" + +msgid "Add to queue" +msgstr "Добавить в очередь" + +msgid "Remember last" +msgstr "Запомнить последнее" + +msgid "Album Reactive" +msgstr "Реакция на альбом" + +msgid "Static" +msgstr "Статичный" + +msgid "Coverflow" +msgstr "Coverflow" + +msgid "Spectrum" +msgstr "Спектр" + +msgid "Lyrics" +msgstr "Текст песни" + +msgid "Queue" +msgstr "Очередь" + +msgid "Auto (opposite to window controls)" +msgstr "Авто (напротив кнопок окна)" + +msgid "Left" +msgstr "Слева" + +msgid "Right" +msgstr "Справа" + +msgid "Rectangular" +msgstr "Прямоугольный" + +msgid "Full-height rounded" +msgstr "Скруглённый во всю высоту" + +msgid "Circular" +msgstr "Круглый" + +msgid "Square" +msgstr "Квадратный" + +msgid "Small" +msgstr "Маленький" + +msgid "Normal" +msgstr "Обычный" + +msgid "Large" +msgstr "Большой" + +msgid "Default" +msgstr "По умолчанию" + +msgid "Monochrome" +msgstr "Монохромный" + +msgid "Custom" +msgstr "Свой" + +msgid "Remember last used" +msgstr "Запомнить последнее" + +msgid "Micro" +msgstr "Микро" + +msgid "Compact" +msgstr "Компактный" + +msgid "Artwork" +msgstr "Обложка" + +msgid "Home" +msgstr "Главная" + +msgid "Where you left off" +msgstr "На чём вы остановились" + +msgid "Mono light" +msgstr "Моно светлый" + +msgid "Mono dark" +msgstr "Моно тёмный" + +msgid "Color" +msgstr "Цвет" + +msgid "Vinyl" +msgstr "Винил" + +msgid "Visualizer" +msgstr "Визуализатор" + +msgid "Neon Flow" +msgstr "Неоновый поток" + +msgid "Tunnel" +msgstr "Тоннель" + +msgid "Comet" +msgstr "Комета" + +msgid "Split: Lyrics" +msgstr "Разделение: текст песни" + +msgid "Split: Track Info" +msgstr "Разделение: о треке" + +msgid "Split: Suggestions" +msgstr "Разделение: рекомендации" + +msgid "Split: Queue" +msgstr "Разделение: очередь" + +msgid "Full" +msgstr "Полный" + +msgid "Lite" +msgstr "Лёгкий" + +msgid "Off" +msgstr "Выкл." + +msgid "versions" +msgstr "версии" + +msgid "Search tracks..." +msgstr "Поиск треков..." + +msgid "Title" +msgstr "Название" + +msgid "Quality" +msgstr "Качество" + +msgid "Disc" +msgstr "Диск" + +msgid "Loading…" +msgstr "Загрузка…" + +msgid "Failed to load booklet" +msgstr "Не удалось загрузить буклет" + +msgid "Add cover" +msgstr "Добавить обложку" + +msgid "Change cover" +msgstr "Изменить обложку" + +msgid "Remove cover" +msgstr "Удалить обложку" + +msgid "Open in browser" +msgstr "Открыть в браузере" + +msgid "Save as…" +msgstr "Сохранить как…" + +msgid "Read more" +msgstr "Подробнее" + +msgid "Loading album…" +msgstr "Загрузка альбома…" + +msgid "Select all" +msgstr "Выбрать все" + +msgid "Add to playlist" +msgstr "Добавить в плейлист" + +msgid "Add to Library" +msgstr "Добавить в библиотеку" + +msgid "Make available offline" +msgstr "Сделать доступным офлайн" + +msgid "Clear" +msgstr "Очистить" + +msgid "LABEL" +msgstr "ЛЕЙБЛ" + +msgid "AWARDS" +msgstr "НАГРАДЫ" + +msgid "EXTERNAL LINKS" +msgstr "ВНЕШНИЕ ССЫЛКИ" + +msgid "About this album" +msgstr "Об альбоме" + +msgid "Released by" +msgstr "Издано" + +msgid "on" +msgstr "на" + +msgid "Credits" +msgstr "Авторы" + +msgid "Review" +msgstr "Рецензия" + +msgid "Failed to load album credits" +msgstr "Не удалось загрузить авторов альбома" + +msgid "Loading credits..." +msgstr "Загрузка авторов..." + +msgid "Loading track info..." +msgstr "Загрузка информации о треке..." + +msgid "Failed to load track info" +msgstr "Не удалось загрузить информацию о треке" + +msgid "Label" +msgstr "Лейбл" + +msgid "Newest" +msgstr "Сначала новые" + +msgid "Oldest" +msgstr "Сначала старые" + +msgid "A–Z" +msgstr "А–Я" + +msgid "Z–A" +msgstr "Я–А" + +msgid "Failed to load Library" +msgstr "Не удалось загрузить библиотеку" + +msgid "Retry" +msgstr "Повторить" + +msgid "No releases found." +msgstr "Релизы не найдены." + +msgid "See discography" +msgstr "Смотреть дискографию" + +msgid "Load more" +msgstr "Загрузить ещё" + +msgid "Add image" +msgstr "Добавить изображение" + +msgid "Change image" +msgstr "Изменить изображение" + +msgid "Remove image" +msgstr "Удалить изображение" + +msgid "Create Artist Collection" +msgstr "Создать коллекцию исполнителя" + +msgid "Artist Scene" +msgstr "Сцена исполнителя" + +msgid "Share" +msgstr "Поделиться" + +msgid "Show artist" +msgstr "Показать исполнителя" + +msgid "Blacklist artist" +msgstr "Добавить в чёрный список" + +msgid "This artist is hidden from discovery" +msgstr "Этот исполнитель скрыт из обзора" + +msgid "Loading artist…" +msgstr "Загрузка исполнителя…" + +msgid "Popular Tracks" +msgstr "Популярные треки" + +msgid "Play all next" +msgstr "Воспроизвести все далее" + +msgid "Play all later" +msgstr "Слушать все позже" + +msgid "Add all to queue" +msgstr "Добавить все в очередь" + +msgid "Shuffle all" +msgstr "Перемешать все" + +msgid "Add all to playlist" +msgstr "Добавить все в плейлист" + +msgid "View less" +msgstr "Свернуть" + +msgid "Latest release" +msgstr "Последний релиз" + +msgid "Appears On" +msgstr "Участие в" + +msgid "Hide" +msgstr "Скрыть" + +msgid "Show" +msgstr "Показать" + +msgid "Network" +msgstr "Сеть" + +msgid "Magazine" +msgstr "Журнал" + +msgid "Loading origin…" +msgstr "Загрузка сведений…" + +msgid "BORN" +msgstr "РОЖДЕНИЕ" + +msgid "FOUNDED" +msgstr "ОСНОВАНИЕ" + +msgid "BORN IN" +msgstr "МЕСТО РОЖДЕНИЯ" + +msgid "FOUNDED IN" +msgstr "МЕСТО ОСНОВАНИЯ" + +msgid "DIED" +msgstr "СМЕРТЬ" + +msgid "DISBANDED" +msgstr "РАСПАД" + +msgid "LABELS" +msgstr "ЛЕЙБЛЫ" + +msgid "No label info" +msgstr "Нет информации о лейбле" + +msgid "SIMILAR ARTISTS" +msgstr "ПОХОЖИЕ ИСПОЛНИТЕЛИ" + +msgid "RELATIONSHIPS" +msgstr "СВЯЗИ" + +msgid "Members & Former" +msgstr "Участники и бывшие" + +msgid "Member Of" +msgstr "Состоит в" + +msgid "Collaborators" +msgstr "Соавторы" + +msgid "YOU MAY ALSO LIKE" +msgstr "ВАМ ТАКЖЕ МОЖЕТ ПОНРАВИТЬСЯ" + +msgid "No stories for this artist" +msgstr "Нет историй об этом исполнителе" + +msgid "About this artist" +msgstr "Об исполнителе" + +msgid "Source" +msgstr "Источник" + +msgid "Following" +msgstr "Вы подписаны" + +msgid "Follow" +msgstr "Подписаться" + +msgid "View all" +msgstr "Показать все" + +msgid "View all →" +msgstr "Показать все →" + +msgid "Filter by genre" +msgstr "Фильтр по жанру" + +msgid "Remember selection" +msgstr "Запомнить выбор" + +msgid "Advanced view" +msgstr "Расширенный вид" + +msgid "Search genres…" +msgstr "Поиск жанров…" + +msgid "Clear filter" +msgstr "Сбросить фильтр" + +msgid "Qobuz Mixes" +msgstr "Миксы Qobuz" + +msgid "Elevate your day with a customized selection of music." +msgstr "Сделайте день ярче с подборкой музыки специально для вас." + +msgid "Take a weekly journey with a fresh mix every Friday." +msgstr "Отправляйтесь в еженедельное путешествие с новым миксом каждую пятницу." + +msgid "A fresh shuffle from your personal library." +msgstr "Свежая подборка из вашей личной библиотеки." + +msgid "Discover new music from your most-played playlists." +msgstr "Откройте новую музыку из ваших любимых плейлистов." + +msgid "Open album" +msgstr "Открыть альбом" + +msgid "Open containing album" +msgstr "Открыть альбом трека" + +msgid "Play" +msgstr "Воспроизвести" + +msgid "Search…" +msgstr "Поиск…" + +msgid "1 genre" +msgstr "1 жанр" + +msgid "{} genres" +msgstr "{} жанров" + +msgid "No results found." +msgstr "Ничего не найдено." + +msgid "Editor's Picks" +msgstr "Выбор редакции" + +msgid "For You" +msgstr "Для вас" + +msgid "Qobuz Playlists" +msgstr "Плейлисты Qobuz" + +msgid "No playlists match the selected categories." +msgstr "Нет плейлистов в выбранных категориях." + +msgid "Recently Played Albums" +msgstr "Недавно прослушанные альбомы" + +msgid "Albums you play will appear here." +msgstr "Здесь появятся прослушанные альбомы." + +msgid "Recently Played Tracks" +msgstr "Недавно прослушанные треки" + +msgid "Tracks you play will appear here." +msgstr "Здесь появятся прослушанные треки." + +msgid "Popular albums" +msgstr "Популярные альбомы" + +msgid "{} selected" +msgstr "Выбрано: {}" + +msgid "Filter by category" +msgstr "Фильтр по категории" + +msgid "All categories" +msgstr "Все категории" + +msgid "Customize" +msgstr "Настроить" + +msgid "Enabling more sections increases load time." +msgstr "Включение большего числа разделов увеличивает время загрузки." + +msgid "{} of {} enabled" +msgstr "Включено {} из {}" + +msgid "Reset to defaults" +msgstr "Сбросить настройки" + +msgid "Radio Stations" +msgstr "Радиостанции" + +msgid "RADIO" +msgstr "РАДИО" + +msgid "Your Top Artists" +msgstr "Ваши топ-исполнители" + +msgid "Artists to Follow" +msgstr "Исполнители для подписки" + +msgid "Pinned" +msgstr "Закреплённые" + +msgid "Pin" +msgstr "Закрепить" + +msgid "Unpin" +msgstr "Открепить" + +msgid "Showing {} of {} track." +msgid_plural "Showing {} of {} tracks." +msgstr[0] "Показано {} из {} трека." +msgstr[1] "Показано {} из {} треков." +msgstr[2] "Показано {} из {} треков." + +msgid "Spotlight" +msgstr "В центре внимания" + +msgid "Shine a light on one of your favourite artists." +msgstr "Узнайте больше об одном из ваших любимых исполнителей." + +msgid "ARTIST" +msgstr "ИСПОЛНИТЕЛЬ" + +msgid "Top tracks" +msgstr "Топ-треки" + +msgid "By {}" +msgstr "От {}" + +msgid "TOP TRACKS" +msgstr "ТОП-ТРЕКИ" + +msgid "Qobuz Radio Station" +msgstr "Радиостанция Qobuz" + +msgid "Available offline" +msgstr "Доступно офлайн" + +msgid "Tracks" +msgstr "Треки" + +msgid "Albums" +msgstr "Альбомы" + +msgid "Artists" +msgstr "Исполнители" + +msgid "Playlists" +msgstr "Плейлисты" + +msgid "Labels" +msgstr "Лейблы" + +msgid "Favorite" +msgstr "Избранное" + +msgid "Library" +msgstr "Библиотека" + +msgid "Search" +msgstr "Поиск" + +msgid "Group: Off" +msgstr "Группировка: выкл." + +msgid "Group: Album" +msgstr "Группировка: альбом" + +msgid "Group: Artist" +msgstr "Группировка: исполнитель" + +msgid "Group: Name" +msgstr "Группировка: название" + +msgid "Group: A-Z" +msgstr "Группировка: А–Я" + +msgid "Title A-Z" +msgstr "Название А–Я" + +msgid "Title Z-A" +msgstr "Название Я–А" + +msgid "Artist A-Z" +msgstr "Исполнитель А–Я" + +msgid "Couldn't load your Library." +msgstr "Не удалось загрузить библиотеку." + +msgid "Remove from Library" +msgstr "Удалить из библиотеки" + +msgid "No tracks in your Library yet." +msgstr "В библиотеке пока нет треков." + +msgid "No tracks match your search." +msgstr "Нет треков по вашему запросу." + +msgid "No albums in your Library yet." +msgstr "В библиотеке пока нет альбомов." + +msgid "No albums match your search." +msgstr "Нет альбомов по вашему запросу." + +msgid "No artists in your Library yet." +msgstr "В библиотеке пока нет исполнителей." + +msgid "No artists match your search." +msgstr "Нет исполнителей по вашему запросу." + +msgid "No playlists match your search." +msgstr "Нет плейлистов по вашему запросу." + +msgid "No followed playlists yet." +msgstr "Пока нет отслеживаемых плейлистов." + +msgid "No playlists in your Library yet." +msgstr "В библиотеке пока нет плейлистов." + +msgid "No labels in your Library yet." +msgstr "В библиотеке пока нет лейблов." + +msgid "No labels match your search." +msgstr "Нет лейблов по вашему запросу." + +msgid "Select an artist to see their albums." +msgstr "Выберите исполнителя, чтобы увидеть его альбомы." + +msgid "Couldn't load albums." +msgstr "Не удалось загрузить альбомы." + +msgid "No albums found." +msgstr "Альбомы не найдены." + +msgid "Go to artist page" +msgstr "Перейти на страницу исполнителя" + +msgid "albums" +msgstr "альбомы" + +msgid "Releases" +msgstr "Релизы" + +msgid "Search this label…" +msgstr "Поиск по лейблу…" + +msgid "No results" +msgstr "Нет результатов" + +msgid "More labels" +msgstr "Больше лейблов" + +msgid "About" +msgstr "Описание" + +msgid "Folders" +msgstr "Папки" + +msgid "tracks" +msgstr "треки" + +msgid "Play all" +msgstr "Воспроизвести все" + +msgid "Clear session" +msgstr "Очистить сессию" + +msgid "No playable tracks in this folder." +msgstr "В этой папке нет доступных треков." + +msgid "Play this album" +msgstr "Воспроизвести этот альбом" + +msgid "Local Library" +msgstr "Локальная библиотека" + +msgid "Artist Z-A" +msgstr "Исполнитель Я–А" + +msgid "Year (newest)" +msgstr "Год (сначала новые)" + +msgid "Year (oldest)" +msgstr "Год (сначала старые)" + +msgid "No grouping" +msgstr "Без группировки" + +msgid "Group by letter" +msgstr "Группировать по букве" + +msgid "Group by artist" +msgstr "Группировать по исполнителю" + +msgid "By album" +msgstr "По альбому" + +msgid "By artist" +msgstr "По исполнителю" + +msgid "By name" +msgstr "По названию" + +msgid "Search artists" +msgstr "Поиск исполнителей" + +msgid "Couldn't load your local albums." +msgstr "Не удалось загрузить локальные альбомы." + +msgid "No albums in your local library yet." +msgstr "В локальной библиотеке пока нет альбомов." + +msgid "Couldn't load your local tracks." +msgstr "Не удалось загрузить локальные треки." + +msgid "No tracks in your local library yet." +msgstr "В локальной библиотеке пока нет треков." + +msgid "Add to Mixtape/Collection" +msgstr "Добавить в микстейп/коллекцию" + +msgid "No folders in your local library yet." +msgstr "В локальной библиотеке пока нет папок." + +msgid "No folders match your search." +msgstr "Нет папок по вашему запросу." + +msgid "Search folders" +msgstr "Поиск папок" + +msgid "sel" +msgstr "выбр." + +msgid "Check all" +msgstr "Выбрать все" + +msgid "Select a folder to see its contents." +msgstr "Выберите папку, чтобы увидеть её содержимое." + +msgid "Search albums..." +msgstr "Поиск альбомов..." + +msgid "This folder has no tracks." +msgstr "В этой папке нет треков." + +msgid "No artists in your local library yet." +msgstr "В локальной библиотеке пока нет исполнителей." + +msgid "artists" +msgstr "исполнители" + +msgid "Select an artist to view their albums" +msgstr "Выберите исполнителя, чтобы посмотреть его альбомы" + +msgid "No albums found" +msgstr "Альбомы не найдены" + +msgid "Local Library port in progress" +msgstr "Перенос локальной библиотеки в процессе" + +msgid "This tab is coming next." +msgstr "Эта вкладка появится позже." + +msgid "Filter" +msgstr "Фильтр" + +msgid "Hi-Res" +msgstr "Hi-Res" + +msgid "CD" +msgstr "CD" + +msgid "Lossy" +msgstr "С потерями" + +msgid "Other" +msgstr "Другое" + +msgid "Local" +msgstr "Локально" + +msgid "Offline cache" +msgstr "Офлайн-кэш" + +msgid "Scene" +msgstr "Сцена" + +msgid "No artists found for this scene." +msgstr "Исполнители для этой сцены не найдены." + +msgid "QBZ" +msgstr "QBZ" + +msgid "QOBUZ™ PLAYER" +msgstr "ПЛЕЕР QOBUZ™" + +msgid "I have read the" +msgstr "Я прочитал(а)" + +msgid "Qobuz™ Terms of Service" +msgstr "Условия использования Qobuz™" + +msgid "Sign in via Browser" +msgstr "Войти через браузер" + +msgid "Use your system browser instead" +msgstr "Использовать системный браузер" + +msgid "A network sign-in page may be blocking the connection" +msgstr "Возможно, подключение блокирует сетевая страница входа" + +msgid "Could not restore your session" +msgstr "Не удалось восстановить сессию" + +msgid "Start offline (no access to Qobuz™ services)" +msgstr "Запустить в офлайне (без доступа к сервисам Qobuz™)" + +msgid "This application uses the Qobuz API but is not certified by Qobuz." +msgstr "Это приложение использует Qobuz API, но не сертифицировано Qobuz." + +msgid "No tracks for this mix." +msgstr "В этом миксе нет треков." + +msgid "Album" +msgstr "Альбом" + +msgid "Bands & Projects" +msgstr "Группы и проекты" + +msgid "No album appearances found." +msgstr "Участие в альбомах не найдено." + +msgid "You're offline" +msgstr "Вы офлайн" + +msgid "Offline mode is enabled. Disable it in Settings to use Qobuz." +msgstr "Включён офлайн-режим. Отключите его в настройках, чтобы пользоваться Qobuz." + +msgid "No internet connection. Your local library and downloads keep working." +msgstr "Нет подключения к интернету. Локальная библиотека и загрузки продолжают работать." + +msgid "Open Settings" +msgstr "Открыть настройки" + +msgid "Offline Cache" +msgstr "Офлайн-кэш" + +msgid "{} track" +msgid_plural "{} tracks" +msgstr[0] "{} трек" +msgstr[1] "{} трека" +msgstr[2] "{} треков" + +msgid "Sort: A-Z" +msgstr "Сортировка: А-Я" + +msgid "Sort: Recent" +msgstr "Сортировка: по дате" + +msgid "Sort: Largest" +msgstr "Сортировка: по убыванию размера" + +msgid "Sort: Smallest" +msgstr "Сортировка: по возрастанию размера" + +msgid "Failed only" +msgstr "Только с ошибками" + +msgid " selected" +msgstr " выбрано" + +msgid "Re-download" +msgstr "Скачать заново" + +msgid "Remove" +msgstr "Удалить" + +msgid "All artists" +msgstr "Все исполнители" + +msgid "No offline tracks yet" +msgstr "Пока нет офлайн-треков" + +msgid "Search playlists…" +msgstr "Поиск плейлистов…" + +msgid "1 playlist" +msgstr "1 плейлист" + +msgid "{} playlists" +msgstr "{} плейлистов" + +msgid "Move to folder" +msgstr "Переместить в папку" + +msgid "No folder" +msgstr "Без папки" + +msgid "Playlist manager" +msgstr "Управление плейлистами" + +msgid "Visible" +msgstr "Показан" + +msgid "Hidden" +msgstr "Скрыт" + +msgid "All" +msgstr "Все" + +msgid "Recent" +msgstr "Недавние" + +msgid "Play Count" +msgstr "Число прослушиваний" + +msgid "Track Count" +msgstr "Число треков" + +msgid "Name A-Z" +msgstr "Название А-Я" + +msgid "New folder" +msgstr "Новая папка" + +msgid "{} folders, {} playlists" +msgstr "{} папок, {} плейлистов" + +msgid "Use the arrows to reorder playlists" +msgstr "Используйте стрелки, чтобы изменить порядок плейлистов" + +msgid "Folders ({})" +msgstr "Папки ({})" + +msgid "Playlists ({})" +msgstr "Плейлисты ({})" + +msgid "No playlists found." +msgstr "Плейлисты не найдены." + +msgid "Artist" +msgstr "Исполнитель" + +msgid "Playlist" +msgstr "Плейлист" + +msgid "Search tracks" +msgstr "Поиск треков" + +msgid "Sort" +msgstr "Сортировка" + +msgid "This playlist is empty." +msgstr "Этот плейлист пуст." + +msgid "Upload to Qobuz?" +msgstr "Загрузить в Qobuz?" + +msgid "Cancel" +msgstr "Отмена" + +msgid "Upload" +msgstr "Загрузить" + +msgid "About this playlist" +msgstr "Об этом плейлисте" + +msgid "ITEM" +msgstr "ЭЛЕМЕНТ" + +msgid "TYPE" +msgstr "ТИП" + +msgid "SOURCE" +msgstr "ИСТОЧНИК" + +msgid "QUALITY" +msgstr "КАЧЕСТВО" + +msgid "TRACKS" +msgstr "ТРЕКИ" + +msgid "YEAR" +msgstr "ГОД" + +msgid "Folder name" +msgstr "Название папки" + +msgid "Creating..." +msgstr "Создание..." + +msgid "Create" +msgstr "Создать" + +msgid "Edit folder" +msgstr "Изменить папку" + +msgid "Enter folder name" +msgstr "Введите название папки" + +msgid "Icon" +msgstr "Значок" + +msgid "Hide from sidebar" +msgstr "Скрыть из боковой панели" + +msgid "Delete" +msgstr "Удалить" + +msgid "Save" +msgstr "Сохранить" + +msgid "JUMP TO" +msgstr "ПЕРЕЙТИ К" + +msgid "Search this page…" +msgstr "Поиск по странице…" + +msgid "Something is already playing" +msgstr "Что-то уже воспроизводится" + +msgid "Clear queue and play" +msgstr "Очистить очередь и воспроизвести" + +msgid "Edit metadata" +msgstr "Изменить метаданные" + +msgid "Album title" +msgstr "Название альбома" + +msgid "Album artist" +msgstr "Исполнитель альбома" + +msgid "Year" +msgstr "Год" + +msgid "Genre" +msgstr "Жанр" + +msgid "Catalog number" +msgstr "Каталожный номер" + +msgid "Total discs" +msgstr "Всего дисков" + +msgid "Searching…" +msgstr "Поиск…" + +msgid "No results, try a different search" +msgstr "Ничего не найдено, измените запрос" + +msgid "Apply" +msgstr "Применить" + +msgid "Album folder" +msgstr "Папка альбома" + +msgid "Sidecar file (recommended)" +msgstr "Отдельный файл (рекомендуется)" + +msgid "Write to audio files" +msgstr "Записать в аудиофайлы" + +msgid "This writes tags directly into your audio files." +msgstr "Это запишет теги прямо в ваши аудиофайлы." + +msgid "Direct write is unavailable for CUE-based albums." +msgstr "Прямая запись недоступна для альбомов на основе CUE." + +msgid "Writing" +msgstr "Запись" + +msgid "Saving…" +msgstr "Сохранение…" + +msgid "Edit playlist" +msgstr "Изменить плейлист" + +msgid "Playlist name" +msgstr "Название плейлиста" + +msgid "Description" +msgstr "Описание" + +msgid "Offline-only playlist" +msgstr "Плейлист только для офлайна" + +msgid "New playlist" +msgstr "Новый плейлист" + +msgid "Name" +msgstr "Название" + +msgid "My Playlist" +msgstr "Мой плейлист" + +msgid "Description (optional)" +msgstr "Описание (необязательно)" + +msgid "Folder" +msgstr "Папка" + +msgid "Make playlist public" +msgstr "Сделать плейлист публичным" + +msgid "Created locally — you're offline" +msgstr "Создан локально — вы офлайн" + +msgid "Add to mixtape" +msgstr "Добавить в микстейп" + +msgid "Share Qobuz link" +msgstr "Поделиться ссылкой Qobuz" + +msgid "Share Album.link" +msgstr "Поделиться ссылкой Album.link" + +msgid "Refresh offline copy" +msgstr "Обновить офлайн-копию" + +msgid "Some of these tracks were already added" +msgstr "Некоторые из этих треков уже добавлены" + +msgid "Adding..." +msgstr "Добавление..." + +msgid "Add all tracks" +msgstr "Добавить все треки" + +msgid "Add only new tracks" +msgstr "Добавить только новые треки" + +msgid "Add to Playlist" +msgstr "Добавить в плейлист" + +msgid "Choose playlist" +msgstr "Выберите плейлист" + +msgid "Adding {} local track(s)" +msgstr "Добавление {} локальных треков" + +msgid "Adding {} track(s)" +msgstr "Добавление {} треков" + +msgid "+ Create new playlist" +msgstr "+ Создать новый плейлист" + +msgid "Search playlists..." +msgstr "Поиск плейлистов..." + +msgid "Playlist Name" +msgstr "Название плейлиста" + +msgid "Saving..." +msgstr "Сохранение..." + +msgid "Create & Add" +msgstr "Создать и добавить" + +msgid "Add" +msgstr "Добавить" + +msgid "No playlists found" +msgstr "Плейлисты не найдены" + +msgid "{} more playlist(s)..." +msgstr "Ещё {} плейлистов..." + +msgid "Import Playlist" +msgstr "Импорт плейлиста" + +msgid "This feature requires internet connection" +msgstr "Для этой функции нужно подключение к интернету" + +msgid "Playlist URL" +msgstr "URL плейлиста" + +msgid "ALLOWED SOURCES" +msgstr "ДОПУСТИМЫЕ ИСТОЧНИКИ" + +msgid "Conversion progress" +msgstr "Ход преобразования" + +msgid "Summary" +msgstr "Сводка" + +msgid "Close" +msgstr "Закрыть" + +msgid "Importing..." +msgstr "Импорт..." + +msgid "Import" +msgstr "Импортировать" + +msgid "Fetching..." +msgstr "Загрузка..." + +msgid "Fetch playlist" +msgstr "Получить плейлист" + +msgid "Shuffle" +msgstr "Перемешать" + +msgid "16-bit / 44.1 kHz — George Harrison · My Sweet Lord" +msgstr "16 бит / 44.1 кГц — George Harrison · My Sweet Lord" + +msgid "24-bit / 44.1 kHz — Billie Eilish · LUNCH" +msgstr "24 бит / 44.1 кГц — Billie Eilish · LUNCH" + +msgid "24-bit / 96 kHz — Iron Maiden · Stratego" +msgstr "24 бит / 96 кГц — Iron Maiden · Stratego" + +msgid "24-bit / 192 kHz — Toto · Africa" +msgstr "24 бит / 192 кГц — Toto · Africa" + +msgid "Welcome" +msgstr "Добро пожаловать" + +msgid "Check" +msgstr "Проверка" + +msgid "DACs" +msgstr "DAC" + +msgid "Configure" +msgstr "Настройка" + +msgid "Test" +msgstr "Проверка" + +msgid "Done" +msgstr "Готово" + +msgid "HiFi Wizard — DAC Setup" +msgstr "Мастер HiFi — настройка DAC" + +msgid "Guided DAC Setup" +msgstr "Пошаговая настройка DAC" + +msgid "What to test with" +msgstr "На чём тестировать" + +msgid "I understand and accept these terms" +msgstr "Я понимаю и принимаю эти условия" + +msgid "Check Audio Stack" +msgstr "Проверить аудиостек" + +msgid "Sandboxed — host check unavailable" +msgstr "В песочнице — проверка хоста недоступна" + +msgid "Audio stack ready" +msgstr "Аудиостек готов" + +msgid "Some packages are missing" +msgstr "Некоторые пакеты отсутствуют" + +msgid "Your distribution" +msgstr "Ваш дистрибутив" + +msgid "Init system (service commands)" +msgstr "Система инициализации (команды служб)" + +msgid "Which DACs do you want to configure?" +msgstr "Какие DAC вы хотите настроить?" + +msgid "Detecting your DACs…" +msgstr "Поиск ваших DAC…" + +msgid "default" +msgstr "по умолчанию" + +msgid "Hide manual entry" +msgstr "Скрыть ручной ввод" + +msgid "Can't see your DAC?" +msgstr "Не видите свой DAC?" + +msgid "Find your DAC's PipeWire node name and paste it here:" +msgstr "Найдите имя узла PipeWire вашего DAC и вставьте его сюда:" + +msgid "1. Find your DAC under 'Audio > Sinks' and note its number" +msgstr "1. Найдите свой DAC в разделе «Audio > Sinks» и запишите его номер" + +msgid "2. Run with that number to read the node.name" +msgstr "2. Запустите с этим номером, чтобы прочитать node.name" + +msgid "Valid node name" +msgstr "Допустимое имя узла" + +msgid "Invalid — should contain alsa_output or alsa_input" +msgstr "Недопустимо — должно содержать alsa_output или alsa_input" + +msgid "Review & Apply" +msgstr "Проверить и применить" + +msgid "QBZ never writes these files" +msgstr "QBZ никогда не записывает эти файлы" + +msgid "1. Back up your current PipeWire/WirePlumber config" +msgstr "1. Сделайте резервную копию текущей конфигурации PipeWire/WirePlumber" + +msgid "I created a backup" +msgstr "Я создал(а) резервную копию" + +msgid "Sample-rate switching (PipeWire)" +msgstr "Переключение частоты дискретизации (PipeWire)" + +msgid "Per-app bit-perfect (PipeWire client)" +msgstr "Бит-перфект для приложения (клиент PipeWire)" + +msgid "Pin the DAC + rates (WirePlumber)" +msgstr "Закрепить DAC и частоты (WirePlumber)" + +msgid "I created the configuration files" +msgstr "Я создал(а) файлы конфигурации" + +msgid "3. Restart the audio services to load the config" +msgstr "3. Перезапустите аудиослужбы, чтобы загрузить конфигурацию" + +msgid "I restarted the audio services" +msgstr "Я перезапустил(а) аудиослужбы" + +msgid "Test Playback" +msgstr "Проверить воспроизведение" + +msgid "Stop" +msgstr "Остановить" + +msgid "Play test" +msgstr "Запустить тест" + +msgid "Use my current queue" +msgstr "Использовать текущую очередь" + +msgid "All set" +msgstr "Всё готово" + +msgid "Config files you can create:" +msgstr "Файлы конфигурации, которые можно создать:" + +msgid "Back" +msgstr "Назад" + +msgid "Start" +msgstr "Начать" + +msgid "Finish" +msgstr "Завершить" + +msgid "Next" +msgstr "Далее" + +msgid "Play now" +msgstr "Воспроизвести сейчас" + +msgid "Start playing now?" +msgstr "Начать воспроизведение сейчас?" + +msgid "Not now" +msgstr "Не сейчас" + +msgid "Show location" +msgstr "Показать расположение" + +msgid "Remove from Local Library" +msgstr "Удалить из локальной библиотеки" + +msgid "Forget download record" +msgstr "Забыть запись о загрузке" + +msgid "Could not open that folder." +msgstr "Не удалось открыть эту папку." + +msgid "Could not remove that library folder." +msgstr "Не удалось удалить эту папку из библиотеки." + +msgid "Could not forget that download." +msgstr "Не удалось забыть эту загрузку." + +msgid "Create radio" +msgstr "Создать радио" + +msgid "Add to library" +msgstr "Добавить в библиотеку" + +msgid "Remove from playlist" +msgstr "Удалить из плейлиста" + +msgid "Share Song.link" +msgstr "Поделиться ссылкой Song.link" + +msgid "Remove offline copy" +msgstr "Удалить офлайн-копию" + +msgid "Go to album" +msgstr "Перейти к альбому" + +msgid "Go to artist" +msgstr "Перейти к исполнителю" + +msgid "Track info" +msgstr "О треке" + +msgid "selected" +msgstr "выбрано" + +msgid "Artist is blacklisted" +msgstr "Исполнитель в чёрном списке" + +msgid "Main Artist" +msgstr "Основной исполнитель" + +msgid "Performer" +msgstr "Исполнитель" + +msgid "Composer" +msgstr "Композитор" + +msgid "Release Name" +msgstr "Название релиза" + +msgid "Most popular" +msgstr "Самые популярные" + +msgid "No results." +msgstr "Ничего не найдено." + +msgid "No results for" +msgstr "Ничего не найдено по запросу" + +msgid "Top result" +msgstr "Лучший результат" + +msgid "View more" +msgstr "Показать ещё" + +msgid "QOBUZ CONNECT" +msgstr "QOBUZ CONNECT" + +msgid "Connect diagnostics" +msgstr "Диагностика Connect" + +msgid "Open diagnostics" +msgstr "Открыть диагностику" + +msgid "PLAYBACK" +msgstr "ВОСПРОИЗВЕДЕНИЕ" + +msgid "Continue playback after track ends" +msgstr "Продолжать воспроизведение после окончания трека" + +msgid "Keep playing the rest of the album or playlist instead of stopping." +msgstr "Продолжать воспроизводить остаток альбома или плейлиста, а не останавливаться." + +msgid "Show track playing context" +msgstr "Показывать контекст воспроизведения трека" + +msgid "Display the context-stack icon in the player." +msgstr "Показывать значок контекстного стека в плеере." + +msgid "Gapless playback" +msgstr "Воспроизведение без пауз" + +msgid "Play consecutive same-format tracks without a gap." +msgstr "Воспроизводить подряд идущие треки одного формата без паузы." + +msgid "SESSION" +msgstr "СЕССИЯ" + +msgid "Restore session on startup" +msgstr "Восстанавливать сессию при запуске" + +msgid "Restore the queue and current track on the next launch." +msgstr "Восстанавливать очередь и текущий трек при следующем запуске." + +msgid "Resume playback position" +msgstr "Восстанавливать позицию воспроизведения" + +msgid "Also seek back to where the saved track left off." +msgstr "Также перематывать к месту, на котором остановился сохранённый трек." + +msgid "STREAMING" +msgstr "СТРИМИНГ" + +msgid "Stream uncached tracks" +msgstr "Стримить некэшированные треки" + +msgid "Initial buffer size" +msgstr "Начальный размер буфера" + +msgid "Seconds of audio buffered before streaming playback starts." +msgstr "Секунды аудио в буфере перед началом стримингового воспроизведения." + +msgid "s" +msgstr "с" + +msgid "Streaming only" +msgstr "Только стриминг" + +msgid "Skip writing tracks to the local cache while streaming." +msgstr "Не записывать треки в локальный кэш во время стриминга." + +msgid "When quality retries fail" +msgstr "Когда повторы по качеству не удаются" + +msgid "What to do when every quality tier for a track is unavailable." +msgstr "Что делать, когда недоступен ни один уровень качества трека." + +msgid "Folder settings" +msgstr "Настройки папки" + +msgid "Folder settings saved" +msgstr "Настройки папки сохранены" + +msgid "Could not save the folder settings." +msgstr "Не удалось сохранить настройки папки." + +msgid "Folder location" +msgstr "Расположение папки" + +msgid "Change" +msgstr "Изменить" + +msgid "Checking..." +msgstr "Проверка..." + +msgid "Folder is accessible" +msgstr "Папка доступна" + +msgid "Folder is not accessible" +msgstr "Папка недоступна" + +msgid "Display name" +msgstr "Отображаемое имя" + +msgid "Optional friendly name" +msgstr "Необязательное удобное имя" + +msgid "Shown instead of the full path in the folder list." +msgstr "Показывается вместо полного пути в списке папок." + +msgid "Enabled" +msgstr "Включено" + +msgid "Disabled folders are skipped during scans." +msgstr "Отключённые папки пропускаются при сканировании." + +msgid "Network folder" +msgstr "Сетевая папка" + +msgid "Mark this folder as a network share (excluded when offline)." +msgstr "Отметить эту папку как сетевой ресурс (исключается в офлайне)." + +msgid "Network filesystem type" +msgstr "Тип сетевой файловой системы" + +msgid "Auto-detect" +msgstr "Автоопределение" + +msgid "Last scanned" +msgstr "Последнее сканирование" + +msgid "Scan this folder" +msgstr "Сканировать эту папку" + +msgid "FOLDER" +msgstr "ПАПКА" + +msgid "LAST SCAN" +msgstr "ПОСЛЕДНЕЕ СКАНИРОВАНИЕ" + +msgid "STATUS" +msgstr "СТАТУС" + +msgid "Unavailable" +msgstr "Недоступно" + +msgid "Active" +msgstr "Активно" + +msgid "LIBRARY FOLDERS" +msgstr "ПАПКИ БИБЛИОТЕКИ" + +msgid "Browse..." +msgstr "Обзор..." + +msgid "Choose a music folder" +msgstr "Выберите папку с музыкой" + +msgid "Browse for a music folder, or type the full path." +msgstr "Найдите папку с музыкой или введите полный путь." + +msgid "Filter folders..." +msgstr "Фильтр папок..." + +msgid "No folders yet. Add a folder to build your local library." +msgstr "Папок пока нет. Добавьте папку, чтобы создать локальную библиотеку." + +msgid "No folders match your filter." +msgstr "Нет папок, соответствующих фильтру." + +msgid "Scanning" +msgstr "Сканирование" + +msgid "MAINTENANCE" +msgstr "ОБСЛУЖИВАНИЕ" + +msgid "Cleanup missing files" +msgstr "Очистить отсутствующие файлы" + +msgid "Remove tracks whose files no longer exist on disk." +msgstr "Удалить треки, файлы которых больше не существуют на диске." + +msgid "Cleaning up..." +msgstr "Очистка..." + +msgid "Cleanup" +msgstr "Очистить" + +msgid "DANGER ZONE" +msgstr "ОПАСНАЯ ЗОНА" + +msgid "Clear library database" +msgstr "Очистить базу данных библиотеки" + +msgid "Remove all indexed tracks. Your audio files are not deleted." +msgstr "Удалить все проиндексированные треки. Ваши аудиофайлы не будут удалены." + +msgid "Clearing..." +msgstr "Очистка..." + +msgid "Clear all" +msgstr "Очистить всё" + +msgid "PLEX" +msgstr "PLEX" + +msgid "Connect a Plex Media Server on your local network." +msgstr "Подключите Plex Media Server в локальной сети." + +msgid "Server address" +msgstr "Адрес сервера" + +msgid "Only local network servers are supported." +msgstr "Поддерживаются только серверы в локальной сети." + +msgid "Enter a valid server address." +msgstr "Введите корректный адрес сервера." + +msgid "http://127.0.0.1:32400" +msgstr "http://127.0.0.1:32400" + +msgid "Authorize" +msgstr "Авторизовать" + +msgid "Generate a code and sign in to Plex in your browser." +msgstr "Сгенерируйте код и войдите в Plex в браузере." + +msgid "Working..." +msgstr "Обработка..." + +msgid "Generate code" +msgstr "Сгенерировать код" + +msgid "Link code" +msgstr "Код привязки" + +msgid "Enter this code at the Plex sign-in page." +msgstr "Введите этот код на странице входа Plex." + +msgid "Copy code" +msgstr "Скопировать код" + +msgid "Open Plex sign-in" +msgstr "Открыть вход Plex" + +msgid "Connected to Plex" +msgstr "Подключено к Plex" + +msgid "The code expired. Generate a new one." +msgstr "Срок действия кода истёк. Создайте новый." + +msgid "Ping the saved server and report what answers." +msgstr "Отправить пинг сохранённому серверу и показать, что ответило." + +msgid "Connected to {}" +msgstr "Подключено к {}" + +msgid "Could not reach the Plex server." +msgstr "Не удалось связаться с сервером Plex." + +msgid "Plex is not configured." +msgstr "Plex не настроен." + +msgid "Enter token manually" +msgstr "Ввести токен вручную" + +msgid "Use an existing X-Plex-Token instead of the PIN flow." +msgstr "Использовать существующий X-Plex-Token вместо PIN-процесса." + +msgid "Token" +msgstr "Токен" + +msgid "X-Plex-Token" +msgstr "X-Plex-Token" + +msgid "Check connection" +msgstr "Проверить соединение" + +msgid "Verify the server is reachable." +msgstr "Проверьте доступность сервера." + +msgid "Libraries" +msgstr "Библиотеки" + +msgid "Fetch your Plex music libraries." +msgstr "Получить ваши музыкальные библиотеки Plex." + +msgid "Get libraries" +msgstr "Получить библиотеки" + +msgid "MUSIC LIBRARIES" +msgstr "МУЗЫКАЛЬНЫЕ БИБЛИОТЕКИ" + +msgid "({} tracks)" +msgstr "({} треков)" + +msgid "Write metadata to Plex (experimental)" +msgstr "Записывать метаданные в Plex (экспериментально)" + +msgid "Allow QBZ to write track metadata back to your Plex server." +msgstr "Разрешить QBZ записывать метаданные треков обратно на сервер Plex." + +msgid "PLEX DANGER ZONE" +msgstr "ОПАСНАЯ ЗОНА PLEX" + +msgid "Disconnect" +msgstr "Отключить" + +msgid "Sign out of Plex and clear the local cache." +msgstr "Выйти из Plex и очистить локальный кэш." + +msgid "Clear cache" +msgstr "Очистить кэш" + +msgid "Remove cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Удалить кэшированные библиотеки и треки Plex. Вход сохраняется." + +msgid "OFFLINE MODE" +msgstr "ОФЛАЙН-РЕЖИМ" + +msgid "Status" +msgstr "Статус" + +msgid "Captive portal detected — sign in to the network to get online." +msgstr "Обнаружен кэптив-портал — войдите в сеть, чтобы выйти в интернет." + +msgid "Offline mode enabled" +msgstr "Офлайн-режим включён" + +msgid "No connection" +msgstr "Нет соединения" + +msgid "Online" +msgstr "В сети" + +msgid "Check now" +msgstr "Проверить сейчас" + +msgid "Enable Offline Mode" +msgstr "Включить офлайн-режим" + +msgid "Test the connection now instead of waiting for the next automatic check." +msgstr "Проверить соединение сейчас, не дожидаясь следующей автоматической проверки." + +msgid "Include sign-in credentials" +msgstr "Включить учётные данные для входа" + +msgid "The bundle will contain your tokens. Anyone who opens the file can sign in as you." +msgstr "Пакет будет содержать ваши токены. Любой, кто откроет файл, сможет войти как вы." + +msgid "Manually switch to offline mode even with internet." +msgstr "Вручную переключиться в офлайн-режим даже при наличии интернета." + +msgid "OFFLINE CACHE" +msgstr "ОФЛАЙН-КЭШ" + +msgid "Manage offline cache" +msgstr "Управление офлайн-кэшем" + +msgid "Browse and manage your downloaded tracks and albums." +msgstr "Просматривайте и управляйте скачанными треками и альбомами." + +msgid "Open manager" +msgstr "Открыть менеджер" + +msgid "Cache folder" +msgstr "Папка кэша" + +msgid "Open the folder where offline tracks are stored on disk." +msgstr "Открыть папку, где офлайн-треки хранятся на диске." + +msgid "Open folder" +msgstr "Открыть папку" + +msgid "Clear offline cache" +msgstr "Очистить офлайн-кэш" + +msgid "Remove every downloaded track. This cannot be undone." +msgstr "Удалить все скачанные треки. Это нельзя отменить." + +msgid "LYRICS" +msgstr "ТЕКСТ ПЕСНИ" + +msgid "Clear lyrics cache" +msgstr "Очистить кэш текстов песен" + +msgid "{} entries using {}" +msgstr "{} записей, занято {}" + +msgid "Manage" +msgstr "Управление" + +msgid "Artist Blacklist" +msgstr "Чёрный список исполнителей" + +msgid "{} artist(s) blocked" +msgstr "Заблокировано исполнителей: {}" + +msgid "{} artist(s) blocked (disabled)" +msgstr "Заблокировано исполнителей: {} (отключено)" + +msgid "Settings" +msgstr "Настройки" + +msgid "Audio" +msgstr "Аудио" + +msgid "Playback" +msgstr "Воспроизведение" + +msgid "Appearance" +msgstr "Внешний вид" + +msgid "Offline" +msgstr "Офлайн" + +msgid "Blacklist" +msgstr "Чёрный список" + +msgid "Integrations" +msgstr "Интеграции" + +msgid "Developer" +msgstr "Разработчик" + +msgid "Loading settings..." +msgstr "Загрузка настроек..." + +msgid "Added {}" +msgstr "Добавлено {}" + +msgid "Clear All" +msgstr "Очистить всё" + +msgid "{} artists" +msgstr "{} исполнителей" + +msgid "Loading blacklist…" +msgstr "Загрузка чёрного списка…" + +msgid "No blacklisted artists" +msgstr "Нет заблокированных исполнителей" + +msgid "To hide an artist, go to their page and use the menu." +msgstr "Чтобы скрыть исполнителя, перейдите на его страницу и используйте меню." + +msgid "No results for \"{}\"" +msgstr "Нет результатов для «{}»" + +msgid "Clear Blacklist?" +msgstr "Очистить чёрный список?" + +msgid "This will remove all {} blacklisted artists. This cannot be undone." +msgstr "Это удалит все {} заблокированных исполнителей. Это нельзя отменить." + +msgid "SCROBBLERS" +msgstr "СКРОББЛЕРЫ" + +msgid "LAST.FM" +msgstr "LAST.FM" + +msgid "Scrobble to Last.fm" +msgstr "Скробблить в Last.fm" + +msgid "Signed in as {}." +msgstr "Выполнен вход как {}." + +msgid "Connect your Last.fm account to enable scrobbling." +msgstr "Подключите аккаунт Last.fm, чтобы включить скробблинг." + +msgid "Connect" +msgstr "Подключить" + +msgid "Authorize QBZ in your browser, then click Finish." +msgstr "Авторизуйте QBZ в браузере, затем нажмите «Готово»." + +msgid "Connect Last.fm" +msgstr "Подключить Last.fm" + +msgid "Open authorize page" +msgstr "Открыть страницу авторизации" + +msgid "Disconnect Last.fm" +msgstr "Отключить Last.fm" + +msgid "Sign out of Last.fm." +msgstr "Выйти из Last.fm." + +msgid "LISTENBRAINZ" +msgstr "LISTENBRAINZ" + +msgid "Scrobble to ListenBrainz" +msgstr "Скробблить в ListenBrainz" + +msgid "Paste your ListenBrainz user token to enable scrobbling." +msgstr "Вставьте пользовательский токен ListenBrainz, чтобы включить скробблинг." + +msgid "User token" +msgstr "Пользовательский токен" + +msgid "From listenbrainz.org/settings." +msgstr "Из listenbrainz.org/settings." + +msgid "ListenBrainz token" +msgstr "Токен ListenBrainz" + +msgid "Save token" +msgstr "Сохранить токен" + +msgid "Validates the token against ListenBrainz." +msgstr "Проверяет токен через ListenBrainz." + +msgid "Validating..." +msgstr "Проверка..." + +msgid "Connect ListenBrainz" +msgstr "Подключить ListenBrainz" + +msgid "Disconnect ListenBrainz" +msgstr "Отключить ListenBrainz" + +msgid "Sign out of ListenBrainz." +msgstr "Выйти из ListenBrainz." + +msgid "DISCORD" +msgstr "DISCORD" + +msgid "Discord Rich Presence" +msgstr "Discord Rich Presence" + +msgid "Flatpak socket access" +msgstr "Доступ к сокету Flatpak" + +msgid "Streaming quality" +msgstr "Качество потока" + +msgid "The quality tier QBZ requests for playback." +msgstr "Уровень качества, который QBZ запрашивает для воспроизведения." + +msgid "Limit quality to device" +msgstr "Ограничить качество устройством" + +msgid "Cap streaming quality at the output device's maximum sample rate." +msgstr "Ограничить качество потока максимальной частотой дискретизации устройства вывода." + +msgid "Maximum sample rate" +msgstr "Максимальная частота дискретизации" + +msgid "The highest sample rate streamed tracks may use." +msgstr "Максимальная частота дискретизации для потоковых треков." + +msgid "OUTPUT" +msgstr "ВЫВОД" + +msgid "Audio backend" +msgstr "Аудиобэкенд" + +msgid "The audio stack QBZ routes playback through." +msgstr "Аудиостек, через который QBZ направляет воспроизведение." + +msgid "Output device" +msgstr "Устройство вывода" + +msgid "The DAC or sound device that receives audio." +msgstr "DAC или звуковое устройство, принимающее аудио." + +msgid "ALSA plugin" +msgstr "Плагин ALSA" + +msgid "How ALSA opens the device — hw is bit-perfect, plughw converts." +msgstr "Как ALSA открывает устройство — hw обеспечивает бит-перфект, plughw конвертирует." + +msgid "Hardware volume control" +msgstr "Аппаратное управление громкостью" + +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Управляет громкостью через микшер выбранного устройства ALSA. Если он недоступен, громкость прямого воспроизведения остаётся на уровне 100 %." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "У этого устройства ALSA нет совместимого аппаратного регулятора громкости. Громкость прямого воспроизведения остаётся на уровне 100 %." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ не удалось полностью освободить предыдущее аудиоустройство. Подробности смотрите в журнале аудио." + +msgid "BIT-PERFECT" +msgstr "БИТ-ПЕРФЕКТ" + +msgid "Exclusive mode" +msgstr "Эксклюзивный режим" + +msgid "Lock the device so no other app can resample it." +msgstr "Заблокировать устройство, чтобы другие приложения не могли его ресемплировать." + +msgid "Reserve DAC while running" +msgstr "Резервировать DAC во время работы" + +msgid "Hold the device reserved so other apps can't grab it." +msgstr "Удерживать устройство зарезервированным, чтобы другие приложения не могли его занять." + +msgid "DAC passthrough" +msgstr "Сквозной режим DAC" + +msgid "Send the bitstream untouched to the DAC." +msgstr "Отправлять битстрим на DAC без изменений." + +msgid "Force bit-perfect" +msgstr "Принудительный бит-перфект" + +msgid "Pin the PipeWire quantum and rate for the active track." +msgstr "Зафиксировать квант и частоту PipeWire для активного трека." + +msgid "Allow quality fallback" +msgstr "Разрешить понижение качества" + +msgid "Drop to a lower tier when the requested one is unavailable." +msgstr "Понижать до меньшего уровня, когда запрошенный недоступен." + +msgid "HiFi Wizard" +msgstr "Мастер HiFi" + +msgid "Auto-detect your DACs and set up bit-perfect playback, step by step." +msgstr "Автоопределение ваших DAC и пошаговая настройка бит-перфект воспроизведения." + +msgid "Open Wizard" +msgstr "Открыть мастер" + +msgid "STARTUP" +msgstr "ЗАПУСК" + +msgid "Sync audio settings on startup" +msgstr "Синхронизировать настройки аудио при запуске" + +msgid "Reload saved audio settings into the player when QBZ launches." +msgstr "Загружать сохранённые настройки аудио в плеер при запуске QBZ." + +msgid "Lock output device" +msgstr "Заблокировать устройство вывода" + +msgid "Keep external routing intact — skip switching the default sink." +msgstr "Сохранить внешнюю маршрутизацию — не переключать устройство вывода по умолчанию." + +msgid "THEME" +msgstr "ТЕМА" + +msgid "Theme" +msgstr "Тема" + +msgid "Album header gradient" +msgstr "Градиент заголовка альбома" + +msgid "Intelligent Search" +msgstr "Умный поиск" + +msgid "Generate a color theme from your system wallpaper or a custom image" +msgstr "Создать цветовую тему из системных обоев или своего изображения" + +msgid "Select Image..." +msgstr "Выбрать изображение..." + +msgid "Detected: " +msgstr "Обнаружено: " + +msgid "Experimental: theme may not match your system exactly." +msgstr "Экспериментально: тема может не точно совпадать с вашей системой." + +msgid "Regenerate" +msgstr "Создать заново" + +msgid "TYPOGRAPHY & LANGUAGE" +msgstr "ТИПОГРАФИКА И ЯЗЫК" + +msgid "Language" +msgstr "Язык" + +msgid "UI Scale" +msgstr "Масштаб интерфейса" + +msgid "Immersive search" +msgstr "Иммерсивный поиск" + +msgid "Immersive default view" +msgstr "Вид иммерсивного режима по умолчанию" + +msgid "LIBRARY & VISUALS" +msgstr "БИБЛИОТЕКА И ОФОРМЛЕНИЕ" + +msgid "Show navigation in sidebar" +msgstr "Показывать навигацию на боковой панели" + +msgid "My QBZ" +msgstr "Мой QBZ" + +msgid "Show playlist cover collages in sidebar" +msgstr "Показывать коллажи обложек плейлистов на боковой панели" + +msgid "Show album artwork in Local Library track list" +msgstr "Показывать обложки альбомов в списке треков локальной библиотеки" + +msgid "NOTIFICATIONS" +msgstr "УВЕДОМЛЕНИЯ" + +msgid "In-app toasts notifications" +msgstr "Всплывающие уведомления в приложении" + +msgid "System Notifications" +msgstr "Системные уведомления" + +msgid "WINDOW TITLE" +msgstr "ЗАГОЛОВОК ОКНА" + +msgid "Show track in window title" +msgstr "Показывать трек в заголовке окна" + +msgid "Title template" +msgstr "Шаблон заголовка" + +msgid "Placeholders: {{artist}}, {{track}}, {{album}}" +msgstr "Подстановки: {{artist}}, {{track}}, {{album}}" + +msgid "TITLE BAR" +msgstr "СТРОКА ЗАГОЛОВКА" + +msgid "Use system title bar" +msgstr "Использовать системную строку заголовка" + +msgid "Hide title bar" +msgstr "Скрыть строку заголовка" + +msgid "Hide the title bar completely (for tiling window manager users)" +msgstr "Полностью скрыть строку заголовка (для пользователей тайловых менеджеров окон)" + +msgid "WINDOW CONTROLS" +msgstr "ЭЛЕМЕНТЫ УПРАВЛЕНИЯ ОКНОМ" + +msgid "Window controls position" +msgstr "Положение элементов управления окном" + +msgid "Show window controls" +msgstr "Показывать элементы управления окном" + +msgid "PLAYER VIEWS" +msgstr "ВИДЫ ПЛЕЕРА" + +msgid "Show volume +/- buttons" +msgstr "Показывать кнопки громкости +/-" + +msgid "Mini player default view" +msgstr "Вид мини-плеера по умолчанию" + +msgid "Startup page" +msgstr "Стартовая страница" + +msgid "Choose which page to show when the app starts" +msgstr "Выберите страницу, отображаемую при запуске приложения" + +msgid "Show Purchases" +msgstr "Показывать покупки" + +msgid "MENU BAR" +msgstr "СТРОКА МЕНЮ" + +msgid "SYSTEM TRAY" +msgstr "СИСТЕМНЫЙ ТРЕЙ" + +msgid "Enable tray icon" +msgstr "Включить значок в трее" + +msgid "Show icon in system tray (requires restart)" +msgstr "Показывать значок в системном трее (требуется перезапуск)" + +msgid "Close to tray" +msgstr "Сворачивать в трей" + +msgid "Keep playing in the tray instead of quitting when you close the window" +msgstr "Продолжать воспроизведение в трее вместо выхода при закрытии окна" + +msgid "Hide Dock icon when closed to menu bar" +msgstr "Скрывать значок в Dock при сворачивании в строку меню" + +msgid "Tray icon variant" +msgstr "Вариант значка в трее" + +msgid "IMMERSIVE" +msgstr "ИММЕРСИВНЫЙ РЕЖИМ" + +msgid "Background effect" +msgstr "Эффект фона" + +msgid "Immersive Panels" +msgstr "Панели иммерсивного режима" + +msgid "Enable or disable individual immersive view panels" +msgstr "Включить или отключить отдельные панели иммерсивного режима" + +msgid "Animation Frame Rate" +msgstr "Частота кадров анимации" + +msgid "Per-animation FPS control for immersive mode" +msgstr "Управление FPS для каждой анимации в иммерсивном режиме" + +msgid "PLAYLISTS" +msgstr "ПЛЕЙЛИСТЫ" + +msgid "No playlists yet." +msgstr "Плейлистов пока нет." + +msgid "Manage playlists" +msgstr "Управление плейлистами" + +msgid "Remove from queue" +msgstr "Удалить из очереди" + +msgid "Stop after this" +msgstr "Остановить после этого" + +msgid "Remove all after" +msgstr "Удалить все после" + +msgid "History" +msgstr "История" + +msgid "NOW PLAYING" +msgstr "СЕЙЧАС ИГРАЕТ" + +msgid "UP NEXT ({} of {}, {} left)" +msgstr "ДАЛЕЕ ({} из {}, осталось {})" + +msgid "Page {} of {}" +msgstr "Страница {} из {}" + +msgid "Your queue is empty" +msgstr "Очередь пуста" + +msgid "Play an album or track to get started" +msgstr "Запустите альбом или трек, чтобы начать" + +msgid "No tracks match your search" +msgstr "Нет треков, соответствующих запросу" + +msgid "RECENTLY PLAYED" +msgstr "НЕДАВНО ПРОСЛУШАННОЕ" + +msgid "Nothing played yet" +msgstr "Пока ничего не воспроизводилось" + +msgid "Search queue" +msgstr "Поиск в очереди" + +msgid "Auto-follow" +msgstr "Автопрокрутка" + +msgid "Font" +msgstr "Шрифт" + +msgid "System" +msgstr "Системный" + +msgid "LINE Seed JP" +msgstr "LINE Seed JP" + +msgid "Montserrat" +msgstr "Montserrat" + +msgid "Noto Sans" +msgstr "Noto Sans" + +msgid "Source Sans 3" +msgstr "Source Sans 3" + +msgid "Size" +msgstr "Размер" + +msgid "S" +msgstr "S" + +msgid "M" +msgstr "M" + +msgid "L" +msgstr "L" + +msgid "XL" +msgstr "XL" + +msgid "Active color" +msgstr "Активный цвет" + +msgid "Uppercase" +msgstr "Прописные" + +msgid "Dimming" +msgstr "Затемнение" + +msgid "Soft" +msgstr "Слабое" + +msgid "Strong" +msgstr "Сильное" + +msgid "Copy lyrics" +msgstr "Скопировать текст песни" + +msgid "Reset" +msgstr "Сбросить" + +msgid "Previous" +msgstr "Назад" + +msgid "Pause" +msgstr "Пауза" + +msgid "Repeat" +msgstr "Повтор" + +msgid "Add to…" +msgstr "Добавить в…" + +msgid "Add album to collection" +msgstr "Добавить альбом в коллекцию" + +msgid "Remove album from collection" +msgstr "Удалить альбом из коллекции" + +msgid "Remove from library" +msgstr "Удалить из библиотеки" + +msgid "{} (not playable)" +msgstr "{} (нельзя воспроизвести)" + +msgid "Cast" +msgstr "Транслировать" + +msgid "Connected to" +msgstr "Подключено к" + +msgid "Streaming {} · {}" +msgstr "Потоковая передача {} · {}" + +msgid "No devices found" +msgstr "Устройства не найдены" + +msgid "Looking for Chromecast & DLNA on your network" +msgstr "Поиск Chromecast и DLNA в вашей сети" + +msgid "Display settings" +msgstr "Настройки отображения" + +msgid "Fetching lyrics..." +msgstr "Получение текста песни..." + +msgid "No lyrics available" +msgstr "Текст песни недоступен" + +msgid "Missing lyrics? You can contribute them to LRCLIB:" +msgstr "Нет текста песни? Вы можете добавить его в LRCLIB:" + +msgid "Lyrics unavailable offline" +msgstr "Текст песни недоступен офлайн" + +msgid "Lyrics source" +msgstr "Источник текста песни" + +msgid "Qobuz Connect" +msgstr "Qobuz Connect" + +msgid "Connected" +msgstr "Подключено" + +msgid "Disconnected" +msgstr "Отключено" + +msgid "Playing on: {}" +msgstr "Воспроизведение на: {}" + +msgid "Looking for devices…" +msgstr "Поиск устройств…" + +msgid "Make sure your renderer is on the same network" +msgstr "Убедитесь, что устройство в той же сети" + +msgid "Play here" +msgstr "Играть здесь" + +msgid "Audio settings" +msgstr "Настройки звука" + +msgid "Normalization" +msgstr "Нормализация" + +msgid "Level loudness across tracks" +msgstr "Выровнять громкость между треками" + +msgid "Gapless" +msgstr "Без пауз" + +msgid "Seamless track transitions" +msgstr "Плавные переходы между треками" + +msgid "Now Playing view" +msgstr "Вид «Сейчас играет»" + +msgid "New" +msgstr "Новый" + +msgid "Classic" +msgstr "Классический" + +msgid "Miniplayer" +msgstr "Мини-плеер" + +msgid "Immersive" +msgstr "Иммерсивный" + +msgid "Unmute" +msgstr "Включить звук" + +msgid "Mute" +msgstr "Без звука" + +msgid "Volume" +msgstr "Громкость" + +msgid "Qobuz Connect: On" +msgstr "Qobuz Connect: вкл." + +msgid "Qobuz Connect: Off" +msgstr "Qobuz Connect: выкл." + +msgid "Volume down" +msgstr "Тише" + +msgid "Volume up" +msgstr "Громче" + +msgid "Nothing playing" +msgstr "Ничего не играет" + +msgid "Go to where this is playing from" +msgstr "Перейти к источнику воспроизведения" + +msgid "Casting" +msgstr "Трансляция" + +msgid "Output mode" +msgstr "Режим вывода" + +msgid "Move to root" +msgstr "Переместить в корень" + +msgid "No folders yet." +msgstr "Папок пока нет." + +msgid "Discover" +msgstr "Обзор" + +msgid "Mixtapes" +msgstr "Микстейпы" + +msgid "Collections" +msgstr "Коллекции" + +msgid "Search playlists" +msgstr "Поиск плейлистов" + +msgid "Sort by" +msgstr "Сортировать" + +msgid "Name (A-Z)" +msgstr "Название (А-Я)" + +msgid "# of tracks" +msgstr "Кол-во треков" + +msgid "Refresh" +msgstr "Обновить" + +msgid "Logged out" +msgstr "Выход выполнен" + +msgid "Offline (manual)" +msgstr "Офлайн (вручную)" + +msgid "Offline (hard)" +msgstr "Офлайн (жёсткий)" + +msgid "You're signed out. Sign-in needs a connection." +msgstr "Вы вышли из аккаунта. Для входа нужно подключение." + +msgid "Connection available — sign back in to Qobuz." +msgstr "Подключение доступно — войдите в Qobuz снова." + +msgid "You're signed out — sign back in to Qobuz." +msgstr "Вы вышли из аккаунта — войдите в Qobuz снова." + +msgid "Sign in" +msgstr "Войти" + +msgid "Offline mode" +msgstr "Офлайн-режим" + +msgid "Enter" +msgstr "Войти" + +msgid "Guest" +msgstr "Гость" + +msgid "Not signed in" +msgstr "Вход не выполнен" + +msgid "Keyboard Shortcuts" +msgstr "Сочетания клавиш" + +msgid "Documentation" +msgstr "Документация" + +msgid "Report an Issue" +msgstr "Сообщить о проблеме" + +msgid "Log Out" +msgstr "Выйти" + +msgid "Create Mixtape" +msgstr "Создать микстейп" + +msgid "Create Collection" +msgstr "Создать коллекцию" + +msgid "NAME" +msgstr "НАЗВАНИЕ" + +msgid "KIND" +msgstr "ТИП" + +msgid "+ New Mixtape" +msgstr "+ Новый микстейп" + +msgid "+ New Collection" +msgstr "+ Новая коллекция" + +msgid "Rename" +msgstr "Переименовать" + +msgid "Edit description" +msgstr "Изменить описание" + +msgid "Delete \"{}\"? This cannot be undone." +msgstr "Удалить «{}»? Это действие необратимо." + +msgid "All ({})" +msgstr "Все ({})" + +msgid "Random queue" +msgstr "Случайная очередь" + +msgid "Number of songs" +msgstr "Количество треков" + +msgid "{} songs available" +msgstr "Доступно треков: {}" + +msgid "No mixtapes yet" +msgstr "Микстейпов пока нет" + +msgid "Position" +msgstr "Позиция" + +msgid "Items" +msgstr "Элементы" + +msgid "Recently updated" +msgstr "Недавно обновлённые" + +msgid "No results found" +msgstr "Ничего не найдено" + +msgid "No collections yet" +msgstr "Коллекций пока нет" + +msgid "All kinds" +msgstr "Все типы" + +msgid "Artist Collections" +msgstr "Коллекции исполнителей" + +msgid "Remove from collection" +msgstr "Удалить из коллекции" + +msgid "Not found" +msgstr "Не найдено" + +msgid "Upload cover" +msgstr "Загрузить обложку" + +msgid "Clear custom cover" +msgstr "Сбросить свою обложку" + +msgid "Album shuffle" +msgstr "Перемешать альбомы" + +msgid "In order" +msgstr "По порядку" + +msgid "Convert to Collection" +msgstr "Преобразовать в коллекцию" + +msgid "Convert to Mixtape" +msgstr "Преобразовать в микстейп" + +msgid "1 filter" +msgstr "1 фильтр" + +msgid "{} filters" +msgstr "Фильтров: {}" + +msgid "Type" +msgstr "Тип" + +msgid "All types" +msgstr "Все типы" + +msgid "Track" +msgstr "Трек" + +msgid "Qobuz" +msgstr "Qobuz" + +msgid "Plex" +msgstr "Plex" + +msgid "Item" +msgstr "Элемент" + +msgid "Already added" +msgstr "Уже добавлено" + +msgid "ADD TO MIXTAPE/COLLECTION" +msgstr "ДОБАВИТЬ В МИКСТЕЙП/КОЛЛЕКЦИЮ" + +msgid "No matches." +msgstr "Совпадений нет." + +msgid "No mixtapes or collections yet." +msgstr "Микстейпов и коллекций пока нет." + +msgid "EP" +msgstr "EP" + +msgid "Single" +msgstr "Сингл" + +msgid "Live" +msgstr "Концертный" + +msgid "Compilation" +msgstr "Сборник" + +msgid "Use auto-detected" +msgstr "Использовать автоопределение" + +msgid "alternate" +msgstr "альтернативный" + +msgid "compilation" +msgstr "сборник" + +msgid "BUILD ARTIST COLLECTION" +msgstr "СОБРАТЬ КОЛЛЕКЦИЮ ИСПОЛНИТЕЛЯ" + +msgid "ORDER BY" +msgstr "СОРТИРОВАТЬ ПО" + +msgid "Release date" +msgstr "Дата релиза" + +msgid "Manual" +msgstr "Вручную" + +msgid "Loading..." +msgstr "Загрузка..." + +msgid "ALBUM" +msgstr "АЛЬБОМ" + +msgid "{} of {} album selected" +msgid_plural "{} of {} albums selected" +msgstr[0] "Выбрано {} из {} альбома" +msgstr[1] "Выбрано {} из {} альбомов" +msgstr[2] "Выбрано {} из {} альбомов" + +msgid "Now Playing" +msgstr "Сейчас играет" + +msgid "No lyrics" +msgstr "Нет текста песни" + +msgid "No track selected" +msgstr "Трек не выбран" + +msgid "Loading suggestions..." +msgstr "Загрузка рекомендаций..." + +msgid "Failed to load suggestions" +msgstr "Не удалось загрузить рекомендации" + +msgid "Recommended tracks" +msgstr "Рекомендованные треки" + +msgid "Exit Immersive" +msgstr "Выйти из иммерсивного режима" + +msgid "FOCUS" +msgstr "ФОКУС" + +msgid "Plasma" +msgstr "Плазма" + +msgid "Aurora" +msgstr "Сияние" + +msgid "SPLIT" +msgstr "РАЗДЕЛЕНИЕ" + +msgid "Track Info" +msgstr "О треке" + +msgid "Suggestions" +msgstr "Рекомендации" + +msgid "UP NEXT" +msgstr "ДАЛЕЕ" + +msgid "Tracks you play will appear here" +msgstr "Здесь появятся треки, которые вы слушаете" + +msgid "No track playing" +msgstr "Ничего не играет" + +msgid "Queue is empty" +msgstr "Очередь пуста" + +msgid "Search albums & tracks…" +msgstr "Поиск альбомов и треков…" + +msgid "Acclaimed Releases" +msgstr "Признанные релизы" + +msgid "No award-winning releases yet." +msgstr "Отмеченных наградами релизов пока нет." + +msgid "Award" +msgstr "Награда" + +msgid "Unfollow award" +msgstr "Отписаться от награды" + +msgid "Follow award" +msgstr "Подписаться на награду" + +msgid "See All" +msgstr "Показать все" + +msgid "Other awards" +msgstr "Другие награды" + +msgid "{} item needs attention before bit-perfect playback will work." +msgid_plural "{} items need attention before bit-perfect playback will work." +msgstr[0] "{} элемент требует внимания, прежде чем заработает бит-перфект." +msgstr[1] "{} элемента требуют внимания, прежде чем заработает бит-перфект." +msgstr[2] "{} элементов требуют внимания, прежде чем заработает бит-перфект." + +msgid "Removed {} artist from blacklist" +msgid_plural "Removed {} artists from blacklist" +msgstr[0] "Удалён {} исполнитель из чёрного списка" +msgstr[1] "Удалено {} исполнителя из чёрного списка" +msgstr[2] "Удалено {} исполнителей из чёрного списка" + +msgid "Removed {} folder" +msgid_plural "Removed {} folders" +msgstr[0] "Удалена {} папка" +msgstr[1] "Удалено {} папки" +msgstr[2] "Удалено {} папок" + +msgid "Scan complete ({} file skipped)" +msgid_plural "Scan complete ({} files skipped)" +msgstr[0] "Сканирование завершено (пропущен {} файл)" +msgstr[1] "Сканирование завершено (пропущено {} файла)" +msgstr[2] "Сканирование завершено (пропущено {} файлов)" + +msgid "{} album" +msgid_plural "{} albums" +msgstr[0] "{} альбом" +msgstr[1] "{} альбома" +msgstr[2] "{} альбомов" + +msgid "{} item" +msgid_plural "{} items" +msgstr[0] "{} элемент" +msgstr[1] "{} элемента" +msgstr[2] "{} элементов" + +msgid "{} duplicate skipped" +msgid_plural "{} duplicates skipped" +msgstr[0] "Пропущен {} дубликат" +msgstr[1] "Пропущено {} дубликата" +msgstr[2] "Пропущено {} дубликатов" + +msgid "Removed {} item" +msgid_plural "Removed {} items" +msgstr[0] "Удалён {} элемент" +msgstr[1] "Удалено {} элемента" +msgstr[2] "Удалено {} элементов" + +msgid "Caching {} track offline…" +msgid_plural "Caching {} tracks offline…" +msgstr[0] "Кэширование {} трека для офлайна…" +msgstr[1] "Кэширование {} треков для офлайна…" +msgstr[2] "Кэширование {} треков для офлайна…" + +msgid "(Re)start the PipeWire audio services" +msgstr "(Пере)запустить аудиослужбы PipeWire" + +msgid "({} local)" +msgstr "({} локально)" + +msgid "176.4 kHz" +msgstr "176,4 кГц" + +msgid "192 kHz" +msgstr "192 кГц" + +msgid "352.8 kHz" +msgstr "352,8 кГц" + +msgid "384 kHz" +msgstr "384 кГц" + +msgid "44.1 kHz" +msgstr "44,1 кГц" + +msgid "48 kHz" +msgstr "48 кГц" + +msgid "88.2 kHz" +msgstr "88,2 кГц" + +msgid "96 kHz" +msgstr "96 кГц" + +msgid "A fresh mix every week." +msgstr "Новый микс каждую неделю." + +msgid "Add a folder before scanning" +msgstr "Добавьте папку перед сканированием" + +msgid "Added to queue" +msgstr "Добавлено в очередь" + +msgid "Added {} to {}" +msgstr "{} добавлено в {}" + +msgid "Added {} to {} ({})" +msgstr "{} добавлено в {} ({})" + +msgid "Adding tracks to playlist..." +msgstr "Добавление треков в плейлист..." + +msgid "Adding tracks: {} / {}" +msgstr "Добавление треков: {} / {}" + +msgid "Album folder not found on disk" +msgstr "Папка альбома не найдена на диске" + +msgid "Album metadata saved" +msgstr "Метаданные альбома сохранены" + +msgid "Album title is required" +msgstr "Нужно указать название альбома" + +msgid "Albums of the Week" +msgstr "Альбомы недели" + +msgid "Albums on Local Library" +msgstr "Альбомы в локальной библиотеке" + +msgid "Already in {}" +msgstr "Уже в {}" + +msgid "Always skip track" +msgstr "Всегда пропускать трек" + +msgid "Always try lowest quality" +msgstr "Всегда выбирать минимальное качество" + +msgid "April" +msgstr "Апрель" + +msgid "Are you absolutely sure?" +msgstr "Вы полностью уверены?" + +msgid "Artist Spotlight" +msgstr "В центре внимания" + +msgid "Artists on Local Library" +msgstr "Исполнители в локальной библиотеке" + +msgid "Ask me" +msgstr "Спрашивать меня" + +msgid "August" +msgstr "Август" + +msgid "Authorize QBZ in your browser, then click \"Finish\"" +msgstr "Авторизуйте QBZ в браузере, затем нажмите «Готово»" + +msgid "Available tracks only — offline" +msgstr "Только доступные треки — офлайн" + +msgid "Bit-perfect (Hardware / Digital)" +msgstr "Бит-перфект (аппаратный / цифровой)" + +msgid "Blacklist disabled" +msgstr "Чёрный список отключён" + +msgid "Blacklist enabled" +msgstr "Чёрный список включён" + +msgid "Booklet open task failed: {}" +msgstr "Не удалось открыть буклет: {}" + +msgid "COLLECTION" +msgstr "КОЛЛЕКЦИЯ" + +msgid "Cache cleared" +msgstr "Кэш очищен" + +msgid "Cached for offline" +msgstr "Кэшировано для офлайна" + +msgid "Cannot convert this kind" +msgstr "Этот тип нельзя преобразовать" + +msgid "Checked {} tracks - all OK" +msgstr "Проверено треков: {} — всё в порядке" + +msgid "Checking playlist link..." +msgstr "Проверка ссылки на плейлист..." + +msgid "Checking your audio stack…" +msgstr "Проверка аудиосистемы…" + +msgid "Couldn't load the test tracks (offline?)" +msgstr "Не удалось загрузить тестовые треки (нет сети?)" + +msgid "Choose a cover image" +msgstr "Выберите изображение обложки" + +msgid "Choose a folder to play" +msgstr "Выберите папку для воспроизведения" + +msgid "Choose a {} icon" +msgstr "Выберите значок {}" + +msgid "Cleaning up missing files..." +msgstr "Удаление отсутствующих файлов..." + +msgid "Cleanup failed" +msgstr "Не удалось выполнить очистку" + +msgid "Clear Plex cache?" +msgstr "Очистить кэш Plex?" + +msgid "Clear library database?" +msgstr "Очистить базу данных библиотеки?" + +msgid "Code copied" +msgstr "Код скопирован" + +msgid "Compilations" +msgstr "Сборники" + +msgid "Connected as {}" +msgstr "Вы вошли как {}" + +msgid "Connected to {} (v{})" +msgstr "Подключено к {} (v{})" + +msgid "Continue Listening" +msgstr "Продолжить прослушивание" + +msgid "Converted" +msgstr "Преобразовано" + +msgid "Couldn't add folder" +msgstr "Не удалось добавить папку" + +msgid "Couldn't change folder location (path may already exist)" +msgstr "Не удалось изменить расположение папки (путь, возможно, уже существует)" + +msgid "Couldn't clean up missing files" +msgstr "Не удалось удалить отсутствующие файлы" + +msgid "Couldn't clear the Plex cache" +msgstr "Не удалось очистить кэш Plex" + +msgid "Couldn't clear the library database" +msgstr "Не удалось очистить базу данных библиотеки" + +msgid "Couldn't clear the offline cache" +msgstr "Не удалось очистить офлайн-кэш" + +msgid "Couldn't create the Qobuz playlist" +msgstr "Не удалось создать плейлист Qobuz" + +msgid "Couldn't load library folders" +msgstr "Не удалось загрузить папки библиотеки" + +msgid "Couldn't load that album" +msgstr "Не удалось загрузить этот альбом" + +msgid "Couldn't load that playlist" +msgstr "Не удалось загрузить этот плейлист" + +msgid "Couldn't load that track" +msgstr "Не удалось загрузить этот трек" + +msgid "Couldn't load this album" +msgstr "Не удалось загрузить этот альбом" + +msgid "Couldn't load this artist" +msgstr "Не удалось загрузить этого исполнителя" + +msgid "Couldn't load this collection" +msgstr "Не удалось загрузить эту коллекцию" + +msgid "Couldn't load this playlist" +msgstr "Не удалось загрузить этот плейлист" + +msgid "Couldn't open that folder" +msgstr "Не удалось открыть эту папку" + +msgid "Couldn't save folder settings" +msgstr "Не удалось сохранить настройки папки" + +msgid "Couldn't save metadata: {}" +msgstr "Не удалось сохранить метаданные: {}" + +msgid "Cover removed" +msgstr "Обложка удалена" + +msgid "Cover updated" +msgstr "Обложка обновлена" + +msgid "Creating playlist..." +msgstr "Создание плейлиста..." + +msgid "Critics' Picks" +msgstr "Выбор критиков" + +msgid "DAC: {} · {} · {} ch" +msgstr "DAC: {} · {} · {} кан." + +msgid "December" +msgstr "Декабрь" + +msgid "Defaults" +msgstr "По умолчанию" + +msgid "Disconnect from Plex?" +msgstr "Отключиться от Plex?" + +msgid "EPs & Singles" +msgstr "EP и синглы" + +msgid "Enable PipeWire in your NixOS configuration" +msgstr "Включите PipeWire в конфигурации NixOS" + +msgid "Enter a title or artist to search" +msgstr "Введите название или исполнителя для поиска" + +msgid "Enter code {} at the Plex sign-in page" +msgstr "Введите код {} на странице входа Plex" + +msgid "Error: {}" +msgstr "Ошибка: {}" + +msgid "Error: {} (did you authorize in the browser?)" +msgstr "Ошибка: {} (вы авторизовались в браузере?)" + +msgid "Essentials by Genre" +msgstr "Лучшее по жанрам" + +msgid "Every track needs a title" +msgstr "У каждого трека должно быть название" + +msgid "Failed to cast track" +msgstr "Не удалось транслировать трек" + +msgid "Failed to change play mode" +msgstr "Не удалось изменить режим воспроизведения" + +msgid "Failed to clear blacklist" +msgstr "Не удалось очистить чёрный список" + +msgid "Failed to clear lyrics cache" +msgstr "Не удалось очистить кэш текстов песен" + +msgid "Failed to create temp dir: {}" +msgstr "Не удалось создать временную папку: {}" + +msgid "Failed to decode booklet page: {}" +msgstr "Не удалось декодировать страницу буклета: {}" + +msgid "Failed to delete" +msgstr "Не удалось удалить" + +msgid "Failed to fetch booklet: {}" +msgstr "Не удалось получить буклет: {}" + +msgid "Failed to fetch metadata" +msgstr "Не удалось получить метаданные" + +msgid "Failed to read booklet: {}" +msgstr "Не удалось прочитать буклет: {}" + +msgid "Failed to remove artist" +msgstr "Не удалось удалить исполнителя" + +msgid "Failed to remove cover" +msgstr "Не удалось удалить обложку" + +msgid "Failed to remove items" +msgstr "Не удалось удалить элементы" + +msgid "Failed to rename" +msgstr "Не удалось переименовать" + +msgid "Failed to save description" +msgstr "Не удалось сохранить описание" + +msgid "Failed to toggle blacklist" +msgstr "Не удалось переключить чёрный список" + +msgid "Failed to upload cover" +msgstr "Не удалось загрузить обложку" + +msgid "Failed to write booklet: {}" +msgstr "Не удалось записать буклет: {}" + +msgid "Library Albums" +msgstr "Альбомы из вашей библиотеки" + +msgid "February" +msgstr "Февраль" + +msgid "File not available — is the drive mounted?" +msgstr "Файл недоступен — диск подключён?" + +msgid "Files not available — is the drive mounted?" +msgstr "Файлы недоступны — диск подключён?" + +msgid "Folder opened" +msgstr "Папка открыта" + +msgid "Found {} tracks from {}." +msgstr "Найдено {} треков из {}." + +msgid "From the same artist" +msgstr "Этого же исполнителя" + +msgid "From your most-played playlists." +msgstr "Из ваших самых прослушиваемых плейлистов." + +msgid "Ideal Discography" +msgstr "Идеальная дискография" + +msgid "Image" +msgstr "Изображение" + +msgid "Import failed: {}" +msgstr "Не удалось импортировать: {}" + +msgid "Imported {} of {} tracks into QBZ." +msgstr "Импортировано {} из {} треков в QBZ." + +msgid "Install the ALSA bridge so playback can reach PipeWire" +msgstr "Установите мост ALSA, чтобы воспроизведение доходило до PipeWire" + +msgid "Install the PipeWire audio stack" +msgstr "Установите аудиостек PipeWire" + +msgid "Install the PipeWire tools (pw-dump)" +msgstr "Установите инструменты PipeWire (pw-dump)" + +msgid "Install the Pulse compatibility tools (optional fallback)" +msgstr "Установите инструменты совместимости Pulse (резервный вариант)" + +msgid "January" +msgstr "Январь" + +msgid "July" +msgstr "Июль" + +msgid "June" +msgstr "Июнь" + +msgid "Last.fm disconnected" +msgstr "Last.fm отключён" + +msgid "Last.fm sign-in failed to start" +msgstr "Не удалось начать вход в Last.fm" + +msgid "Library database cleared" +msgstr "База данных библиотеки очищена" + +msgid "ListenBrainz disconnected" +msgstr "ListenBrainz отключён" + +msgid "Listening suggestions" +msgstr "Рекомендации для прослушивания" + +msgid "Loaded {} cached tracks" +msgstr "Загружено {} треков из кэша" + +msgid "Log in to cache tracks offline" +msgstr "Войдите, чтобы кэшировать треки офлайн" + +msgid "Lossless: {}" +msgstr "Lossless: {}" + +msgid "Lyrics cache cleared" +msgstr "Кэш текстов песен очищен" + +msgid "Lyrics copied" +msgstr "Текст песни скопирован" + +msgid "MIXTAPE" +msgstr "МИКСТЕЙП" + +msgid "March" +msgstr "Март" + +msgid "Matching tracks: {} / {} ({} found)" +msgstr "Совпадающие треки: {} / {} (найдено {})" + +msgid "May" +msgstr "Май" + +msgid "Middle-click to pause" +msgstr "Средний клик — пауза" + +msgid "Middle-click to play" +msgstr "Средний клик — воспроизведение" + +msgid "Mixed queue — not cast to Qobuz Connect" +msgstr "Смешанная очередь — не транслируется в Qobuz Connect" + +msgid "More From Your Library" +msgstr "Ещё из вашей библиотеки" + +msgid "More Labels" +msgstr "Больше лейблов" + +msgid "Most Streamed" +msgstr "Самые прослушиваемые" + +msgid "Network folder detected" +msgstr "Обнаружена сетевая папка" + +msgid "Never" +msgstr "Никогда" + +msgid "New Releases" +msgstr "Новинки" + +msgid "Next Track" +msgstr "Следующий трек" + +msgid "No limit" +msgstr "Без ограничений" + +msgid "No matching tracks found." +msgstr "Совпадающих треков не найдено." + +msgid "No sinks detected — reinstall the ALSA UCM profiles, then reboot" +msgstr "Устройства вывода не обнаружены — переустановите профили ALSA UCM и перезагрузитесь" + +msgid "No top tracks available for this artist" +msgstr "Для этого исполнителя нет популярных треков" + +msgid "No tracks available offline" +msgstr "Нет треков, доступных офлайн" + +msgid "Not available offline" +msgstr "Недоступно офлайн" + +msgid "Nothing playable in this playlist right now" +msgstr "Сейчас в этом плейлисте нечего воспроизвести" + +msgid "November" +msgstr "Ноябрь" + +msgid "October" +msgstr "Октябрь" + +msgid "Offline cache cleared" +msgstr "Офлайн-кэш очищен" + +msgid "Offline cache is full — free space or raise the limit" +msgstr "Офлайн-кэш заполнен — освободите место или увеличьте лимит" + +msgid "Offline caching failed" +msgstr "Не удалось сохранить в офлайн-кэш" + +msgid "On Local Library" +msgstr "В локальной библиотеке" + +msgid "Opened folder ({} files skipped)" +msgstr "Папка открыта (пропущено файлов: {})" + +msgid "Other Outputs" +msgstr "Другие устройства вывода" + +msgid "PLAYLIST" +msgstr "ПЛЕЙЛИСТ" + +msgid "Paste your ListenBrainz user token first" +msgstr "Сначала вставьте свой токен пользователя ListenBrainz" + +msgid "Play/Pause" +msgstr "Воспроизвести/Пауза" + +msgid "Playing next" +msgstr "Следующим в очереди" + +msgid "Playing {} of {}" +msgstr "Воспроизводится {} из {}" + +msgid "Playlist ({} local)" +msgstr "Плейлист (локальных: {})" + +msgid "Playlist created in Qobuz™." +msgstr "Плейлист создан в Qobuz™." + +msgid "Playlist uploaded to Qobuz" +msgstr "Плейлист загружен в Qobuz" + +msgid "Playlist: {}" +msgstr "Плейлист: {}" + +msgid "Plex link failed to start" +msgstr "Не удалось начать подключение Plex" + +msgid "Plugin Hardware" +msgstr "Оборудование plughw" + +msgid "Press Accolades" +msgstr "Признание прессы" + +msgid "Previous Track" +msgstr "Предыдущий трек" + +msgid "Purchase Only" +msgstr "Только покупка" + +msgid "QBZ requesting {} · {}-bit" +msgstr "QBZ запрашивает {} · {}-бит" + +msgid "Qobuz Connect is unavailable while offline" +msgstr "Qobuz Connect недоступен в офлайн-режиме" + +msgid "Qobuz Connect renderer disconnected" +msgstr "Рендерер Qobuz Connect отключён" + +msgid "Qobuz Connect renderer unreachable" +msgstr "Рендерер Qobuz Connect недоступен" + +msgid "Qobuzissimes" +msgstr "Qobuzissimes" + +msgid "Quit QBZ" +msgstr "Выйти из QBZ" + +msgid "Rate limited, try again shortly" +msgstr "Превышен лимит запросов, повторите попытку позже" + +msgid "Recently Played" +msgstr "Недавно прослушанное" + +msgid "Rediscover Your Library" +msgstr "Откройте библиотеку заново" + +msgid "Release Watch" +msgstr "Отслеживание релизов" + +msgid "Removed \"{}\" ({})" +msgstr "Удалено «{}» ({})" + +msgid "Removed album from offline" +msgstr "Альбом удалён из офлайна" + +msgid "Removed from offline" +msgstr "Удалено из офлайна" + +msgid "Removed {} of {} tracks" +msgstr "Удалено {} из {} треков" + +msgid "Scan cancelled" +msgstr "Сканирование отменено" + +msgid "Scan complete" +msgstr "Сканирование завершено" + +msgid "Scan failed" +msgstr "Не удалось выполнить сканирование" + +msgid "Scanning track paths..." +msgstr "Сканирование путей треков..." + +msgid "Scroll to adjust volume" +msgstr "Прокрутка — регулировка громкости" + +msgid "Search failed: {}" +msgstr "Не удалось выполнить поиск: {}" + +msgid "Searching Qobuz catalog..." +msgstr "Поиск в каталоге Qobuz..." + +msgid "Select music folder" +msgstr "Выберите папку с музыкой" + +msgid "September" +msgstr "Сентябрь" + +msgid "Show/Hide Window" +msgstr "Показать/Скрыть окно" + +msgid "Similar to {}" +msgstr "Похоже на {}" + +msgid "Skipped: {}" +msgstr "Пропущено: {}" + +msgid "Some tracks can't be cast to Qobuz Connect" +msgstr "Некоторые треки нельзя транслировать в Qobuz Connect" + +msgid "Song Radio" +msgstr "Радио по треку" + +msgid "Split into {} playlists (Qobuz 2000-track limit)" +msgstr "Разделить на {} плейлистов (лимит Qobuz — 2000 треков)" + +msgid "Start the sign-in first" +msgstr "Сначала начните вход" + +msgid "Starting…" +msgstr "Запуск…" + +msgid "System default" +msgstr "Системное по умолчанию" + +msgid "TRACK" +msgstr "ТРЕК" + +msgid "These items resolved to 0 playable tracks" +msgstr "В этих элементах не нашлось воспроизводимых треков" + +msgid "This action cannot be undone." +msgstr "Это действие нельзя отменить." + +msgid "This album has no playable tracks" +msgstr "В этом альбоме нет воспроизводимых треков" + +msgid "This collection resolved to 0 playable tracks" +msgstr "В этой коллекции не нашлось воспроизводимых треков" + +msgid "This item resolved to 0 playable tracks" +msgstr "В этом элементе не нашлось воспроизводимых треков" + +msgid "This modifies your audio files on disk and cannot be undone." +msgstr "Это изменит ваши аудиофайлы на диске, и отменить это нельзя." + +msgid "This playlist has no playable tracks" +msgstr "В этом плейлисте нет воспроизводимых треков" + +msgid "This removes cached Plex libraries and tracks. Your sign-in is kept." +msgstr "Это удалит кэшированные библиотеки и треки Plex. Ваш вход сохранится." + +msgid "This track is no longer available" +msgstr "Этот трек больше недоступен" + +msgid "This track is not yet available" +msgstr "Этот трек пока недоступен" + +msgid "Track count differs from the result; titles applied by position" +msgstr "Количество треков отличается от результата; названия применены по позиции" + +msgid "Track not available offline" +msgstr "Трек недоступен офлайн" + +msgid "Track not castable to Qobuz Connect" +msgstr "Трек нельзя транслировать в Qobuz Connect" + +msgid "Track unavailable on Qobuz Connect" +msgstr "Трек недоступен в Qobuz Connect" + +msgid "Tracks matched: {} / {}" +msgstr "Совпало треков: {} / {}" + +msgid "Unavailable Plex track" +msgstr "Недоступный трек Plex" + +msgid "Unavailable track" +msgstr "Недоступный трек" + +msgid "Unknown" +msgstr "Неизвестно" + +msgid "Unknown Artist" +msgstr "Неизвестный исполнитель" + +msgid "Unknown source" +msgstr "Неизвестный источник" + +msgid "Upcoming" +msgstr "Скоро" + +msgid "Upload incomplete — local playlist kept" +msgstr "Загрузка не завершена — локальный плейлист сохранён" + +msgid "Various Artists" +msgstr "Различные исполнители" + +msgid "Waiting for the DAC to start playing…" +msgstr "Ожидание начала воспроизведения на DAC…" + +msgid "Write tags to audio files?" +msgstr "Записать теги в аудиофайлы?" + +msgid "Writing tags to files isn't supported for CUE albums; use sidecar mode" +msgstr "Запись тегов в файлы не поддерживается для CUE-альбомов; используйте режим сопроводительного файла" + +msgid "Year must be a number" +msgstr "Год должен быть числом" + +msgid "You're offline — try again when connected" +msgstr "Вы офлайн — повторите попытку при подключении" + +msgid "Your audio stack is ready for bit-perfect playback." +msgstr "Ваш аудиостек готов к бит-перфект воспроизведению." + +msgid "Your queue is empty — add some tracks first, or press Play test." +msgstr "Очередь пуста — сначала добавьте треки или нажмите «Тест воспроизведения»." + +msgid "by {}" +msgstr "от {}" + +msgid "hw (Direct Hardware)" +msgstr "hw (прямой доступ к оборудованию)" + +msgid "pcm (Most compatible)" +msgstr "pcm (наиболее совместимый)" + +msgid "plughw (Auto-convert)" +msgstr "plughw (автопреобразование)" + +msgid "{} + {} more" +msgstr "{} и ещё {}" + +msgid "{} albums · {} tracks" +msgstr "{} альбомов · {} треков" + +msgid "{} h {} min" +msgstr "{} ч {} мин" + +msgid "{} libraries found" +msgstr "Найдено библиотек: {}" + +msgid "{} min" +msgstr "{} мин" + +msgid "{} removed from blacklist" +msgstr "{} удалён из чёрного списка" + +msgid "{} tracks loaded" +msgstr "Загружено треков: {}" + +msgid "{} tracks · {}" +msgstr "{} треков · {}" + +msgid "{} {}" +msgstr "{} {}" + +msgid "{} {}, {}" +msgstr "{} {}, {}" + +msgid "{} — Complete Discography" +msgstr "{} — полная дискография" + +msgid "· Unlimited" +msgstr "· Без ограничений" + +msgid "· of {}" +msgstr "· из {}" + +# --- Purchases (Slice 13) --- +msgid "My Purchases" +msgstr "Мои покупки" + +msgid "Purchases are downloaded as DRM-free files. Availability depends on your Qobuz region." +msgstr "Покупки скачиваются как файлы без DRM. Доступность зависит от вашего региона Qobuz." + +msgid "Search purchases…" +msgstr "Поиск по покупкам…" + +msgid "No purchases yet" +msgstr "Покупок пока нет" + +msgid "No purchased tracks yet" +msgstr "Купленных треков пока нет" + +msgid "Filters" +msgstr "Фильтры" + +msgid "Availability" +msgstr "Доступность" + +msgid "Hide unavailable" +msgstr "Скрыть недоступные" + +msgid "CD Quality" +msgstr "Качество CD" + +msgid "MP3" +msgstr "MP3" + +msgid "Downloads" +msgstr "Загрузки" + +msgid "Hide downloaded" +msgstr "Скрыть скачанные" + +msgid "Group: A–Z" +msgstr "Группировка: А–Я" + +msgid "Sort: Date" +msgstr "Сортировка: дата" + +msgid "Sort: Artist" +msgstr "Сортировка: исполнитель" + +msgid "Sort: Album" +msgstr "Сортировка: альбом" + +msgid "Sort: Quality" +msgstr "Сортировка: качество" + +msgid "Date" +msgstr "Дата" + +msgid "Download all" +msgstr "Скачать всё" + +msgid "Download complete" +msgstr "Загрузка завершена" + +msgid "Download cancelled ({}/{})" +msgstr "Загрузка отменена ({}/{})" + +msgid "Downloading {}/{}..." +msgstr "Загрузка {}/{}..." + +msgid "Select format" +msgstr "Выберите формат" + +msgid "Purchased on" +msgstr "Дата покупки" + +msgid "Add these downloaded tracks to your local library." +msgstr "Добавьте эти скачанные треки в локальную библиотеку." + +msgid "{} tracks" +msgstr "{} треков" + +msgid "Choose folder" +msgstr "Выберите папку" + +msgid "Couldn't add to library" +msgstr "Не удалось добавить в библиотеку" + +msgid "Added to library" +msgstr "Добавлено в библиотеку" + +msgid "Purchased from Qobuz" +msgstr "Куплено в Qobuz" + +msgid "Couldn't load purchases. Check your connection." +msgstr "Не удалось загрузить покупки. Проверьте подключение." + +msgid "This track has no album to download" +msgstr "У этого трека нет альбома для скачивания" + +msgid "Failed to start download. Please try again." +msgstr "Не удалось начать загрузку. Повторите попытку." + +msgid "No downloadable formats available" +msgstr "Нет форматов, доступных для скачивания" + +msgid "Download goodies" +msgstr "Скачать бонусы" + +# Playlist "Suggested Songs" section (T8) +msgid "Suggested songs" +msgstr "Рекомендованные треки" + +msgid "Suggest songs for this playlist" +msgstr "Предложить треки для этого плейлиста" + +msgid "Finding similar artists..." +msgstr "Поиск похожих исполнителей..." + +msgid "Refresh suggestions" +msgstr "Обновить рекомендации" + +msgid "Not interested" +msgstr "Не интересно" + +# Slint polish batch (radio / playlist follow-copy / MusicBrainz / reco) +msgid "QBZ Radio" +msgstr "Радио QBZ" + +msgid "Qobuz Radio" +msgstr "Радио Qobuz" + +msgid "Create QBZ radio" +msgstr "Создать радио QBZ" + +msgid "Create Qobuz radio" +msgstr "Создать радио Qobuz" + +msgid "Follow on Qobuz" +msgstr "Подписаться в Qobuz" + +msgid "Following on Qobuz" +msgstr "Вы подписаны в Qobuz" + +msgid "Copy to your library" +msgstr "Скопировать в библиотеку" + +msgid "METADATA" +msgstr "МЕТАДАННЫЕ" + +msgid "MusicBrainz" +msgstr "MusicBrainz" + +msgid "Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections." +msgstr "Включите связи между исполнителями и расширенные метаданные из MusicBrainz. Без телеметрии — только сопоставление релизов для обогащения страниц исполнителей и рекомендаций треков в плейлистах. Отключите, чтобы убрать эти разделы." + +msgid "No tracks to add" +msgstr "Нет треков для добавления" + +msgid "Failed to add tracks" +msgstr "Не удалось добавить треки" + +msgid "Copied to your library" +msgstr "Скопировано в библиотеку" + +msgid "Failed to copy playlist" +msgstr "Не удалось скопировать плейлист" + +msgid "Playlist has no tracks to copy" +msgstr "В плейлисте нет треков для копирования" + +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "Добавлен {} трек" +msgstr[1] "Добавлено {} трека" +msgstr[2] "Добавлено {} треков" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "Добавлен {} трек в {}" +msgstr[1] "Добавлено {} трека в {}" +msgstr[2] "Добавлено {} треков в {}" + +msgid "Added {} track to queue" +msgid_plural "Added {} tracks to queue" +msgstr[0] "Добавлен {} трек в очередь" +msgstr[1] "Добавлено {} трека в очередь" +msgstr[2] "Добавлено {} треков в очередь" + +msgid "Unfollow on Qobuz" +msgstr "Отписаться в Qobuz" + +msgid "Stops in" +msgstr "Остановится через" + +msgid "Cancel Timer" +msgstr "Отменить таймер" + +msgid "Stop playback after:" +msgstr "Остановить воспроизведение через:" + +msgid "30 min" +msgstr "30 мин" + +msgid "1 hr" +msgstr "1 ч" + +msgid "2 hr" +msgstr "2 ч" + +msgid "3 hr" +msgstr "3 ч" + +msgid "5 hr" +msgstr "5 ч" + +msgid "Custom…" +msgstr "Свой…" + +msgid "min" +msgstr "мин" + +msgid "Set Timer" +msgstr "Установить таймер" + +msgid "Cancel stop after this" +msgstr "Отменить остановку после этого" + +msgid "LOGS" +msgstr "ЖУРНАЛЫ" + +msgid "Application logs" +msgstr "Журналы приложения" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Просмотрите журнал в приложении, скопируйте его (секреты скрыты) или загрузите, чтобы поделиться в GitHub issue." + +msgid "Open log viewer" +msgstr "Открыть просмотр журнала" + +msgid "Setting" +msgstr "Настройка" + +msgid "Saved" +msgstr "Сохранено" + +msgid "Runtime" +msgstr "Текущее" + +msgid "Diagnostics" +msgstr "Диагностика" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Сравнение текущей и сохранённой конфигурации." + +msgid "Exported" +msgstr "Экспортировано" + +msgid "Export to clipboard" +msgstr "Экспортировать в буфер обмена" + +msgid "Cast Discovery" +msgstr "Поиск устройств трансляции" + +msgid "Scanning…" +msgstr "Сканирование…" + +msgid "Scan for devices" +msgstr "Искать устройства" + +msgid "Graphics" +msgstr "Графика" + +msgid "Environment" +msgstr "Окружение" + +msgid "Logs" +msgstr "Журналы" + +msgid "View logs" +msgstr "Показать журнал" + +msgid "Upload failed." +msgstr "Не удалось загрузить." + +msgid "All levels" +msgstr "Все уровни" + +msgid "Error" +msgstr "Ошибка" + +msgid "Warn" +msgstr "Предупреждение" + +msgid "Info" +msgstr "Информация" + +msgid "Debug" +msgstr "Отладка" + +msgid "Trace" +msgstr "Трассировка" + +msgid "Search logs…" +msgstr "Поиск по журналам…" + +msgid "Auto-tail" +msgstr "Автопрокрутка" + +msgid "Time" +msgstr "Время" + +msgid "Level" +msgstr "Уровень" + +msgid "Target" +msgstr "Источник" + +msgid "Message" +msgstr "Сообщение" + +msgid "No log entries." +msgstr "Нет записей в журнале." + +msgid "Copied" +msgstr "Скопировано" + +msgid "Copy all" +msgstr "Копировать всё" + +msgid "Copy diagnostics bundle" +msgstr "Копировать пакет диагностики" + +msgid "Uploading…" +msgstr "Загрузка…" + +msgid "Upload (public)" +msgstr "Загрузить (публично)" + +msgid "Open log file" +msgstr "Открыть файл журнала" + +msgid "Report an issue" +msgstr "Сообщить о проблеме" + +msgid "Go to logs" +msgstr "Перейти к журналам" + +msgid "Create issue report" +msgstr "Создать отчёт о проблеме" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Чтобы упростить решение ошибок и проблем, рекомендуется поделиться журналами приложения. Мы тщательно маскируем отправляемые журналы, чтобы исключить конфиденциальные данные. Процесс выполняется вручную, никогда автоматически, поэтому вы можете проверить, чем делитесь." + +msgid "Cache window" +msgstr "Окно кэширования" + +msgid "24 hours" +msgstr "24 часа" + +msgid "36 hours" +msgstr "36 часов" + +msgid "48 hours" +msgstr "48 часов" + +msgid "72 hours" +msgstr "72 часа" + +msgid "Refresh now" +msgstr "Обновить сейчас" + +msgid "No internet connection — you can start in offline mode with your local library and downloads" +msgstr "Нет подключения к интернету — можно начать в офлайн-режиме с локальной библиотекой и загрузками" + +msgid "QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™." +msgstr "QBZ требует активной подписки Qobuz™. Ваши учётные данные отправляются напрямую в Qobuz™." + +msgid "Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz." +msgstr "Qobuz™ — товарный знак Qobuz. QBZ — приложение с открытым исходным кодом под лицензией MIT License, не связанное с Qobuz, не одобренное и не сертифицированное им." + +msgid "This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy." +msgstr "Создаёт плейлист в Qobuz с его треками Qobuz, сохраняет привязанные локальные файлы и удаляет копию, существующую только локально." + +msgid "Add this to the current queue, or clear the queue and start playing it?" +msgstr "Добавить в текущую очередь или очистить очередь и начать воспроизведение?" + +msgid "This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates." +msgstr "В этом плейлисте уже есть {} из {} добавляемых треков. Добавить только новые или добавить всё, включая дубликаты." + +msgid "This wizard helps you configure your DAC for bit-perfect playback on Linux. QBZ never changes your system automatically — it auto-detects your DACs, shows you the exact commands and config to copy, and lets you test playback right here." +msgstr "Этот мастер поможет настроить DAC для бит-перфект воспроизведения на Linux. QBZ никогда не меняет систему автоматически — он сам находит ваши DAC, показывает точные команды и конфигурацию для копирования и позволяет проверить воспроизведение прямо здесь." + +msgid "You are responsible for your own backups. This is a helper, not a system recovery tool." +msgstr "Вы сами отвечаете за резервные копии. Это помощник, а не средство восстановления системы." + +msgid "The built-in test plays four tracks spanning different bit-depths and sample rates (16/44.1, 24/44.1, 24/96, 24/192) so you can see your DAC switch. If you'd rather use your own queue, add one track of each kind first so every rate is exercised." +msgstr "Встроенный тест воспроизводит четыре трека с разной разрядностью и частотой дискретизации (16/44.1, 24/44.1, 24/96, 24/192), чтобы вы увидели переключение DAC. Если хотите использовать свою очередь, сначала добавьте по одному треку каждого вида, чтобы задействовать все частоты." + +msgid "QBZ is packaged as Flatpak/Snap and can't inspect your host audio stack. If playback already works, you're set. Pick your distribution and init system below for the right setup commands." +msgstr "QBZ поставляется как Flatpak/Snap и не может проверить аудиостек хост-системы. Если воспроизведение уже работает, всё готово. Выберите ниже свой дистрибутив и систему инициализации, чтобы получить нужные команды настройки." + +msgid "QBZ already pins rate, sink and exclusive mode at runtime. These files only change system-wide behavior — copy and run them yourself if you want them. Back up first." +msgstr "QBZ уже фиксирует частоту, выход и эксклюзивный режим во время работы. Эти файлы меняют только общесистемное поведение — скопируйте и запустите их сами, если они вам нужны. Сначала сделайте резервную копию." + +msgid "Plays four tracks at different bit-depths and sample rates through the selected DAC. Watch your DAC's display switch rates — and confirm it below." +msgstr "Воспроизводит четыре трека с разной разрядностью и частотой дискретизации через выбранный DAC. Следите за переключением частот на дисплее DAC — и подтвердите это ниже." + +msgid "Your DAC is ready for bit-perfect playback. If you applied the config files, restart the audio services (or log out and back in) for them to take effect." +msgstr "Ваш DAC готов к бит-перфект воспроизведению. Если вы применили файлы конфигурации, перезапустите аудиослужбы (или выйдите и снова войдите), чтобы изменения вступили в силу." + +msgid "Live session topology and a rolling event log for debugging Qobuz Connect at runtime." +msgstr "Топология активной сессии и текущий журнал событий для отладки Qobuz Connect во время работы." + +msgid "Start uncached tracks via streaming instead of waiting for the full download." +msgstr "Запускать некэшированные треки через стриминг, не дожидаясь полной загрузки." + +msgid "Blacklisted artists are hidden from search results, radio, suggestions, and similar artists." +msgstr "Исполнители из чёрного списка скрыты из результатов поиска, радио, рекомендаций и похожих исполнителей." + +msgid "Blacklist filtering is disabled. Blacklisted artists are shown everywhere until you re-enable it." +msgstr "Фильтрация по чёрному списку отключена. Исполнители из чёрного списка показываются везде, пока вы её снова не включите." + +msgid "Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services." +msgstr "Отправляйте свои прослушивания в Last.fm и ListenBrainz. Работает для треков Qobuz, локальных и Plex. Этот переключатель также определяет, может ли раздел «Рекомендации» читать вашу историю прослушиваний из этих сервисов." + +msgid "Show what you're listening to as your Discord status. Opt-in — Discord must be running." +msgstr "Показывать, что вы слушаете, в статусе Discord. По желанию — Discord должен быть запущен." + +msgid "" +"Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" +msgstr "" +"Установка через Flatpak и статус не отображается? Предоставьте доступ к IPC-сокету Discord, затем перезапустите QBZ:\n" +"flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz" + +msgid "Use artwork-derived blur as a backdrop in album and artist detail views." +msgstr "Использовать размытие на основе обложки как фон на страницах альбома и исполнителя." + +msgid "Smart search cache, ranking, and the search preview dropdown. On by default." +msgstr "Умный кэш поиска, ранжирование и выпадающий предпросмотр поиска. Включено по умолчанию." + +msgid "What selecting a result in the Immersive search does. Disabled turns the in-immersive search off." +msgstr "Что происходит при выборе результата в иммерсивном поиске. «Отключено» выключает поиск внутри иммерсивного режима." + +msgid "Which immersive view opens by default. 'Remember last' restores your last view." +msgstr "Какой иммерсивный вид открывается по умолчанию. «Запомнить последний» восстанавливает ваш последний вид." + +msgid "Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar." +msgstr "Переместить разделы «Обзор», «Библиотека», «Локальная библиотека» и «Мой QBZ» из заголовка в боковую панель." + +msgid "Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default." +msgstr "Переименовать раздел «Мой QBZ». Оставьте имя пустым (или нажмите сброс), чтобы вернуть значение по умолчанию." + +msgid "Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images." +msgstr "Показывать миниатюру обложек треков 2×2 рядом с каждым плейлистом. Отключите на слабых машинах, чтобы не загружать дополнительные изображения." + +msgid "Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost." +msgstr "Показывать миниатюру обложки альбома рядом с каждым треком в видах «Треки» и «Папки». Отключено по умолчанию — большие библиотеки платят за декодирование изображения в каждой строке." + +msgid "Replace QBZ custom title bar with your system's native window decorations. Changing this will restart the app." +msgstr "Заменить собственный заголовок QBZ на стандартные оконные элементы вашей системы. Изменение перезапустит приложение." + +msgid "Place the window control buttons on the left or right side of the title bar" +msgstr "Разместить кнопки управления окном слева или справа в заголовке" + +msgid "Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these." +msgstr "Показывать кнопки свернуть, развернуть и закрыть в заголовке. Отключите, если ими управляет ваш оконный менеджер." + +msgid "Add discrete plus and minus buttons next to the volume slider in the player bar." +msgstr "Добавить отдельные кнопки плюс и минус рядом с ползунком громкости в панели плеера." + +msgid "Show the Purchases section in the sidebar for browsing and downloading your purchased music" +msgstr "Показывать раздел «Покупки» в боковой панели для просмотра и скачивания купленной музыки" + +msgid "Keep playing in the menu bar instead of quitting when you close the window" +msgstr "Продолжать воспроизведение в строке меню вместо выхода при закрытии окна" + +msgid "Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)" +msgstr "Работать только в строке меню, пока окно закрыто. При отключении значок остаётся в Dock (как в Spotify)" + +msgid "Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo" +msgstr "Выберите монохромный значок под вашу панель (Plasma, постоянно тёмная верхняя панель GNOME) или полноцветный логотип-винил" + +msgid "Controls how the blurred artwork background is rendered in immersive mode." +msgstr "Управляет тем, как размытый фон обложки отображается в иммерсивном режиме." + +msgid "Disabled panels are hidden from the visualizer menu so you don't land on them by mistake." +msgstr "Отключённые панели скрыты из меню визуализатора, чтобы вы не попадали на них по ошибке." + +msgid "Configure frame rate per animation. Lower values save CPU. Requires re-entering immersive mode to apply." +msgstr "Настройте частоту кадров для каждой анимации. Меньшие значения экономят CPU. Чтобы применить, нужно снова войти в иммерсивный режим." + +msgid "No internet connection — your local library and downloads keep working." +msgstr "Нет подключения к интернету — ваша локальная библиотека и загрузки продолжают работать." + +msgid "Create a random queue with songs from this collection. How many songs do you want?" +msgstr "Создать случайную очередь из треков этой коллекции. Сколько треков вы хотите?" + +msgid "No items yet. Add albums, tracks, or playlists from their detail pages." +msgstr "Пока ничего нет. Добавьте альбомы, треки или плейлисты с их страниц." + +msgid "" +"Music Player\n" +"Nothing playing" +msgstr "" +"Музыкальный плеер\n" +"Ничего не воспроизводится" + +msgid "Offline listening period expired — reconnect to verify your subscription" +msgstr "Период офлайн-прослушивания истёк — подключитесь снова, чтобы проверить подписку" + +msgid "This removes ALL indexed tracks from the database. Your audio files are NOT deleted. You will need to re-scan your folders afterward." +msgstr "Это удалит ВСЕ проиндексированные треки из базы данных. Ваши аудиофайлы НЕ удаляются. После этого нужно будет заново просканировать папки." + +msgid "This signs out of Plex and clears the locally cached libraries and tracks." +msgstr "Это выполнит выход из Plex и очистит локально кэшированные библиотеки и треки." + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback without browser sample rate limitations." +msgstr "Нативный клиент музыкального стриминга для Linux, созданный для аудиофилов, которым нужно бит-перфект Hi-Fi воспроизведение без ограничений частоты дискретизации, свойственных браузеру." + +msgid "Acknowledgments" +msgstr "Благодарности" + +msgid "Animated background renderer" +msgstr "Движок анимированного фона" + +msgid "Audio playback engine" +msgstr "Движок воспроизведения звука" + +msgid "Author" +msgstr "Автор" + +msgid "Build" +msgstr "Сборка" + +msgid "Build Info" +msgstr "Сведения о сборке" + +msgid "Codename" +msgstr "Кодовое имя" + +msgid "Contributors" +msgstr "Участники" + +msgid "Hatred towards all those companies that discriminate against the Linux community and don't provide us with a decent client for their product." +msgstr "С неприязнью ко всем тем компаниям, которые дискриминируют сообщество Linux и не дают нам достойного клиента для своего продукта." + +msgid "Icon library" +msgstr "Библиотека иконок" + +msgid "License" +msgstr "Лицензия" + +msgid "Lyrics API" +msgstr "API текстов песен" + +msgid "Made with" +msgstr "Сделано с" + +msgid "Music link aggregation API" +msgstr "API агрегации музыкальных ссылок" + +msgid "Music metadata database" +msgstr "База данных музыкальных метаданных" + +msgid "Music streaming service and API" +msgstr "Сервис музыкального стриминга и API" + +msgid "Platform" +msgstr "Платформа" + +msgid "Release" +msgstr "Релиз" + +msgid "Release notes are not available." +msgstr "Примечания к релизу недоступны." + +msgid "Synced lyrics provider" +msgstr "Поставщик синхронизированных текстов песен" + +msgid "UI framework" +msgstr "UI-фреймворк" + +msgid "Version" +msgstr "Версия" + +msgid "Website" +msgstr "Веб-сайт" + +msgid "What's new" +msgstr "Что нового" + +msgid "What's new in v{}" +msgstr "Что нового в v{}" + +msgid "in" +msgstr "на" + +msgid "QBZ uses your Last.fm and ListenBrainz account data when they're connected (if they aren't, we recommend connecting them to enrich this section). Here you set how often that data is fetched — it defaults to 48 hours, or you can request an update right now." +msgstr "QBZ использует данные ваших аккаунтов Last.fm и ListenBrainz, когда они подключены (если нет, рекомендуем подключить их, чтобы наполнить этот раздел). Здесь вы задаёте, как часто загружаются эти данные — по умолчанию каждые 48 часов, или вы можете запросить обновление прямо сейчас." + +msgid "A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback." +msgstr "Нативный клиент музыкального стриминга для Linux, созданный для аудиофилов, которым нужно бит-перфект Hi-Fi воспроизведение." + +msgid "Built with Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, Lucide, and Kawarp." +msgstr "Создано на Slint, работает на Rodio и Symphonia. Благодарим Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, Lucide и Kawarp." + +msgid "Extra large" +msgstr "Очень большой" + +msgid "INTERFACE" +msgstr "ИНТЕРФЕЙС" + +msgid "Interface size" +msgstr "Размер интерфейса" + +msgid "Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)" +msgstr "Масштабирует весь интерфейс, как масштаб страницы в браузере. Маленький вмещает больше контента на экране, Большой и Очень большой улучшают читаемость (требуется перезапуск)" + +msgid "Interface size changed — restart QBZ to apply" +msgstr "Размер интерфейса изменён — перезапустите QBZ, чтобы применить" + +msgid "Extra small" +msgstr "Очень маленький" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Войти через браузер" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Продолжите в веб-браузере — QBZ ждёт, пока вы завершите вход." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "Браузер может открыться в фоне, не перехватывая фокус — проверьте другие окна." + +msgid "Signing you in…" +msgstr "Выполняется вход…" + +msgid "Sign-in failed" +msgstr "Не удалось войти" + +msgid "About QBZ" +msgstr "О QBZ" + +msgid "Already used by" +msgstr "Уже используется:" + +msgid "Auto (recommended)" +msgstr "Авто (рекомендуется)" + +msgid "Discrete GPU (higher performance)" +msgstr "Дискретный GPU (выше производительность)" + +msgid "Integrated GPU (lower power)" +msgstr "Встроенный GPU (меньше энергии)" + +msgid "Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)" +msgstr "Авто выбирает лучший рендерер для вашей видеокарты. Меняйте только если приложение работает медленно или отображается неправильно (требуется перезапуск)" + +msgid "Customize Shortcuts" +msgstr "Настроить сочетания" + +msgid "GPU" +msgstr "GPU" + +msgid "GPU (compatibility)" +msgstr "GPU (совместимость)" + +msgid "Go" +msgstr "Перейти" + +msgid "Line Bed" +msgstr "Line Bed" + +msgid "Tunnel Flow" +msgstr "Tunnel Flow" + +msgid "Open Music Link" +msgstr "Открыть музыкальную ссылку" + +msgid "Open Playlist Importer" +msgstr "Открыть импорт плейлистов" + +msgid "Open Qobuz Link" +msgstr "Открыть ссылку Qobuz" + +msgid "Paste a Qobuz, Spotify, Apple Music, Tidal, Deezer or song.link URL" +msgstr "Вставьте ссылку Qobuz, Spotify, Apple Music, Tidal, Deezer или song.link" + +msgid "Place the Purchases entry in the custom title bar instead of the sidebar" +msgstr "Размещает раздел «Покупки» в собственной строке заголовка вместо боковой панели" + +msgid "Press keys…" +msgstr "Нажмите клавиши…" + +msgid "Purchases" +msgstr "Покупки" + +msgid "Purchases in title bar" +msgstr "Покупки в строке заголовка" + +msgid "RENDERER" +msgstr "РЕНДЕРЕР" + +msgid "Rendering backend" +msgstr "Бэкенд рендеринга" + +msgid "Reset All to Defaults" +msgstr "Сбросить всё" + +msgid "Resolving…" +msgstr "Обработка…" + +msgid "Software" +msgstr "Программный" + +msgid "Spectral Ribbon" +msgstr "Spectral Ribbon" + +msgid "This looks like a playlist." +msgstr "Похоже, это плейлист." + +msgid "Wave Bed" +msgstr "Wave Bed" + +msgid "What's New" +msgstr "Что нового" + +msgid "modified" +msgstr "изменено" + +msgid "Block this album" +msgstr "Заблокировать этот альбом" + +msgid "Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed." +msgstr "Заблокированные альбомы скрываются из поиска, рекомендаций и списков — даже если их исполнитель разрешён." + +msgid "No blocked albums" +msgstr "Нет заблокированных альбомов" + +msgid "This will remove all {} blocked albums. This cannot be undone." +msgstr "Будут удалены все заблокированные альбомы ({}). Это действие нельзя отменить." + +msgid "To block an album, open its menu and choose Block this album." +msgstr "Чтобы заблокировать альбом, откройте его меню и выберите «Заблокировать этот альбом»." + +msgid "Unblock album" +msgstr "Разблокировать альбом" + +msgid "{} albums" +msgstr "{} альбомов" + +msgid "{} artists, {} albums blocked" +msgstr "Заблокировано: {} исполнителей, {} альбомов" + +msgid "{} artists, {} albums blocked (disabled)" +msgstr "Заблокировано: {} исполнителей, {} альбомов (отключено)" + +msgid "A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases." +msgstr "Персональная вкладка «Обзор» на основе вашего прослушивания. Для лучших результатов подключите Last.fm и ListenBrainz ниже — MusicBrainz используется автоматически для сопоставления релизов." + +msgid "Based on what you've been into lately" +msgstr "На основе того, что вы слушали в последнее время" + +msgid "Built with Rust and Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, and Lucide." +msgstr "Создано на Rust и Slint, работает на Rodio и Symphonia. Благодарим Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh и Lucide." + +msgid "Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations." +msgstr "Подключите Last.fm или ListenBrainz в настройках или слушайте больше музыки, чтобы получить персональные рекомендации." + +msgid "JACK is not bit-perfect" +msgstr "JACK не является bit-perfect" + +msgid "More like the artists you love" +msgstr "Похожее на любимых исполнителей" + +msgid "No recommendations yet" +msgstr "Рекомендаций пока нет" + +msgid "Popular artists on Qobuz" +msgstr "Популярные исполнители на Qobuz" + +msgid "RECOMMENDATIONS" +msgstr "РЕКОМЕНДАЦИИ" + +msgid "Recommendations" +msgstr "Рекомендации" + +msgid "Show Recommendations in Discover" +msgstr "Показывать рекомендации в «Обзоре»" + +msgid "The JACK backend routes audio through the JACK graph, which resamples to the graph's sample rate. For bit-perfect playback use ALSA (direct) or PipeWire with passthrough." +msgstr "Бэкенд JACK направляет звук через граф JACK, который передискретизирует его в частоту графа. Для bit-perfect воспроизведения используйте ALSA (напрямую) или PipeWire с passthrough." + +msgid "Weekly Exploration" +msgstr "Открытия недели" + +msgid "Weekly Jams" +msgstr "Любимое за неделю" + +msgid "Keep your system's native window decorations. Turn off to use the QBZ header as the title bar, with its own window controls and drag support. Takes effect after restarting QBZ." +msgstr "Сохраняет системное оформление окна. Отключите, чтобы использовать шапку QBZ как строку заголовка — с собственными кнопками управления окном и перетаскиванием. Вступает в силу после перезапуска QBZ." + +msgid "Frameless window without window controls or header drag (for tiling window manager users)" +msgstr "Окно без рамки, без кнопок управления и перетаскивания за шапку (для пользователей тайловых оконных менеджеров)" + +msgid "Title bar mode changed — restart QBZ to apply" +msgstr "Режим строки заголовка изменён — перезапустите QBZ, чтобы применить" + +# Compact header navigation (opt-in, 2026-07-03) +msgid "Compact header navigation" +msgstr "Компактная навигация в заголовке" + +msgid "Use the icon-only section navigation in the header even while the sidebar is open." +msgstr "Использовать навигацию по разделам только со значками в заголовке, даже когда боковая панель открыта." + +# DSD support Phase 1 (2026-07-03) +msgid "DSD: {}" +msgstr "DSD: {}" + +msgid "Cannot play DSD file" +msgstr "Не удаётся воспроизвести файл DSD" + +# DSD Phases 2-3: delivery mode (2026-07-03) +msgid "DSD playback" +msgstr "Воспроизведение DSD" + +msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC." +msgstr "Как DSD-треки попадают на ЦАП. ВНИМАНИЕ: выбирайте DoP или Native только если ваш ЦАП это поддерживает — на любом другом ЦАПе это воспроизводится как громкий шум. Громкость фиксирована в режиме DoP/Native. Native дополнительно требует поддержки ядра для ЦАПа." + +msgid "Convert to PCM (works everywhere)" +msgstr "Конвертировать в PCM (работает везде)" + +msgid "DoP — DSD over PCM (bit-perfect)" +msgstr "DoP — DSD поверх PCM (бит-перфект)" + +msgid "Native DSD (kernel support required)" +msgstr "Нативный DSD (требуется поддержка ядра)" + +msgid "Date added" +msgstr "Дата добавления" + +msgid "Renderer setting reverted to Auto — the previous start didn't finish" +msgstr "Настройка рендерера возвращена на «Авто» — предыдущий запуск не завершился" + +msgid "Renderer switched to GPU (compatibility) — the GPU renderer failed to start" +msgstr "Рендерер переключён на GPU (совместимость) — GPU-рендерер не смог запуститься" + +msgid "Audio output error" +msgstr "Ошибка аудиовывода" + +msgid "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default" +msgstr "Прямой доступ к устройствам ALSA заблокирован в Flatpak/Snap — переключите аудиобэкенд на PipeWire или системный вывод" + +msgid "Auto theme generation failed" +msgstr "Не удалось сгенерировать автоматическую тему" + +msgid "Play all tracks" +msgstr "Воспроизвести все треки" + +msgid "Shuffle all tracks" +msgstr "Перемешать все треки" + +msgid "Select multiple tracks" +msgstr "Выбрать несколько треков" + +msgid "Play a random album" +msgstr "Воспроизвести случайный альбом" + +msgid "Open a random artist" +msgstr "Открыть случайного исполнителя" + +msgid "Play a random playlist" +msgstr "Воспроизвести случайный плейлист" + +msgid "Open a random label" +msgstr "Открыть случайный лейбл" + +msgid "Start from current theme" +msgstr "Начать с текущей темы" + +msgid "Copy the colors of the currently applied theme into the editor as a starting point." +msgstr "Копирует цвета текущей темы в редактор как отправную точку." + +msgid "Use current colors" +msgstr "Использовать текущие цвета" + +msgid "Dark theme" +msgstr "Тёмная тема" + +msgid "Set the overall light or dark polarity. Affects derived shades, borders and overlays." +msgstr "Задаёт общую светлую или тёмную полярность. Влияет на производные оттенки, границы и наложения." + +msgid "SURFACES" +msgstr "ПОВЕРХНОСТИ" + +msgid "Background" +msgstr "Фон" + +msgid "Surface" +msgstr "Поверхность" + +msgid "Elevated" +msgstr "Приподнятый" + +msgid "TEXT" +msgstr "ТЕКСТ" + +msgid "Text" +msgstr "Текст" + +msgid "Secondary text" +msgstr "Вторичный текст" + +msgid "ACCENT" +msgstr "АКЦЕНТ" + +msgid "Accent" +msgstr "Акцент" + +msgid "Danger" +msgstr "Опасность" + +msgid "Warning" +msgstr "Предупреждение" + +msgid "Success" +msgstr "Успех" + +msgid "OTHER" +msgstr "ДРУГОЕ" + +msgid "Border" +msgstr "Граница" + +# Unavailable-track auto-skip (Tauri #467 parity) +msgid "No available tracks to play" +msgstr "Нет доступных треков для воспроизведения" +# Always-visible log uploader entry (settings sub-nav footer) +msgid "Share logs" +msgstr "Поделиться журналами" +# Guest local-only mode disclaimer line (login screen, #553) +msgid "QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library)." +msgstr "QBZ можно использовать как офлайн-плеер без учётной записи Qobuz™ (без доступа к библиотеке Qobuz™)." +# Renderer degradation ladder toast (generic: GL or software rung) +msgid "Renderer switched automatically — the previous renderer failed to start" +msgstr "Рендерер переключён автоматически — предыдущий не смог запуститься" + +# QConnect startup behavior (Settings > Playback, ported from Tauri) +msgid "Auto-connect Qobuz Connect on startup" +msgstr "Автоматически подключать Qobuz Connect при запуске" +msgid "Choose whether Qobuz Connect activates automatically when QBZ launches." +msgstr "Выберите, включается ли Qobuz Connect автоматически при запуске QBZ." +msgid "Remember state" +msgstr "Запоминать состояние" +msgid "On by default" +msgstr "Включено по умолчанию" +msgid "Off by default" +msgstr "Выключено по умолчанию" + +msgid "QBZ recovered from repeated startup crashes — some restored state was reset" +msgstr "QBZ восстановился после повторных сбоев при запуске — часть восстановленного состояния была сброшена" + +# QConnect device name (Settings > Playback, ported from Tauri) +msgid "Qobuz Connect device name" +msgstr "Имя устройства в Qobuz Connect" + +msgid "The name other Qobuz Connect apps see for this device. Applies on the next connection." +msgstr "Имя, под которым это устройство видят другие приложения Qobuz Connect. Применяется при следующем подключении." + +# Quality downgrade tooltip (#590 follow-up) +msgid "Source: {}" +msgstr "Источник: {}" + +msgid "Output: {}" +msgstr "Вывод: {}" + +# Local Library header Plex sync button (#573) +msgid "Sync Plex library" +msgstr "Синхронизировать библиотеку Plex" + +# Library Albums sort options (#566) +msgid "Recently added" +msgstr "Недавно добавленные" + +msgid "First added" +msgstr "Добавленные первыми" + +msgid "Random" +msgstr "Случайно" + + +# Offline cache clear — communicate it does not remove downloads (#606) +msgid "Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above." +msgstr "Освобождает кэшированные данные. Загруженные альбомы сохраняются — удаляйте их в офлайн-менеджере выше." + +msgid "Couldn't clear the cache" +msgstr "Не удалось очистить кэш" + + +# Task 18 — Settings > Developer > Export settings modal (daemon 04 §4.2) +msgid "SETTINGS PORTABILITY" +msgstr "ПЕРЕНОС НАСТРОЕК" + +msgid "Export settings…" +msgstr "Экспорт настроек…" + +msgid "Save a portable bundle of your settings to move to another machine or the qbzd daemon." +msgstr "Сохраняет переносимый набор ваших настроек для переноса на другой компьютер или в демон qbzd." + +msgid "Export…" +msgstr "Экспорт…" + +msgid "Export settings" +msgstr "Экспорт настроек" + +msgid "Save your settings as a bundle to import on the qbzd daemon or another machine." +msgstr "Сохраните настройки в виде набора для импорта на демон qbzd или другой компьютер." + +msgid "Include Qobuz login and integration tokens" +msgstr "Включить вход в Qobuz и токены интеграций" + +msgid "Anyone with the file can use your accounts. It is saved readable only by you; delete it after importing." +msgstr "Любой, кто получит файл, сможет пользоваться вашими аккаунтами. Он сохраняется доступным для чтения только вам; удалите его после импорта." + +msgid "Export" +msgstr "Экспорт" + +msgid "Bundle saved. On the daemon box:" +msgstr "Набор сохранён. На машине демона:" + +msgid "Could not export settings" +msgstr "Не удалось экспортировать настройки" + + +# Share menu Song.link/Album.link toasts (#514) +msgid "Fetching Song.link..." +msgstr "Получение Song.link..." + +msgid "Fetching Album.link..." +msgstr "Получение Album.link..." + +msgid "Link copied" +msgstr "Ссылка скопирована" + +msgid "Failed to copy link" +msgstr "Не удалось скопировать ссылку" + +# Dynamic app background (Appearance settings) +msgid "Ambient" +msgstr "Атмосферный" + +msgid "Blurred art" +msgstr "Размытая обложка" + +msgid "Dynamic background" +msgstr "Динамический фон" + +msgid "Animated album-art background behind the whole app. High resource use — GPU accelerated." +msgstr "Анимированный фон из обложки альбома за всем приложением. Высокое потребление ресурсов — аппаратное ускорение GPU." + +# Preferred GPU (Renderer settings) +msgid "Preferred GPU" +msgstr "Предпочитаемый GPU" + +msgid "Which GPU renders the app. Only the GPUs actually present are listed. Choosing one other than the default also switches the renderer to Vulkan — the only backend Qt can select a GPU on (requires restart)" +msgstr "Какой GPU отрисовывает приложение. Перечислены только реально присутствующие GPU. Выбор отличного от стандартного также переключает отрисовщик на Vulkan — единственный бэкенд, где Qt может выбрать GPU (нужен перезапуск)" + +msgid "Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)" +msgstr "Какой GPU отрисовывает приложение. На гибридном ноутбуке выбор дискретного GPU переносит нагрузку динамического фона со встроенного (меньше нагрев, но выше энергопотребление). Рекомендуется Авто (требуется перезапуск)" + +msgid "discrete" +msgstr "дискретный" + +msgid "integrated" +msgstr "встроенный" + +msgid "Preferred GPU changed — restart QBZ to apply" +msgstr "Предпочитаемый GPU изменён — перезапустите QBZ, чтобы применить" + +msgid "Selected GPU is unavailable in this display session — using Auto" +msgstr "Выбранный GPU недоступен в этом сеансе отображения — используется авто" + +msgid "Renderer changed — restart QBZ to apply" +msgstr "Отрисовщик изменён — перезапустите QBZ, чтобы применить" + +msgid "Search your library" +msgstr "Поиск в библиотеке" + +msgid "From catalog" +msgstr "Из каталога" + +msgid "In library" +msgstr "В библиотеке" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Добавить в избранное" + +msgid "Remove from favorites" +msgstr "Удалить из избранного" + +msgid "Start radio" +msgstr "Запустить радио" + +msgid "Booklet" +msgstr "Буклет" + +msgid "Add to Mixtape" +msgstr "Добавить в Mixtape" + +msgid "Album info" +msgstr "Об альбоме" + +msgid "More options" +msgstr "Ещё" + +msgid "Follow artist" +msgstr "Подписаться на исполнителя" + +msgid "Unfollow" +msgstr "Отписаться" + +msgid "Artist network" +msgstr "Сеть исполнителя" + +msgid "Upload to Qobuz" +msgstr "Загрузить в Qobuz" + +msgid "Select tracks" +msgstr "Выбрать треки" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz временно отклоняет запросы — пауза. Повторите попытку чуть позже." + + +msgid "Flatpak" +msgstr "Flatpak" + +msgid "Snap" +msgstr "Snap" + +msgid "FLATPAK SANDBOX" +msgstr "ПЕСОЧНИЦА FLATPAK" + +msgid "SNAP SANDBOX" +msgstr "ПЕСОЧНИЦА SNAP" + +msgid "QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates." +msgstr "QBZ работает внутри песочницы Flatpak. Некоторым функциям нужны разрешения, выдаваемые один раз, — выполните команды в терминале и перезапустите QBZ. Разрешения сохраняются между обновлениями." + +msgid "QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ." +msgstr "QBZ работает внутри песочницы Snap. Некоторые аудиоинтерфейсы нужно подключить один раз — выполните эти команды в терминале и перезапустите QBZ." + +msgid "Exclusive DAC access (bit-perfect)" +msgstr "Эксклюзивный доступ к ЦАП (bit-perfect)" + +msgid "Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected." +msgstr "Рекомендуется для эксклюзивного режима: позволяет QBZ попросить PipeWire корректно передать ЦАП (резервирование устройства через D-Bus). Без этого PipeWire удерживает ЦАП и другие приложения продолжают микшировать через него даже при выбранном ALSA + hw + Exclusive." + +msgid "Music on NAS or external drives" +msgstr "Музыка на NAS или внешних дисках" + +msgid "Grants access to a folder outside the sandbox. Repeat the command for each folder you need." +msgstr "Даёт доступ к папке вне песочницы. Повторите команду для каждой нужной папки." + +msgid "Required audio connections" +msgstr "Необходимые аудиоподключения" + +msgid "Optional — external drives & NAS" +msgstr "Опционально — внешние диски и NAS" + +msgid "Copy" +msgstr "Копировать" + +msgid "Cut" +msgstr "Вырезать" + +msgid "Paste" +msgstr "Вставить" + +msgid "Copied!" +msgstr "Скопировано!" + +msgid "Limited by your streaming quality setting" +msgstr "Ограничено вашей настройкой качества потока" + +msgid "Qobuz has no higher quality for this track" +msgstr "Qobuz не предлагает более высокого качества для этого трека" + +msgid "offline copy — cap not applied" +msgstr "офлайн-копия — ограничение не применяется" + +msgid "cached copy — cap not applied" +msgstr "копия из кэша — ограничение не применяется" + +msgid "Limited by your output device's limit" +msgstr "Ограничено пределом вашего устройства вывода" + +msgid "Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting." +msgstr "Ограничивает запрашиваемое качество стриминга пределом вашего устройства вывода. Применяется только к локальному воспроизведению, никогда — к трансляции." + +msgid "Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly." +msgstr "Не удалось прочитать точные пределы вашего устройства в этой системе, поэтому используется типовой набор. Ограничение всё равно применяется, но может не совпадать точно с вашим оборудованием." + +msgid "Detected device limit" +msgstr "Обнаруженный предел устройства" + +msgid "Quality for this device" +msgstr "Качество для этого устройства" + +msgid "Follow app setting ({})" +msgstr "Следовать настройке приложения ({})" + +msgid "Hi-Res — 24-bit, up to 96 kHz" +msgstr "Hi-Res — 24 бит, до 96 кГц" + +msgid "CD — 16-bit / 44.1 kHz" +msgstr "CD — 16 бит / 44.1 кГц" + +msgid "MP3 320" +msgstr "MP3 320" + +msgid "Qobuz only offers these tiers — there is no 48 kHz option. The step below Hi-Res is CD 44.1 kHz." +msgstr "Qobuz предлагает только эти уровни — варианта 48 кГц нет. Ступень ниже Hi-Res — CD 44.1 кГц." + +msgid "Limited by the quality cap for {}" +msgstr "Ограничено пределом качества для {}" + +msgid "capped for this device" +msgstr "ограничено для этого устройства" + +msgid "Albums by folder" +msgstr "Альбомы по папкам" + +msgid "Albums by metadata" +msgstr "Альбомы по метаданным" + +msgid "ALBUMS VIEW" +msgstr "ВИД АЛЬБОМОВ" + +msgid "Album grouping" +msgstr "Группировка альбомов" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Папки: одна карточка на папку альбома — лучше для сборников и бокс-сетов. Метаданные: разделение по тегам альбома + исполнителя." + +msgid "+{} more artists" +msgstr "+ ещё {} исполнителей" + +msgid "Show less" +msgstr "Показать меньше" +# Most Played Albums rail title +msgid "Most Played Albums" +msgstr "Самые прослушиваемые альбомы" + +# Most Played Albums View-all — per-card play count +msgid "{} plays" +msgstr "{} воспроизведений" + +# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast +msgid "Show lyrics translation" +msgstr "Показать перевод текста" + +msgid "Hide lyrics translation" +msgstr "Скрыть перевод текста" + +msgid "Translation language" +msgstr "Язык перевода" + +msgid "Auto (account language)" +msgstr "Автоматически (язык аккаунта)" + +msgid "Translation is not available right now. Please try again later." +msgstr "Перевод сейчас недоступен. Попробуйте ещё раз позже." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Отклонённые исполнители исчезают только из Рекомендаций — они по-прежнему видны в поиске и повсюду остальном." + +msgid "No dismissed artists" +msgstr "Нет отклонённых исполнителей" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Чтобы больше не видеть исполнителя в Рекомендациях, откройте меню его карточки и выберите Не интересно." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} больше не появится в Рекомендациях" + +msgid "Artist dismissed from Recommendations" +msgstr "Исполнитель отклонён из Рекомендаций" + +msgid "{} restored to Recommendations" +msgstr "{} возвращён в Рекомендации" + +msgid "LIBRARY › ALL" +msgstr "БИБЛИОТЕКА › ВСЁ" + +msgid "Show local files & Plex" +msgstr "Показывать локальные файлы и Plex" + +msgid "Include your favorited local files and Plex items in the Library All feed." +msgstr "Показывать избранные локальные файлы и элементы Plex в ленте «Всё» библиотеки." + +msgid "Play indicator animation" +msgstr "Анимация индикатора воспроизведения" + +msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU." +msgstr "Анимирует строку воспроизведения полосами эквалайзера. Выкл. (по умолчанию) — статичный значок паузы с акцентной меткой на краю строки, легче для CPU." + +msgid "Invert swipe navigation direction" +msgstr "Инвертировать направление жеста навигации" + +msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward." +msgstr "Меняет местами свайп двумя пальцами по тачпаду: влево — назад, вправо — вперёд." + +msgid "Local items in Library All" +msgstr "Локальные элементы в «Всё» библиотеки" + +msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track." +msgstr "Только избранное: отмеченное вами. Вся локальная библиотека: каждый альбом, исполнитель и трек из папок и Plex." + +msgid "Favorited items only" +msgstr "Только избранное" + +msgid "Entire local library" +msgstr "Вся локальная библиотека" + +msgid "Select multiple albums" +msgstr "Выбрать несколько альбомов" + +msgid "Play later" +msgstr "Слушать позже" + +msgid "Added to play later" +msgstr "Добавлено «на позже»" + +msgid "Next in queue" +msgstr "Далее в очереди" + +msgid "Next up" +msgstr "Далее" + +msgid "Removed {} tracks from favorites" +msgstr "{} треков удалено из избранного" + +msgid "Removing {} tracks from favorites…" +msgstr "Удаление {} треков из избранного…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Альбом «{}» заблокирован" + +msgid "Album \"{}\" unblocked" +msgstr "Альбом «{}» разблокирован" + +msgid "Failed to block album" +msgstr "Не удалось заблокировать альбом" + +msgid "Failed to unblock album" +msgstr "Не удалось разблокировать альбом" + +msgid "Failed to clear album blacklist" +msgstr "Не удалось очистить чёрный список альбомов" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "Удалён {} альбом из чёрного списка" +msgstr[1] "Удалено {} альбома из чёрного списка" +msgstr[2] "Удалено {} альбомов из чёрного списка" + +msgid "Playlist imported" +msgstr "Плейлист импортирован" + +msgid "Playlist import failed" +msgstr "Не удалось импортировать плейлист" + +msgid "Not logged in to Qobuz" +msgstr "Вход в Qobuz не выполнен" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Удалить папку" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Удалить папку «{}»? Её плейлисты вернутся в корень." + +msgid "Choose a folder image" +msgstr "Выберите изображение папки" + +msgid "Images" +msgstr "Изображения" + +msgid "Failed to create folder" +msgstr "Не удалось создать папку" + +msgid "Failed to save folder" +msgstr "Не удалось сохранить папку" + +msgid "Failed to delete folder" +msgstr "Не удалось удалить папку" + +msgid "Failed to rename playlist" +msgstr "Не удалось переименовать плейлист" + +msgid "Failed to delete playlist" +msgstr "Не удалось удалить плейлист" + +msgid "Couldn't create the playlist" +msgstr "Не удалось создать плейлист" + +# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard, +# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. +msgid "Open playlist" +msgstr "Открыть плейлист" + +# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut +# action labels + category names for the cheatsheet and the customize editor. +# Slint's Rust-side labels were never extracted (the port inherits the gap); +# this block fixes it for both frontends. en is EXCLUDED on purpose +# (sparse-override convention, qbz-i18n/src/lib.rs:120-125). + +msgid "Play / Pause" +msgstr "Воспроизведение / Пауза" + +msgid "Go Back" +msgstr "Назад" + +msgid "Go Forward" +msgstr "Вперёд" + +msgid "Toggle Sidebar" +msgstr "Переключить боковую панель" + +msgid "Immersive Mode" +msgstr "Иммерсивный режим" + +msgid "Close / Dismiss" +msgstr "Закрыть / Скрыть" + +msgid "Show Shortcuts" +msgstr "Показать сочетания клавиш" + +msgid "Toggle Mini Player" +msgstr "Переключить мини-плеер" + +msgid "Seek Forward (5s)" +msgstr "Вперёд на 5 с" + +msgid "Seek Back (5s)" +msgstr "Назад на 5 с" + +msgid "Seek Forward (10s)" +msgstr "Вперёд на 10 с" + +msgid "Seek Back (10s)" +msgstr "Назад на 10 с" + +msgid "Micro View" +msgstr "Микровид" + +msgid "Compact View" +msgstr "Компактный вид" + +msgid "Artwork View" +msgstr "Вид обложки" + +msgid "Listen list" +msgstr "Список прослушивания" + +msgid "Lyrics View" +msgstr "Вид текста песни" + +msgid "Navigation" +msgstr "Навигация" + +msgid "Interface" +msgstr "Интерфейс" + +msgid "Mini Player" +msgstr "Мини-плеер" + + +# Kiosk shell (2026-08-02 kiosk-port contract) + +msgid "Cover" +msgstr "Обложка" + +msgid "Radio" +msgstr "Радио" + +msgid "Up Next" +msgstr "Далее" + +# Kiosk <-> Desktop profile toggle (view-mode menu) + +msgid "Kiosk mode" +msgstr "Режим киоска" + +msgid "Desktop mode" +msgstr "Режим рабочего стола" + +# AlbumView cover menu / lightbox (Qt port, 2026-08-10) + +msgid "View cover" +msgstr "Показать обложку" + +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Показать изображение" + +msgid "Could not load the image" +msgstr "Не удалось загрузить изображение" + +# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's +# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen +# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter", +# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On", +# "Bands & Projects", "Load more", "Loading…", "Artist Scene", +# "No artists found for this scene.", "No album appearances found." and +# "Select an artist to see their albums." (period-only delta) already exist. +# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7, +# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them +# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every +# call site substitutes explicitly, e.g. .replace("{artist}", name). + +msgid "Based on {artist} · {genres}" +msgstr "На основе {artist} · {genres}" + +msgid "Searching MusicBrainz for {genre} artists..." +msgstr "Поиск исполнителей жанра {genre} в MusicBrainz..." + +msgid "Verifying availability on Qobuz..." +msgstr "Проверка доступности в Qobuz..." + +msgid "Preparing results..." +msgstr "Подготовка результатов..." + +msgid "No matching artists from this scene are currently available on Qobuz." +msgstr "Подходящих исполнителей из этой сцены сейчас нет в Qobuz." + +msgid "Loading albums..." +msgstr "Загрузка альбомов..." + +msgid "Failed to load albums" +msgstr "Не удалось загрузить альбомы" + +msgid "Failed to load appearances" +msgstr "Не удалось загрузить участия" + +msgid "Looking up {name}..." +msgstr "Поиск {name}..." + +msgid "Failed to look up musician" +msgstr "Не удалось найти музыканта" + +msgid "Browse view" +msgstr "Режим обзора" + +msgid "Grid view" +msgstr "Режим сетки" + +msgid "Alphabetical (A-Z)" +msgstr "По алфавиту (А–Я)" + +msgid "This musician appears in album credits but has limited catalog information available in Qobuz." +msgstr "Этот музыкант указан в авторах альбома, но в Qobuz доступно мало информации о его каталоге." + +msgid "Unable to verify this musician's catalog. The information shown is based on album credits." +msgstr "Не удалось проверить каталог этого музыканта. Показанная информация основана на данных об авторах альбома." + +msgid "This musician appears in album credits." +msgstr "Этот музыкант указан в авторах альбома." + +msgid "Discography" +msgstr "Дискография" + +msgid "Live Albums" +msgstr "Концертные альбомы" + +msgid "Select an artist to see their albums" +msgstr "Выберите исполнителя, чтобы увидеть его альбомы" + +msgid "{} / {} artists" +msgstr "{} / {} исполнителей" + +# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17 +# §8-6). "Unavailable" and "This track is no longer available" already +# exist in this file and are REUSED by the render and the reactive skip toast +# — nothing below twins them. +# +# The skipped-tracks toast is a plural PAIR, per the convention already set by +# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build +# drops N rows at once, and a singular-only string reads wrong in every locale +# with plural rules. Russian carries all three forms. +# +# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled +# state (contract §5.3/F5): the track left the catalogue but the user owns a +# downloaded copy, so it still plays. It cannot fold into "This track is no +# longer available" — that sentence would be a lie on a row playing right now. +# +# Em dash is U+2014 with spaces on both sides, as everywhere else in this file. +# nplurals=3 — all three forms, like every other plural entry in this file. +msgid "Find available version" +msgstr "Найти доступную версию" + +msgid "Replace unavailable track" +msgstr "Заменить недоступный трек" + +msgid "No available version found" +msgstr "Доступная версия не найдена" + +msgid "Track replaced" +msgstr "Трек заменён" + +msgid "Could not replace the track" +msgstr "Не удалось заменить трек" + +msgid "The replacement was added at the end of the playlist" +msgstr "Замена добавлена в конец плейлиста" + +msgid "{} track skipped — no longer available" +msgid_plural "{} tracks skipped — no longer available" +msgstr[0] "Пропущен {} трек — он больше недоступен" +msgstr[1] "Пропущено {} трека — они больше недоступны" +msgstr[2] "Пропущено {} треков — они больше недоступны" + +msgid "No longer on Qobuz — playing your downloaded copy" +msgstr "Больше нет в Qobuz — воспроизводится скачанная копия" + +msgid "Exact match" +msgstr "Точное совпадение" + +msgid "Low confidence" +msgstr "Низкая точность" + +msgid "Replace" +msgstr "Заменить" + +msgid "Replacing..." +msgstr "Замена…" + +msgid "The replacement takes the unavailable track's place in the playlist." +msgstr "Замена займёт место недоступного трека в плейлисте." + +# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19) +msgid "Items per carousel" +msgstr "Элементов в карусели" + +# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19) +msgid "Recently Played Playlists" +msgstr "Недавно прослушанные плейлисты" + +msgid "Playlists you play will appear here." +msgstr "Здесь появятся плейлисты, которые вы слушаете." + +# Applied-filters tooltip (2026-08-19) +msgid "+{} more" +msgstr "+{} ещё" + +msgid "+{} more filters" +msgstr "+{} фильтров ещё" + +msgid "Applied filters" +msgstr "Применённые фильтры" + +# Applied-filters tooltip: group labels (2026-08-19) +msgid "Category" +msgstr "Категория" + +# Applied-filters tooltip: Library source switches (2026-08-19) +msgid "Favorites" +msgstr "Избранное" + +# Discover configurator: Refresh content (2026-08-19) +msgid "Refresh content" +msgstr "Обновить содержимое" + +# Appearance: click-on-section opens the first tab (2026-08-19) +msgid "Click on menu item navigates to first tab" +msgstr "Клик по разделу открывает его первую вкладку" + +msgid "Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu" +msgstr "Клик по разделу в боковой панели или заголовке открывает его на первой вкладке — Discover на Home, Library на All — вместо показа только меню" + +# Discover configurator: per-rail default item count (2026-08-19) +msgid "Default ({})" +msgstr "По умолчанию ({})" + +# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources + +msgid "Streaming URL" +msgstr "URL стриминга" + +msgid "Playlist file" +msgstr "Файл плейлиста" + +msgid "JSON file" +msgstr "Файл JSON" + +msgid "ListenBrainz" +msgstr "ListenBrainz" + +msgid "Last.fm" +msgstr "Last.fm" + +msgid "Choose file…" +msgstr "Выбрать файл…" + +msgid "No file selected" +msgstr "Файл не выбран" + +msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog." +msgstr "Читается только список треков — указанные аудиофайлы никогда не открываются, не копируются и не добавляются в локальную библиотеку. Каждая запись сопоставляется с каталогом Qobuz." + +msgid "JSON is read best-effort — check the track count below before importing." +msgstr "JSON читается по мере возможности — проверьте количество треков перед импортом." + +msgid "Could not read the file." +msgstr "Не удалось прочитать файл." + +msgid "No tracks found in the file." +msgstr "В файле не найдено треков." + +msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON." +msgstr "Формат файла не распознан — используйте XSPF, PLS, M3U/M3U8 или JSON." + +msgid "This looks like a live-stream manifest, not a playlist." +msgstr "Похоже, это манифест прямой трансляции, а не плейлист." + +msgid "That file is too large to import." +msgstr "Этот файл слишком велик для импорта." + +msgid "Could not recognize a track list in this JSON." +msgstr "Не удалось распознать список треков в этом JSON." + +msgid "Username or profile URL" +msgstr "Имя пользователя или URL профиля" + +msgid "Username or playlist URL" +msgstr "Имя пользователя или URL плейлиста" + +msgid "Play your library" +msgstr "Слушать вашу библиотеку" + +msgid "Play your mix" +msgstr "Слушать ваш микс" + +msgid "Play your recommendations" +msgstr "Слушать ваши рекомендации" + +msgid "Station" +msgstr "Станция" + +msgid "No playlists found for {}." +msgstr "Плейлисты для {} не найдены." + +msgid "Enter a public username." +msgstr "Введите публичное имя пользователя." + +msgid "Duplicates: {}" +msgstr "Дубликаты: {}" + +msgid "Media" +msgstr "Носитель" + +msgid "Open audio CD" +msgstr "Открыть аудио-CD" + +msgid "Audio CD" +msgstr "Аудио-CD" + +msgid "No optical drive found." +msgstr "Оптический привод не найден." + +msgid "No disc in the drive." +msgstr "В приводе нет диска." + +msgid "That disc has no audio tracks." +msgstr "На этом диске нет аудиодорожек." + +msgid "Could not read the disc." +msgstr "Не удалось прочитать диск." + +msgid "Track {}" +msgstr "Дорожка {}" + +msgid "Open SACD image…" +msgstr "Открыть образ SACD…" + +msgid "Choose a disc image" +msgstr "Выберите образ диска" + +msgid "That image has no stereo audio area." +msgstr "В этом образе нет стереообласти." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Диск сжат в DST — QBZ пока не может его воспроизвести." + +msgid "That file is not a disc image." +msgstr "Это не образ диска." + +msgid "That file is not a SACD image." +msgstr "Это не образ SACD." + +msgid "That SACD image is damaged or unsupported." +msgstr "Этот образ SACD повреждён или не поддерживается." + +msgid "SACD" +msgstr "SACD" + +msgid "Library Explorer" +msgstr "Обозреватель библиотеки" + +msgid "Genres" +msgstr "Жанры" + +msgid "All genres" +msgstr "Все жанры" + +msgid "All albums" +msgstr "Все альбомы" + +msgid "No albums match these filters" +msgstr "Нет альбомов, соответствующих этим фильтрам" + +msgid "Try a different genre, artist, album, source or quality." +msgstr "Попробуйте выбрать другой жанр, исполнителя, альбом, источник или качество." + +msgid "Favorites only" +msgstr "Только избранное" + +msgid "Where should the ripped album go?" +msgstr "Куда сохранить извлечённый альбом?" + +msgid "Ripping" +msgstr "Извлечение" + +msgid "Nothing to rip." +msgstr "Нечего извлекать." + +msgid "Ripped {} track to your library" +msgid_plural "Ripped {} tracks to your library" +msgstr[0] "Извлечена {} дорожка в вашу библиотеку" +msgstr[1] "Извлечено {} дорожек в вашу библиотеку" +msgstr[2] "Извлечено {} дорожек в вашу библиотеку" + +# Discs: rip wizard, metadata lookup and the disc tooltips (2026-08-21) + +msgid "Play a folder, CD or disc image without adding it to your library" +msgstr "Воспроизвести папку, CD или образ диска, не добавляя их в медиатеку" + +msgid "Reading the disc…" +msgstr "Чтение диска…" + +msgid "Re-scan your folders and media servers for new music" +msgstr "Повторно просканировать папки и медиасерверы в поисках новой музыки" + +msgid "Manage the folders your library is built from" +msgstr "Управление папками, из которых собрана медиатека" + +msgid "Play the whole disc from the first track" +msgstr "Воспроизвести весь диск с первого трека" + +msgid "Play it in a shuffled order" +msgstr "Воспроизвести в случайном порядке" + +msgid "Look this disc up and correct its details" +msgstr "Найти этот диск и исправить его сведения" + +msgid "Copy this CD to your computer as FLAC files" +msgstr "Скопировать этот CD на компьютер в файлы FLAC" + +msgid "Close this disc and go back to your library" +msgstr "Закрыть этот диск и вернуться в медиатеку" + +msgid "Correct disc details" +msgstr "Исправить сведения о диске" + +msgid "SACD image" +msgstr "Образ SACD" + +msgid "Close without changing anything" +msgstr "Закрыть, ничего не меняя" + +msgid "Community database. Knows discs by their table of contents." +msgstr "Общественная база данных. Определяет диски по оглавлению." + +msgid "Collector database. Stronger on pressings, editions and catalogue numbers." +msgstr "База данных коллекционеров. Лучше знает прессы, издания и каталожные номера." + +msgid "Search this provider" +msgstr "Искать в этом источнике" + +msgid "The provider is rate-limiting right now. Try again in a moment." +msgstr "Источник сейчас ограничивает запросы. Попробуйте через минуту." + +msgid "No releases matched. Try fewer words, or the other provider." +msgstr "Совпадений нет. Попробуйте меньше слов или другой источник." + +msgid "Search for the release this disc actually is." +msgstr "Найдите издание, которым этот диск является на самом деле." + +msgid "Remove my correction" +msgstr "Удалить мою правку" + +msgid "Forget the saved details for this disc and look it up fresh next time" +msgstr "Забыть сохранённые сведения об этом диске и найти их заново в следующий раз" + +msgid "Applying..." +msgstr "Применение..." + +msgid "Use these details" +msgstr "Использовать эти сведения" + +msgid "Rename this disc and remember it for next time" +msgstr "Переименовать этот диск и запомнить на будущее" + +msgid "Rip this CD" +msgstr "Извлечь этот CD" + +msgid "What will happen" +msgstr "Что произойдёт" + +msgid "Track names" +msgstr "Названия треков" + +msgid "Your library" +msgstr "Ваша медиатека" + +msgid "Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags." +msgstr "Каждый трек диска считывается и записывается в файл FLAC: одна папка на альбом, один файл на трек, с тегами." + +msgid "Format: FLAC (lossless) — the only format QBZ writes" +msgstr "Формат: FLAC (без потерь) — единственный формат, который пишет QBZ" + +msgid "Where the album goes" +msgstr "Куда попадёт альбом" + +msgid "Choose folder…" +msgstr "Выбрать папку…" + +msgid "Change…" +msgstr "Изменить…" + +msgid "Pick the folder the album folder will be created in" +msgstr "Выберите папку, в которой будет создана папка альбома" + +msgid "Nothing chosen yet" +msgstr "Пока ничего не выбрано" + +msgid "This folder is already part of your Local Library." +msgstr "Эта папка уже входит в вашу локальную медиатеку." + +msgid "This folder is outside your Local Library, so the album will not appear there on its own." +msgstr "Эта папка вне вашей локальной медиатеки, поэтому альбом сам там не появится." + +msgid "Choose a destination first and this step will know what to offer." +msgstr "Сначала выберите папку — тогда этот шаг поймёт, что предложить." + +msgid "Re-scan that folder when the rip finishes" +msgstr "Просканировать эту папку заново, когда извлечение закончится" + +msgid "Only that folder, not your whole library." +msgstr "Только эту папку, а не всю медиатеку." + +msgid "Do nothing" +msgstr "Ничего не делать" + +msgid "The album appears the next time you scan." +msgstr "Альбом появится при следующем сканировании." + +msgid "Add this folder to Local Library and scan it" +msgstr "Добавить эту папку в локальную медиатеку и просканировать её" + +msgid "Future rips into the same folder will be picked up too." +msgstr "Последующие извлечения в эту же папку тоже будут найдены." + +msgid "The files are written; the library will not know about them." +msgstr "Файлы будут записаны, но медиатека о них не узнает." + +msgid "A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool." +msgstr "Простой риппер: он нужен, чтобы быстро добавить компакт-диски в медиатеку. Для проверки AccurateRip и продвинутых настроек используйте специализированную программу." + +msgid "Close without ripping anything" +msgstr "Закрыть, ничего не извлекая" + +msgid "Go back a step" +msgstr "Вернуться на шаг назад" + +msgid "Start ripping" +msgstr "Начать извлечение" + +msgid "Go to the next step" +msgstr "Перейти к следующему шагу" + +msgid "Read the disc and write the FLAC files" +msgstr "Прочитать диск и записать файлы FLAC" + +msgid "Choose a destination folder first" +msgstr "Сначала выберите папку назначения" + +msgid "No disc is open." +msgstr "Ни один диск не открыт." + +msgid "Couldn't load that release." +msgstr "Не удалось загрузить это издание." + +msgid "Disc details updated" +msgstr "Сведения о диске обновлены" + +msgid "Correction removed. Re-open the disc to look it up again." +msgstr "Правка удалена. Откройте диск заново, чтобы найти его снова." + +msgid "Only a physical CD can be ripped." +msgstr "Извлекать можно только физический CD." + +msgid "Choose where the album should go first." +msgstr "Сначала выберите, куда поместить альбом." + +msgid "Couldn't add that folder to the library" +msgstr "Не удалось добавить эту папку в медиатеку" + +# Rip progress panel, cancel and partial rips (2026-08-21) + +msgid "Writing FLAC files to" +msgstr "Файлы FLAC записываются в" + +msgid "Hide this panel — the rip keeps going" +msgstr "Скрыть эту панель — извлечение продолжится" + +msgid "Ripping — don't eject the disc until this finishes." +msgstr "Идёт извлечение — не вынимайте диск, пока оно не закончится." + +msgid "Finished" +msgstr "Готово" + +msgid "Files already written stay on disk. Delete them yourself if you don't want them." +msgstr "Уже записанные файлы останутся на диске. Если они не нужны, удалите их сами." + +msgid "Stop ripping" +msgstr "Остановить извлечение" + +msgid "Stopping…" +msgstr "Останавливается…" + +msgid "Stop after the current track. Nothing is deleted." +msgstr "Остановить после текущего трека. Ничего не удаляется." + +msgid "Rip stopped. The files already written are still there." +msgstr "Извлечение остановлено. Уже записанные файлы на месте." + +msgid "See what the rip is doing, or stop it" +msgstr "Посмотреть, что делает извлечение, или остановить его" + +msgid "Pick at least one track to rip." +msgstr "Выберите хотя бы один трек для извлечения." + +# Ephemeral pane: per-disc blocks and the track filter (2026-08-21) + +msgid "Disc {}" +msgstr "Диск {}" + +msgid "Filter tracks" +msgstr "Фильтр треков" + +msgid "No track matches that." +msgstr "Ни один трек не подходит." + +msgid "NAVIGATION" +msgstr "НАВИГАЦИЯ" + +msgid "SEARCH" +msgstr "ПОИСК" + +msgid "PLAYER & VISUALS" +msgstr "ПЛЕЕР И ВИЗУАЛИЗАЦИЯ" + +msgid "WINDOW" +msgstr "ОКНО" + +msgid "SYSTEM INTEGRATION" +msgstr "ИНТЕГРАЦИЯ С СИСТЕМОЙ" + +msgid "The typeface used across the app. System follows your operating system; the other choices are bundled with QBZ (requires restart)" +msgstr "Шрифт для всего приложения. «Системный» использует шрифт операционной системы; остальные варианты входят в состав QBZ (требуется перезапуск)" + +msgid "Library folders" +msgstr "Папки библиотеки" + +msgid "{} folders" +msgstr "Папок: {}" + +msgid "No Qobuz account? You can still use QBZ as a player for your own music library" +msgstr "Нет аккаунта Qobuz? QBZ всё равно можно использовать как плеер для своей музыки" + +msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working." +msgstr "Вы используете QBZ без аккаунта Qobuz. Локальная библиотека и загрузки продолжают работать." + +msgid "Continuous playback" +msgstr "Непрерывное воспроизведение" + +msgid "Open" +msgstr "Открыть" + +msgid "Folder view" +msgstr "Просмотр папок" + +msgid "Open media" +msgstr "Открыть медиафайл" + +msgid "Open folder…" +msgstr "Открыть папку…" + +# Порядок вкладок локальной медиатеки и запуск без сеанса (2026-08-24) +msgid "LIBRARY TABS" +msgstr "ВКЛАДКИ МЕДИАТЕКИ" + +msgid "Tab order and default" +msgstr "Порядок вкладок и вкладка по умолчанию" + +msgid "Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session." +msgstr "Выберите порядок вкладок локальной медиатеки. Первая открывается по умолчанию, в том числе при запуске QBZ без сеанса Qobuz." + +msgid "Local Library tabs" +msgstr "Вкладки локальной медиатеки" + +msgid "Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session." +msgstr "Измените порядок вкладок локальной медиатеки. Первая открывается по умолчанию и отображается при запуске без сеанса Qobuz." + +msgid "Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu" +msgstr "Щелчок по разделу на боковой панели или панели заголовка открывает его первую вкладку — включая выбранную вкладку локальной медиатеки по умолчанию — вместо простого отображения меню" + +# Расположение фильтров жанра локальной медиатеки (2026-08-24) +msgid "GENRES VIEW" +msgstr "ПРОСМОТР ПО ЖАНРАМ" + +msgid "Genre filters position" +msgstr "Расположение фильтров жанра" + +msgid "Place the chained genre filters above, beside or below the album results." +msgstr "Разместите связанные фильтры жанра над результатами альбомов, сбоку или под ними." + +msgid "Top" +msgstr "Сверху" + +msgid "Bottom" +msgstr "Снизу" + +msgid "Album metadata saved." +msgstr "Метаданные альбома сохранены." + +msgid "Album title is required." +msgstr "Необходимо указать название альбома." + +msgid "Apply to form" +msgstr "Применить к форме" + +msgid "CUE-based albums can only be edited with a sidecar." +msgstr "Альбомы на основе CUE можно редактировать только через сопроводительный файл." + +msgid "CUE-based albums use sidecar metadata." +msgstr "Альбомы на основе CUE используют метаданные в сопроводительном файле." + +msgid "Canonical" +msgstr "Канонический слой" + +msgid "Close without saving" +msgstr "Закрыть без сохранения" + +msgid "Couldn't inspect album metadata." +msgstr "Не удалось проверить метаданные альбома." + +msgid "Couldn't load that metadata result." +msgstr "Не удалось загрузить выбранный результат метаданных." + +msgid "Couldn't open the metadata source." +msgstr "Не удалось открыть источник метаданных." + +msgid "Detected" +msgstr "Обнаружены" + +msgid "Disc number" +msgstr "Номер диска" + +msgid "Editing the selected physical version" +msgstr "Редактирование выбранной физической версии" + +msgid "Enter an album title or artist to search." +msgstr "Введите название альбома или имя исполнителя для поиска." + +msgid "Every track needs a title." +msgstr "У каждого трека должно быть название." + +msgid "File names are shown for reference and cannot be changed here." +msgstr "Имена файлов показаны для справки и не могут быть изменены здесь." + +msgid "Find metadata" +msgstr "Найти метаданные" + +msgid "ID3 writing" +msgstr "Запись ID3" + +msgid "Metadata editing is available for local file versions only." +msgstr "Редактирование метаданных доступно только для локальных файловых версий." + +msgid "Metadata search failed." +msgstr "Поиск метаданных завершился ошибкой." + +msgid "No local album version is selected." +msgstr "Локальная версия альбома не выбрана." + +msgid "None" +msgstr "Нет" + +msgid "One or more audio files are unavailable." +msgstr "Один или несколько аудиофайлов недоступны." + +msgid "Open source" +msgstr "Открыть источник" + +msgid "QBZ will preflight every file, update its canonical tag layer, then read it back to verify the change. Keep a backup if these files cannot be replaced." +msgstr "QBZ предварительно проверит каждый файл, обновит его канонический слой тегов и повторно прочитает его для проверки. Сохраните резервную копию, если эти файлы нельзя заменить." + +msgid "Save changes to" +msgstr "Сохранить изменения в" + +msgid "Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled." +msgstr "Некоторые файлы содержат конфликтующие слои тегов. Канонический слой имеет приоритет, если синхронизация не включена." + +msgid "Synchronize compatible tag layers" +msgstr "Синхронизировать совместимые слои тегов" + +msgid "Tag layers" +msgstr "Слои тегов" + +msgid "The canonical tag is not writable for every file." +msgstr "Канонический тег доступен для записи не во всех файлах." + +msgid "The metadata editor is no longer open." +msgstr "Редактор метаданных уже закрыт." + +msgid "The metadata form could not be read." +msgstr "Не удалось прочитать форму метаданных." + +msgid "The track list changed; reopen the editor." +msgstr "Список треков изменился; откройте редактор заново." + +msgid "The track list contains a duplicate row." +msgstr "Список треков содержит повторяющуюся строку." + +msgid "Track number" +msgstr "Номер трека" + +msgid "Unknown metadata persistence mode." +msgstr "Неизвестный режим хранения метаданных." + +msgid "Write and verify" +msgstr "Записать и проверить" + +msgid "Write metadata into audio files?" +msgstr "Записать метаданные в аудиофайлы?" + +msgid "Year must be a valid number." +msgstr "Год должен быть допустимым числом." + +msgid "{} must be a positive number." +msgstr "{} должно быть положительным числом." + +msgid "The ephemeral session changed; reopen the metadata editor." +msgstr "Эфемерный сеанс изменился; снова откройте редактор метаданных." + +msgid "Album artists" +msgstr "Исполнители альбома" + +msgid "Album artists (ordered; separate with semicolons)" +msgstr "Исполнители альбома (по порядку; разделяйте точкой с запятой)" + +msgid "Artist credit" +msgstr "Указание исполнителей" + +msgid "Artists (ordered; semicolon separated)" +msgstr "Исполнители (по порядку; через точку с запятой)" + +msgid "Artwork is larger than 25 MiB." +msgstr "Размер обложки превышает 25 МиБ." + +msgid "Artwork search failed." +msgstr "Не удалось найти обложку." + +msgid "Artwork source did not provide a secure URL." +msgstr "Источник обложки не предоставил безопасный URL." + +msgid "Choose album artwork" +msgstr "Выбрать обложку альбома" + +msgid "Choose another file" +msgstr "Выбрать другой файл" + +msgid "Choose file" +msgstr "Выбрать файл" + +msgid "Compilation / Various Artists" +msgstr "Сборник / Разные исполнители" + +msgid "Composers" +msgstr "Композиторы" + +msgid "Couldn't download that artwork." +msgstr "Не удалось загрузить эту обложку." + +msgid "Couldn't stage artwork" +msgstr "Не удалось подготовить обложку" + +msgid "Current artwork" +msgstr "Текущая обложка" + +msgid "Discogs release ID" +msgstr "ID релиза Discogs" + +msgid "Enter an album title to find artwork." +msgstr "Введите название альбома для поиска обложки." + +msgid "File" +msgstr "Файл" + +msgid "Find artwork" +msgstr "Найти обложку" + +msgid "Local file" +msgstr "Локальный файл" + +msgid "MusicBrainz album artist IDs" +msgstr "ID исполнителей альбома MusicBrainz" + +msgid "MusicBrainz artist IDs" +msgstr "ID исполнителей MusicBrainz" + +msgid "MusicBrainz recording ID" +msgstr "ID записи MusicBrainz" + +msgid "MusicBrainz release ID" +msgstr "ID релиза MusicBrainz" + +msgid "MusicBrainz release-group ID" +msgstr "ID группы релизов MusicBrainz" + +msgid "MusicBrainz track ID" +msgstr "ID дорожки MusicBrainz" + +msgid "Performers" +msgstr "Исполнители партий" + +msgid "Revert selection" +msgstr "Отменить выбор" + +msgid "Selected track details" +msgstr "Сведения о выбранной дорожке" + +msgid "That artwork result is no longer available." +msgstr "Этот результат поиска обложки больше недоступен." + +msgid "The selected artwork changed; choose it again." +msgstr "Выбранная обложка изменилась; выберите её снова." + +msgid "The selected artwork has unsupported dimensions." +msgstr "Размеры выбранной обложки не поддерживаются." + +msgid "The selected file is not a supported image." +msgstr "Выбранный файл не является поддерживаемым изображением." + +msgid "Goniometer" +msgstr "Гониометр" + +msgid "Oscilloscope" +msgstr "Осциллограф" + +msgid "Track waveform" +msgstr "Форма волны трека" + +msgid "Show the full-track waveform in the player seek bar." +msgstr "Показывать форму волны всего трека на шкале воспроизведения." + +msgid "Compact header" +msgstr "Компактный заголовок" + +msgid "Show less data in album headers." +msgstr "Показывать меньше данных в заголовках альбомов." + +msgid "Show full album header" +msgstr "Показать полный заголовок альбома" + +msgid "Show compact album header" +msgstr "Показать компактный заголовок альбома" + +msgid "Immediate scrobbling" +msgstr "Немедленный скробблинг" + +msgid "Send scrobbles immediately while manual offline mode is enabled." +msgstr "Немедленно отправлять скробблы, пока включён ручной автономный режим." + +msgid "Accumulated scrobbling" +msgstr "Накопительный скробблинг" + +msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old." +msgstr "Сохранять скробблы в очереди при отсутствии сети и отправлять после восстановления соединения. Last.fm принимает скробблы не старше 2 недель." + +msgid "Scrobble while logged out" +msgstr "Скробблинг без входа" + +msgid "Continue sending plays to connected scrobblers without a Qobuz session." +msgstr "Продолжать отправлять прослушивания в подключённые сервисы без сеанса Qobuz." + +msgid "Focus on playing" +msgstr "Следовать за воспроизведением" + +msgid "Go to top" +msgstr "Наверх" + +msgid "Quick view" +msgstr "Быстрый просмотр" + +msgid "Could not load album" +msgstr "Не удалось загрузить альбом" + +msgid "This album has no tracks." +msgstr "В этом альбоме нет треков." + +msgid "Reactive Rings" +msgstr "Реактивные кольца" + +msgid "Windows — yes, but read this first" +msgstr "Windows — да, но сначала прочтите это" + +msgid "Don't show this again" +msgstr "Больше не показывать" + +msgid "I understand" +msgstr "Я понимаю" +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker + +msgid "Volume Up" +msgstr "Громче" + +msgid "Volume Down" +msgstr "Тише" + +msgid "Mute / Unmute" +msgstr "Звук выкл. / вкл." + +msgid "Toggle Shuffle" +msgstr "Перемешивание" + +msgid "Cycle Repeat" +msgstr "Режим повтора" + +msgid "Like Track" +msgstr "Нравится" + +msgid "Seek Forward" +msgstr "Перемотка вперёд" + +msgid "Seek Back" +msgstr "Перемотка назад" + +msgid "Keymap" +msgstr "Раскладка клавиш" + +msgid "Vim" +msgstr "Vim" + +# Local Library QoL: Explorer, scan progress, and per-track metadata editor +msgid "All years" +msgstr "Все годы" + +msgid "Explorer columns" +msgstr "Столбцы обозревателя" + +msgid "Genre and year" +msgstr "Жанр и год" + +msgid "Years" +msgstr "Годы" + +msgid "Edit track metadata" +msgstr "Изменить метаданные трека" + +msgid "Building library index" +msgstr "Создание индекса медиатеки" + +msgid "Local folders" +msgstr "Локальные папки" + +msgid "Next track" +msgstr "Следующий трек" + +msgid "Overall" +msgstr "Всего" + +msgid "Previous track" +msgstr "Предыдущий трек" + +msgid "Scanning library" +msgstr "Сканирование медиатеки" + +msgid "The track is no longer in the library." +msgstr "Трека больше нет в медиатеке." + +msgid "Updating library index" +msgstr "Обновление индекса медиатеки" + +msgid "Edit album" +msgstr "Изменить альбом" + +msgid "Editing a local metadata sidecar" +msgstr "Редактирование локального sidecar-файла метаданных" + +msgid "Media-server metadata is stored in a local sidecar." +msgstr "Метаданные медиасервера хранятся в локальном sidecar-файле." + +msgid "Media-server metadata can only be edited with a sidecar." +msgstr "Метаданные медиасервера можно редактировать только через sidecar-файл." + +msgid "Network file" +msgstr "Сетевой файл" + +msgid "Offline download" +msgstr "Офлайн-загрузка" + +msgid "File path" +msgstr "Путь к файлу" + +msgid "Containing folder" +msgstr "Содержащая папка" + +msgid "Media item ID" +msgstr "ID медиафайла" + +msgid "Album ID" +msgstr "ID альбома" + +msgid "Server" +msgstr "Сервер" + +msgid "Duration" +msgstr "Длительность" + +msgid "Format" +msgstr "Формат" + +msgid "Channels" +msgstr "Каналы" + +msgid "File size" +msgstr "Размер файла" + +msgid "Location" +msgstr "Расположение" + +msgid "The media item is no longer available." +msgstr "Медиафайл больше недоступен." + +msgid "The local album folder is not currently available." +msgstr "Локальная папка альбома сейчас недоступна." + +msgid "Everything is already available offline" +msgstr "Всё уже доступно офлайн" + +msgid "Some tracks are already available offline" +msgstr "Некоторые треки уже доступны офлайн" + +msgid "{} of {} tracks in “{}” are already available offline." +msgstr "{} из {} треков в «{}» уже доступны офлайн." + +msgid "Download the entire playlist again" +msgstr "Скачать весь плейлист заново" + +msgid "Download the entire album again" +msgstr "Скачать весь альбом заново" + +msgid "Re-downloads every track and can repair corrupt offline copies." +msgstr "Повторно загружает каждый трек и может исправить повреждённые офлайн-копии." + +msgid "Only missing tracks" +msgstr "Только недостающие треки" + +msgid "Keeps existing offline copies and downloads tracks that are not available yet." +msgstr "Сохраняет существующие офлайн-копии и загружает ещё недоступные треки." + +msgid "PRIVACY" +msgstr "КОНФИДЕНЦИАЛЬНОСТЬ" + +msgid "Listening history" +msgstr "История прослушиваний" + +msgid "Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler." +msgstr "Ведёт локальный журнал того, что вы слушаете: трек, источник, сколько из него вы прослушали и когда. Он хранится на этом компьютере, отдельно для каждой учётной записи, и никогда не покидает его, если вы не включите скробблер." + +msgid "Clear listening history" +msgstr "Очистить историю прослушиваний" + +msgid "Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected." +msgstr "Удаляет все прослушивания, записанные на этом компьютере для этой учётной записи. Уже отправленные скробблы не затрагиваются." + +msgid "Listening history cleared." +msgstr "История прослушиваний очищена." + +msgid "Couldn't clear listening history." +msgstr "Не удалось очистить историю прослушиваний." + +msgid "Checking ALSA mixer controls…" +msgstr "Проверка регуляторов микшера ALSA…" + +msgid "This device exposes multiple playback controls. Choose which one QBZ should use." +msgstr "Это устройство предоставляет несколько регуляторов воспроизведения. Выберите, какой из них должен использовать QBZ." + +msgid "The saved mixer control is no longer available. Choose another control." +msgstr "Сохранённый регулятор микшера больше недоступен. Выберите другой регулятор." + +msgid "QBZ does not have permission to read this device's ALSA mixer." +msgstr "У QBZ нет разрешения на чтение микшера ALSA этого устройства." + +msgid "The ALSA mixer is busy. Close the app using it and try again." +msgstr "Микшер ALSA занят. Закройте использующее его приложение и повторите попытку." + +msgid "The selected ALSA device is no longer available." +msgstr "Выбранное устройство ALSA больше недоступно." + +msgid "This ALSA device does not expose a mixer." +msgstr "Это устройство ALSA не предоставляет микшер." + +msgid "QBZ could not write the selected ALSA mixer control. Direct playback remains fixed at 100%." +msgstr "QBZ не удалось изменить выбранный регулятор микшера ALSA. Громкость прямого воспроизведения остаётся на уровне 100 %." + +msgid "Selected mixer control" +msgstr "Выбранный регулятор микшера" + +msgid "Choose mixer control" +msgstr "Выбрать регулятор микшера" + +msgid "Change mixer control" +msgstr "Изменить регулятор микшера" + +msgid "Qobuz Connect could not shut down safely. Quit and reopen QBZ, then try logging out again." +msgstr "Не удалось безопасно завершить работу Qobuz Connect. Закройте и снова откройте QBZ, затем повторите выход из учётной записи." + +msgid "Show filter panels" +msgstr "Показать панели фильтров" + +msgid "Hide filter panels" +msgstr "Скрыть панели фильтров" + +msgid "Already in" +msgstr "Уже в" + +msgid "Playlist index updating..." +msgstr "Индекс плейлистов обновляется..." + +msgid "{} of {}" +msgstr "{} из {}" + +msgid "Last playlist" +msgstr "Последний плейлист" + +msgid "OK" +msgstr "OK" + +msgid "Sponsors" +msgstr "Спонсоры" + +msgid "And all those anonymous sponsors..." +msgstr "И все те анонимные спонсоры..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect не может распознать локальные треки. Пропущен {} локальный трек; он не добавлен в очередь. Отключите Qobuz Connect, чтобы воспроизвести его." +msgstr[1] "Qobuz Connect не может распознать локальные треки. Пропущено {} локальных трека; они не добавлены в очередь. Отключите Qobuz Connect, чтобы воспроизвести их." +msgstr[2] "Qobuz Connect не может распознать локальные треки. Пропущено {} локальных треков; они не добавлены в очередь. Отключите Qobuz Connect, чтобы воспроизвести их." + +msgid "Qobuz Connect device" +msgstr "Устройство Qobuz Connect" + +msgid "Continue playback on %1" +msgstr "Продолжить воспроизведение на %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Использовать очередь Qobuz Connect и оставить это устройство активным." + +msgid "Continue playback on this device" +msgstr "Продолжить воспроизведение на этом устройстве" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Использовать очередь Qobuz Connect и заменить локальную очередь." + +msgid "Continue this device's current playback" +msgstr "Продолжить текущее воспроизведение на этом устройстве" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Заменить очередь и состояние воспроизведения Qobuz Connect текущей очередью и позицией этого устройства." + +msgid "Cancel Qobuz Connect" +msgstr "Отменить подключение Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Оставить обе очереди без изменений и продолжить текущее локальное воспроизведение." + +msgid "Choose how to continue" +msgstr "Выберите, как продолжить" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "На этом устройстве и другом проигрывателе Qobuz Connect уже идёт воспроизведение. Выберите очередь и устройство, которые должны продолжить." + +msgid "Ask every time" +msgstr "Спрашивать каждый раз" + +msgid "Continue on the active renderer" +msgstr "Продолжить на активном устройстве" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Продолжить здесь с очередью Qobuz Connect" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Продолжить локальное воспроизведение и заменить очередь Qobuz Connect" + +msgid "When playback conflicts" +msgstr "При конфликте воспроизведения" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Спрашивать каждый раз или автоматически выбирать, какая очередь и устройство продолжат воспроизведение." + +msgid "Purchased" +msgstr "Куплено" + +msgid "Sort: Release date" +msgstr "Сортировка: Дата выхода" + +msgid "Until the purchased file is downloaded, Play streams the best quality Qobuz offers for this album. Once it is on disk, Play uses your copy." +msgstr "Пока купленный файл не скачан, воспроизведение идёт в лучшем качестве, которое Qobuz предлагает для этого альбома. Как только он окажется на диске, воспроизводится ваша копия." + +msgid "Open in Local Library" +msgstr "Открыть в локальной библиотеке" + +msgid "Import / Export" +msgstr "Импорт / Экспорт" + +msgid "This bundle holds the app's own settings: audio, playback, appearance, integrations. It does not contain your Qobuz favorites, playlists, blacklist or purchases." +msgstr "Этот пакет содержит настройки приложения: аудио, воспроизведение, оформление, интеграции. В нём нет ваших избранных, плейлистов, чёрного списка и покупок Qobuz." + +msgid "BLACKLIST" +msgstr "ЧЁРНЫЙ СПИСОК" + +msgid "Export blacklist…" +msgstr "Экспортировать чёрный список…" + +msgid "Save your blocked artists and albums as a file you can hand to another QBZ user." +msgstr "Сохраняет заблокированных исполнителей и альбомы в файл, который можно передать другому пользователю QBZ." + +msgid "Import blacklist…" +msgstr "Импортировать чёрный список…" + +msgid "Add blocked artists and albums from a file. Nothing already on your blacklist is removed." +msgstr "Добавляет заблокированных исполнителей и альбомы из файла. Ничего из вашего чёрного списка не удаляется." + +msgid "Import…" +msgstr "Импортировать…" + +msgid "Import failed." +msgstr "Импорт не удался." + +msgid "Not a QBZ blacklist file." +msgstr "Это не файл чёрного списка QBZ." + +msgid "Choose a blacklist file" +msgstr "Выберите файл чёрного списка" + +msgid "Imported {} artists and {} albums ({} already blocked)." +msgstr "Импортировано исполнителей: {}, альбомов: {} ({} уже заблокировано)." + +msgid "Member" +msgstr "Участник" + +msgid "QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™." +msgstr "QBZ требует учётную запись Qobuz™. Без активной подписки воспроизведение ограничено 30-секундными фрагментами; избранное, плейлисты и покупки работают. Ваши данные отправляются напрямую в Qobuz™." + +msgid "ACCOUNT MIGRATION" +msgstr "ПЕРЕНОС АККАУНТА" + +msgid "Moves favorites, playlists and followed playlists from one Qobuz account into another, adding only what is missing and deleting nothing on either side. It does not move subscriptions or purchases: those stay with the Qobuz account that holds them." +msgstr "Переносит избранное, плейлисты и подписки на плейлисты из одного аккаунта Qobuz в другой, добавляя только недостающее и ничего не удаляя. Подписки на сервис и покупки не переносятся: они остаются у того аккаунта Qobuz, которому принадлежат." + +msgid "Create migration snapshot" +msgstr "Создать снимок для переноса" + +msgid "Sign in with the account you are moving FROM, then save its favorites and playlists to a file in its QBZ profile." +msgstr "Войдите в аккаунт, ИЗ которого переезжаете, и сохраните его избранное и плейлисты в файл в его профиле QBZ." + +msgid "Create…" +msgstr "Создать…" + +msgid "Migrate into this account" +msgstr "Перенести в этот аккаунт" + +msgid "Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing." +msgstr "Войдите в аккаунт, В который переезжаете, и выберите снимок ниже. Повтор безопасен: второй запуск ничего не добавит." + +msgid "No snapshots yet. Sign in with the old account and create one first." +msgstr "Снимков пока нет. Войдите в старый аккаунт и сначала создайте его." + +msgid "{} favorites · {} playlists · {} followed" +msgstr "{} избранных · {} плейлистов · {} подписок" + +msgid "this account" +msgstr "этот аккаунт" + +msgid "Migrate…" +msgstr "Перенести…" + +msgid "Account {} · {}" +msgstr "Аккаунт {} · {}" + +msgid "Reading favorites and playlists…" +msgstr "Чтение избранного и плейлистов…" + +msgid "Reading this account…" +msgstr "Чтение этого аккаунта…" + +msgid "Adding favorites…" +msgstr "Добавление избранного…" + +msgid "Adding playlists…" +msgstr "Добавление плейлистов…" + +msgid "Following playlists…" +msgstr "Подписка на плейлисты…" + +msgid "{} / {} — {}" +msgstr "{} / {} — {}" + +msgid "Snapshot failed." +msgstr "Не удалось создать снимок." + +msgid "Migration failed." +msgstr "Перенос не удался." + +msgid "Saved {} favorites, {} playlists and {} followed playlists to {}" +msgstr "Сохранено: избранных {}, плейлистов {}, подписок {} в {}" + +msgid "That snapshot was taken from this same account." +msgstr "Этот снимок сделан с этого же аккаунта." + +msgid "Nothing to migrate: everything is already in this account." +msgstr "Переносить нечего: всё уже есть в этом аккаунте." + +msgid "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there." +msgstr "Добавлено: избранных {}, плейлистов {} (треков {}), подписок {}; уже было: {}." + +msgid "{} items failed; see the log." +msgstr "Не удалось: {} элементов; см. журнал." + +msgid "Also copy the local profile" +msgstr "Также скопировать локальный профиль" + +msgid "Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer." +msgstr "Папки библиотеки, папки и порядок плейлистов, закреплённые элементы, чёрный список и настройки профиля QBZ старого аккаунта на этом компьютере." + +msgid "Media server connections" +msgstr "Подключения к медиасерверам" + +msgid "Plex, Jellyfin and Subsonic settings, including their credentials." +msgstr "Настройки Plex, Jellyfin и Subsonic, включая учётные данные." + +msgid "Scrobbler accounts" +msgstr "Аккаунты скробблинга" + +msgid "Last.fm and ListenBrainz settings, including their credentials." +msgstr "Настройки Last.fm и ListenBrainz, включая учётные данные." + +msgid "The listen log and the events behind offline recommendations." +msgstr "Журнал прослушивания и события, на которых основаны офлайн-рекомендации." + +msgid "Local profile: {} rows copied; {} playlist rows had no match." +msgstr "Локальный профиль: скопировано строк: {}; строк плейлистов без соответствия: {}." + +msgid "The library already had folders: run a rescan to index the added ones." +msgstr "В библиотеке уже были папки: выполните повторное сканирование, чтобы проиндексировать добавленные." + +msgid "Restart QBZ to see the migrated local profile." +msgstr "Перезапустите QBZ, чтобы увидеть перенесённый локальный профиль." + +msgid "The local profile could not be copied; see the log." +msgstr "Не удалось скопировать локальный профиль; см. журнал." +msgid "Delete…" +msgstr "Удалить…" + +msgid "Delete migration snapshot?" +msgstr "Удалить снимок для переноса?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Будет удалён только пакет переноса. Профиль QBZ и пользовательские данные старой учётной записи сохранятся." + +msgid "Delete snapshot" +msgstr "Удалить снимок" + +msgid "Migration snapshot deleted." +msgstr "Снимок для переноса удалён." + +msgid "Could not delete the migration snapshot." +msgstr "Не удалось удалить снимок для переноса." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} обновляет свою медиатеку (просканировано элементов: {}). Дождитесь завершения и повторите синхронизацию." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "В исходном профиле не было сохранено подключений Plex, Jellyfin или Subsonic, поэтому копировать было нечего." + +msgid "Media server connections copied: {} of {}." +msgstr "Подключения к медиасерверам скопированы: {} из {}." + +msgid "Copying local profile…" +msgstr "Копирование локального профиля…" + +msgid "Refreshing migrated data…" +msgstr "Обновление перенесённых данных…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Это происходит только один раз. Дождитесь завершения и не закрывайте окно." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Эту панель можно закрыть; перенос продолжится в фоновом режиме." + +msgid "From" +msgstr "Источник" + +msgid "To" +msgstr "Назначение" + +msgid "Step {} of {}" +msgstr "Шаг {} из {}" + +msgid "Elapsed: {}" +msgstr "Прошло времени: {}" + +msgid "Remaining: {}" +msgstr "Осталось: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Проверьте сохранённые исходные профили, выберите данные для копирования и подтвердите целевой аккаунт, в который выполнен вход." + +msgid "Migrate account data" +msgstr "Перенос данных аккаунта" + +msgid "Start migration" +msgstr "Начать перенос" + +msgid "What is a migration profile?" +msgstr "Что такое профиль переноса?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Это одноразовый пакет, созданный из исходного аккаунта. В нём сохраняются избранное и плейлисты Qobuz, чтобы добавить их в аккаунт, в который выполнен вход. Дополнительные локальные данные копируются напрямую из старого профиля QBZ на этом компьютере. Удаление пакета не удаляет старый профиль или данные аккаунтов." + +msgid "SAVED SOURCE PROFILES" +msgstr "СОХРАНЁННЫЕ ИСХОДНЫЕ ПРОФИЛИ" + +msgid "No saved migration profiles are available." +msgstr "Нет доступных сохранённых профилей переноса." + +msgid "Selected" +msgstr "Выбрано" + +msgid "Select" +msgstr "Выбрать" + +msgid "MIGRATION DIRECTION" +msgstr "НАПРАВЛЕНИЕ ПЕРЕНОСА" + +msgid "Select a source profile above" +msgstr "Выберите исходный профиль выше" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Аккаунт Qobuz, в который выполнен вход · ID {}" + +msgid "WHAT TO COPY" +msgstr "ЧТО КОПИРОВАТЬ" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "Перенос только добавляет данные: избранное и плейлисты не удаляются ни из одного аккаунта." + +msgid "That library folder is disabled." +msgstr "Эта папка медиатеки отключена." + +msgid "That folder overlaps an existing library folder." +msgstr "Эта папка пересекается с существующей папкой медиатеки." + +msgid "This purchase is not available for download." +msgstr "Эта покупка недоступна для скачивания." + +msgid "Could not save playback preference." +msgstr "Не удалось сохранить настройку воспроизведения." + +msgid "Qobuz · stream/offline copy" +msgstr "Qobuz · поток/офлайн-копия" + +msgid "Local purchase" +msgstr "Локальная покупка" + +msgid "Play with" +msgstr "Воспроизводить через" + +msgid "Download purchase" +msgstr "Скачать покупку" + +msgid "1 track purchased" +msgstr "Куплен 1 трек" + +msgid "{} tracks purchased" +msgstr "Куплено треков: {}" + +msgid "This copy cannot be continued right now." +msgstr "Сейчас эту копию нельзя продолжить." + +msgid "Library folder refreshed." +msgstr "Папка медиатеки обновлена." + +msgid "Download another copy" +msgstr "Скачать ещё одну копию" + +msgid "Complete" +msgstr "Полная" + +msgid "Partial" +msgstr "Частичная" + +msgid "Missing files" +msgstr "Отсутствующие файлы" + +msgid "Changed files" +msgstr "Изменённые файлы" + +msgid "Unreadable files" +msgstr "Нечитаемые файлы" + +msgid "Local purchase copies" +msgstr "Локальные копии покупки" + +msgid "{} of {} tracks" +msgstr "{} из {} треков" + +msgid "Continue copy" +msgstr "Продолжить копию" + +msgid "Add to Local Library" +msgstr "Добавить в локальную медиатеку" + +msgid "In Local Library" +msgstr "В локальной медиатеке" + +msgid "{} of {} tracks available in this folder." +msgstr "В этой папке доступно {} из {} треков." + +msgid "{} failed" +msgstr "Ошибок: {}" + +msgid "{} cancelled" +msgstr "Отменено: {}" + +msgid "Download finished" +msgstr "Загрузка завершена" + +msgid "Shuffle: On" +msgstr "Перемешивание: вкл." + +msgid "Shuffle: Off" +msgstr "Перемешивание: выкл." + +msgid "Repeat: Off" +msgstr "Повтор: выкл." + +msgid "Repeat: All" +msgstr "Повтор: все" + +msgid "Repeat: One" +msgstr "Повтор: один трек" + +msgid "Show album artwork in Queue track list" +msgstr "Показывать обложки альбомов в списке треков очереди" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Заменяет номера треков в очереди миниатюрами обложек альбомов. Отключено по умолчанию." + +msgid "Show album artwork in Library track list" +msgstr "Показывать обложки альбомов в списке треков библиотеки" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Показывает миниатюру обложки между номером и названием трека. Отключено по умолчанию." + +msgid "Play random" +msgstr "Воспроизвести случайный" + +msgid "Play selected" +msgstr "Воспроизвести выбранное" + +msgid "Cancel selection" +msgstr "Отменить выбор" + +msgid "QOBUZ LINKS" +msgstr "ССЫЛКИ QOBUZ" + +msgid "Open Qobuz links in QBZ" +msgstr "Открывать ссылки Qobuz в QBZ" + +msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here." +msgstr "Делает QBZ обработчиком ссылок qobuzapp:// из браузера. Если выключено, их оставляет официальное приложение Qobuz; собственные ссылки qbz:// всегда открываются здесь." + +msgid "Look for better replacement" +msgstr "Найти лучшую замену" + +msgid "Remove all favorites from this release" +msgstr "Удалить всё избранное из этого релиза" + +msgid "Remove all favorites from this release?" +msgstr "Удалить всё избранное из этого релиза?" + +msgid "Remove the album and every track from this release from your Library?" +msgstr "Удалить альбом и все треки этого релиза из вашей медиатеки?" + +msgid "No replacement releases found" +msgstr "Релизы на замену не найдены" + +msgid "Choosing a result opens the release. Your existing favorites are not changed." +msgstr "Выбор результата откроет релиз. Ваше текущее избранное не изменится." + +msgid "No favorites from this release to remove" +msgstr "В этом релизе нет избранного для удаления" + +msgid "Favorites removed from this release" +msgstr "Избранное этого релиза удалено" + +msgid "Could not remove favorites from this release" +msgstr "Не удалось удалить избранное этого релиза" + +msgid "Some favorites could not be removed" +msgstr "Некоторые элементы избранного не удалось удалить" + +msgid "Replace in Library" +msgstr "Заменить в медиатеке" + +msgid "Replacing favorite..." +msgstr "Замена избранного..." + +msgid "The selected release replaces the unavailable album favorite." +msgstr "Выбранный релиз заменит недоступный альбом в избранном." + +msgid "The matching track from the selected release replaces the unavailable favorite." +msgstr "Соответствующий трек из выбранного релиза заменит недоступный трек в избранном." + +msgid "Could not open the selected release" +msgstr "Не удалось открыть выбранный релиз" + +msgid "Could not find a matching track in this release" +msgstr "В этом релизе не найден соответствующий трек" + +msgid "Could not add the replacement favorite" +msgstr "Не удалось добавить замену в избранное" + +msgid "Favorite replaced" +msgstr "Избранное заменено" + +msgid "The replacement was added, but the unavailable favorite could not be removed" +msgstr "Замена добавлена, но недоступный элемент не удалось удалить из избранного" + +msgid "Failed to send playback to the selected device" +msgstr "Не удалось передать воспроизведение на выбранное устройство" + +msgid "Move up" +msgstr "Переместить вверх" + +msgid "Move down" +msgstr "Переместить вниз" + +msgid "Download" +msgstr "Скачать" + +msgid "Edit" +msgstr "Редактировать" + +msgid "Grid" +msgstr "Сетка" + +msgid "List" +msgstr "Список" + +msgid "Tree" +msgstr "Дерево" + +msgid "Forward" +msgstr "Вперёд" diff --git a/crates/qbz-integrations/Cargo.toml b/crates/qbz-integrations/Cargo.toml index aeef7ea4f..558f05999 100644 --- a/crates/qbz-integrations/Cargo.toml +++ b/crates/qbz-integrations/Cargo.toml @@ -10,7 +10,7 @@ license = "MIT" tokio = { version = "1", features = ["sync", "time"] } # HTTP client -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { workspace = true, default-features = false, features = ["json", "query", "rustls-no-provider"] } # Serialization serde = { version = "1", features = ["derive"] } @@ -30,7 +30,7 @@ urlencoding = "2" discord-rich-presence = "1.1.0" # SQLite for caching (optional, enabled by default) -rusqlite = { version = "0.31", features = ["bundled"], optional = true } +rusqlite = { version = "0.40", features = ["bundled"], optional = true } # Time handling for cache TTL chrono = { version = "0.4", optional = true } diff --git a/crates/qbz-integrations/src/discogs/mod.rs b/crates/qbz-integrations/src/discogs/mod.rs index 053228509..4a1a75937 100644 --- a/crates/qbz-integrations/src/discogs/mod.rs +++ b/crates/qbz-integrations/src/discogs/mod.rs @@ -432,6 +432,47 @@ impl DiscogsClient { Ok(cache_path.to_string_lossy().to_string()) } + /// Download one previously returned Discogs image without trusting its + /// size or exposing the proxy URL to a frontend. The editor stages these + /// bytes and validates the decoded image before any tag/file mutation. + pub async fn download_artwork_bytes( + &self, + image_url: &str, + max_bytes: usize, + ) -> Result<Vec<u8>, String> { + let proxy_url = format!( + "{}/image?url={}", + DISCOGS_PROXY_URL, + urlencoding::encode(image_url) + ); + let mut response = self + .client + .get(&proxy_url) + .send() + .await + .map_err(|error| format!("Failed to download Discogs image: {error}"))? + .error_for_status() + .map_err(|error| format!("Discogs image request failed: {error}"))?; + if response + .content_length() + .is_some_and(|length| length > max_bytes as u64) + { + return Err("Discogs image is larger than the editor limit".to_string()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("Failed to read Discogs image: {error}"))? + { + if bytes.len().saturating_add(chunk.len()) > max_bytes { + return Err("Discogs image is larger than the editor limit".to_string()); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + /// Search for artists and return search results pub async fn search_artist(&self, query: &str) -> Result<SearchResponse, String> { let url = format!( diff --git a/crates/qbz-integrations/src/lastfm/client.rs b/crates/qbz-integrations/src/lastfm/client.rs index 717095231..b2675e8d8 100644 --- a/crates/qbz-integrations/src/lastfm/client.rs +++ b/crates/qbz-integrations/src/lastfm/client.rs @@ -791,6 +791,102 @@ impl LastFmClient { Ok(albums) } + /// Public `artist.getInfo` lookup used by Local Library's conservative + /// portrait enrichment. No Last.fm user session is required; the proxy + /// supplies the application key. `autocorrect` is returned to the caller + /// as the canonical `name`, but consumers must still validate identity + /// before attaching the image to a local tag spelling. + pub async fn get_artist_info(&self, artist: &str) -> IntegrationResult<LastFmArtist> { + let url = format!("{}/artist.getInfo", LASTFM_PROXY_URL); + let response = self + .client + .post(&url) + .json(&json!({ + "artist": artist, + "autocorrect": 1, + })) + .send() + .await?; + if !response.status().is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(IntegrationError::internal(format!( + "Last.fm artist.getInfo failed: {text}" + ))); + } + let data: serde_json::Value = response.json().await?; + if let Some(error) = data.get("error") { + return Err(IntegrationError::api( + error.as_u64().unwrap_or(0) as u32, + data.get("message") + .and_then(|message| message.as_str()) + .unwrap_or("Unknown error") + .to_string(), + )); + } + let value = data.get("artist").ok_or_else(|| { + IntegrationError::internal("Last.fm returned no artist object".to_string()) + })?; + parse_artist_info(value, artist) + } + + /// Public `album.getInfo` lookup used by the local metadata editor for a + /// conservative artwork candidate. No Last.fm user session is required; + /// the existing proxy supplies the application key. + pub async fn get_album_info( + &self, + artist: &str, + album: &str, + ) -> IntegrationResult<LastFmAlbum> { + let url = format!("{}/album.getInfo", LASTFM_PROXY_URL); + let response = self + .client + .post(&url) + .json(&json!({ + "artist": artist, + "album": album, + "autocorrect": 1, + })) + .send() + .await?; + if !response.status().is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(IntegrationError::internal(format!( + "Last.fm album.getInfo failed: {text}" + ))); + } + let data: serde_json::Value = response.json().await?; + if let Some(error) = data.get("error") { + return Err(IntegrationError::api( + error.as_u64().unwrap_or(0) as u32, + data.get("message") + .and_then(|message| message.as_str()) + .unwrap_or("Unknown error") + .to_string(), + )); + } + let value = data.get("album").ok_or_else(|| { + IntegrationError::internal("Last.fm returned no album object".to_string()) + })?; + let name = value + .get("name") + .and_then(|name| name.as_str()) + .unwrap_or(album) + .to_string(); + let artist = value + .get("artist") + .and_then(|artist| artist.as_str()) + .unwrap_or(artist) + .to_string(); + Ok(LastFmAlbum { + name, + artist, + artist_mbid: None, + mbid: extract_mbid(value), + image: extract_image(value), + playcount: parse_u64(value.get("playcount")), + }) + } + /// user.getTopAlbums — the USER's scrobbled albums (their playcount). /// /// The "already heard" exclusion set for Recommended Albums. Public read @@ -900,14 +996,11 @@ fn extract_mbid(value: &serde_json::Value) -> Option<String> { /// Extract a Unix timestamp from a Last.fm `date.uts` field (string or number). fn extract_uts(value: &serde_json::Value) -> Option<i64> { - value - .get("date") - .and_then(|d| d.get("uts")) - .and_then(|u| { - u.as_str() - .and_then(|s| s.parse::<i64>().ok()) - .or_else(|| u.as_i64()) - }) + value.get("date").and_then(|d| d.get("uts")).and_then(|u| { + u.as_str() + .and_then(|s| s.parse::<i64>().ok()) + .or_else(|| u.as_i64()) + }) } /// Parse a `u64` that Last.fm may return as a JSON string or number; defaults to 0. @@ -920,3 +1013,42 @@ fn parse_u64(value: Option<&serde_json::Value>) -> u64 { }) .unwrap_or(0) } + +fn parse_artist_info( + value: &serde_json::Value, + fallback_name: &str, +) -> IntegrationResult<LastFmArtist> { + Ok(LastFmArtist { + name: value + .get("name") + .and_then(|name| name.as_str()) + .unwrap_or(fallback_name) + .to_string(), + mbid: extract_mbid(value), + playcount: parse_u64(value.get("stats").and_then(|stats| stats.get("playcount"))), + image: extract_image(value), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn artist_info_keeps_autocorrected_identity_and_largest_image() { + let value = serde_json::json!({ + "name": "Beyoncé", + "mbid": "859d0860-d480-4efd-970c-c05d5f1776b8", + "stats": { "playcount": "42" }, + "image": [ + { "#text": "https://img/small.jpg", "size": "small" }, + { "#text": "https://img/large.jpg", "size": "extralarge" } + ] + }); + let parsed = parse_artist_info(&value, "Beyonce").unwrap(); + assert_eq!(parsed.name, "Beyoncé"); + assert_eq!(parsed.playcount, 42); + assert_eq!(parsed.image.as_deref(), Some("https://img/large.jpg")); + assert!(parsed.mbid.is_some()); + } +} diff --git a/crates/qbz-integrations/src/lib.rs b/crates/qbz-integrations/src/lib.rs index e942e57cc..e332860f2 100644 --- a/crates/qbz-integrations/src/lib.rs +++ b/crates/qbz-integrations/src/lib.rs @@ -42,5 +42,5 @@ pub use musicbrainz::{MusicBrainzClient, MusicBrainzConfig}; pub use remote_metadata::{ discogs_extended_to_search_result, discogs_full_to_metadata, musicbrainz_full_to_metadata, musicbrainz_release_to_search_result, RemoteAlbumMetadata, - RemoteAlbumSearchResult, RemoteProvider, RemoteTrackMetadata, + RemoteAlbumSearchResult, RemoteArtistCredit, RemoteProvider, RemoteTrackMetadata, }; diff --git a/crates/qbz-integrations/src/musicbrainz/cache.rs b/crates/qbz-integrations/src/musicbrainz/cache.rs index eb84e13a1..82e761e12 100644 --- a/crates/qbz-integrations/src/musicbrainz/cache.rs +++ b/crates/qbz-integrations/src/musicbrainz/cache.rs @@ -26,6 +26,30 @@ const METADATA_TTL_SECS: i64 = 30 * 24 * 60 * 60; const SCENE_TTL_SECS: i64 = 30 * 24 * 60 * 60; /// TTL for Qobuz artist validation cache (30 days) const QOBUZ_VALIDATION_TTL_SECS: i64 = 30 * 24 * 60 * 60; +/// TTL for the typed Qobuz artist-match cache (30 days, same as above) +const QOBUZ_ARTIST_MATCH_TTL_SECS: i64 = 30 * 24 * 60 * 60; + +/// The Qobuz half of a scene validation, and NOTHING else. +/// +/// This exists because the old `mb_qobuz_validation` table stores a whole +/// `LocationCandidate` — MBID, affinity score and genres included — keyed on +/// the normalised artist NAME alone. Tauri only ever wrote it; reading it back +/// would transplant one scene's identity and ranking onto another scene's +/// candidate with the same name. Only these four fields are a pure function of +/// the name (they are literally what the Qobuz artist search returns), so only +/// these four are safe to replay. The caller rebuilds the candidate around them +/// from the CURRENT mbid, score and genres. +/// +/// Negative results are deliberately NOT cached: Tauri disabled its negative +/// cache (`// Negative cache — TEMPORARILY DISABLED`) and a "not on Qobuz" +/// verdict that sticks for 30 days is how a newly-added artist stays invisible. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct QobuzArtistMatch { + pub qobuz_id: i64, + pub name: String, + pub image: Option<String>, + pub albums_count: Option<u32>, +} /// Cache statistics #[derive(Debug, Clone, serde::Serialize)] @@ -123,6 +147,17 @@ impl MusicBrainzCache { ); CREATE INDEX IF NOT EXISTS idx_mb_qobuz_validation_fetched ON mb_qobuz_validation(fetched_at); + -- Typed Qobuz artist match (id/name/image/album count only), + -- keyed on the normalized artist name. Separate table from + -- mb_qobuz_validation because that one holds Tauri-era whole + -- LocationCandidate rows that must never be replayed. + CREATE TABLE IF NOT EXISTS mb_qobuz_artist_match ( + name_normalized TEXT PRIMARY KEY, + data TEXT NOT NULL, + fetched_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mb_qobuz_artist_match_fetched ON mb_qobuz_artist_match(fetched_at); + -- V2 resolved tracks (simple cache) CREATE TABLE IF NOT EXISTS resolved_tracks ( isrc TEXT PRIMARY KEY, @@ -455,9 +490,75 @@ impl MusicBrainzCache { Ok(()) } + /// Drop every cached scene, leaving the rest of the cache alone. + /// + /// Targeted invalidation for the cases a post-filter cannot fix: the + /// blacklist is applied INSIDE the validation loop (a blocked artist makes + /// the loop pick the next-best same-name Qobuz artist for that MBID), so + /// UN-blacklisting cannot be undone by filtering on the way out. Also the + /// right hammer on account switch, because the account territory decides + /// which candidates validate and is not yet part of the key. + /// + /// Returns the number of rows removed. + pub fn clear_scene_cache(&self) -> Result<usize, String> { + let deleted = self + .conn + .execute("DELETE FROM mb_scene_cache", []) + .map_err(|e| format!("Failed to clear scene cache: {}", e))?; + if deleted > 0 { + log::info!("MusicBrainz scene cache invalidated: {} entries", deleted); + } + Ok(deleted) + } + // ============ Qobuz Validation Cache ============ + /// Get the typed Qobuz match for a normalized artist name. + /// + /// A parse failure is a MISS, not an error — the row is from an older + /// shape and re-validating costs one search. + pub fn get_qobuz_artist_match( + &self, + name_normalized: &str, + ) -> Result<Option<QobuzArtistMatch>, String> { + let min_fetched_at = Self::current_timestamp() - QOBUZ_ARTIST_MATCH_TTL_SECS; + let result: Option<String> = self + .conn + .query_row( + "SELECT data FROM mb_qobuz_artist_match WHERE name_normalized = ? AND fetched_at > ?", + params![name_normalized, min_fetched_at], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("Failed to query artist-match cache: {}", e))?; + + Ok(result.and_then(|data| serde_json::from_str(&data).ok())) + } + + /// Cache the typed Qobuz match for a normalized artist name. + pub fn set_qobuz_artist_match( + &self, + name_normalized: &str, + data: &QobuzArtistMatch, + ) -> Result<(), String> { + let fetched_at = Self::current_timestamp(); + let json = serde_json::to_string(data) + .map_err(|e| format!("Failed to serialize artist match: {}", e))?; + self.conn + .execute( + "INSERT OR REPLACE INTO mb_qobuz_artist_match (name_normalized, data, fetched_at) VALUES (?, ?, ?)", + params![name_normalized, json, fetched_at], + ) + .map_err(|e| format!("Failed to cache artist match: {}", e))?; + Ok(()) + } + /// Get cached Qobuz validation result for an artist name + /// + /// DEAD in this tree and left that way on purpose: the stored value is a + /// whole scene-specific `LocationCandidate` keyed on the name alone, so + /// replaying it in another scene transplants the wrong MBID, score and + /// genres. [`Self::get_qobuz_artist_match`] is the safe replacement. pub fn get_qobuz_validation(&self, name_normalized: &str) -> Result<Option<String>, String> { let min_fetched_at = Self::current_timestamp() - QOBUZ_VALIDATION_TTL_SECS; let result: Option<String> = self @@ -629,6 +730,7 @@ impl MusicBrainzCache { ("mb_artist_metadata", METADATA_TTL_SECS), ("mb_scene_cache", SCENE_TTL_SECS), ("mb_qobuz_validation", QOBUZ_VALIDATION_TTL_SECS), + ("mb_qobuz_artist_match", QOBUZ_ARTIST_MATCH_TTL_SECS), ]; for (table, ttl) in &tables_and_ttls { @@ -662,6 +764,7 @@ impl MusicBrainzCache { DELETE FROM mb_artist_metadata; DELETE FROM mb_scene_cache; DELETE FROM mb_qobuz_validation; + DELETE FROM mb_qobuz_artist_match; DELETE FROM resolved_tracks; DELETE FROM resolved_artists; UPDATE cache_stats SET value = 0; diff --git a/crates/qbz-integrations/src/musicbrainz/location.rs b/crates/qbz-integrations/src/musicbrainz/location.rs index a73817326..708f05411 100644 --- a/crates/qbz-integrations/src/musicbrainz/location.rs +++ b/crates/qbz-integrations/src/musicbrainz/location.rs @@ -236,6 +236,16 @@ pub fn compute_affinity_score( } /// Build the scene cache key from location + seeds +/// +/// UNFIT — kept only so no out-of-tree caller breaks; it has zero callers in +/// this workspace. It carries **only** area + an ordered seed hash, while the +/// response also depends on the source MBID (that artist is excluded from its +/// own scene), the country, the page size and the blacklist. Two callers asking +/// for different page sizes against the same DB would share one entry. Use +/// [`build_scene_cache_key_v1`]. +#[deprecated( + note = "unfit key: ignores source_mbid/country/page_size. Use build_scene_cache_key_v1." +)] pub fn build_scene_cache_key(area_id: &str, seeds: &AffinitySeeds) -> String { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; @@ -249,6 +259,170 @@ pub fn build_scene_cache_key(area_id: &str, seeds: &AffinitySeeds) -> String { format!("{}:{:x}", area_id, seed_hash) } +/// Version tag baked into every scene cache key. +/// +/// BUMP IT whenever the discovery pipeline changes what a stored +/// `LocationDiscoveryResponse` means — scoring weights, the broad-tag filter, +/// the per-genre limit, the `LocationCandidate` shape. Bumping is how old +/// entries get abandoned; there is no migration and none is wanted, since a +/// stale scene is indistinguishable from a fresh one on the wire. +pub const SCENE_CACHE_KEY_VERSION: &str = "v1"; + +/// Everything a scene discovery response actually depends on. +/// +/// Every field here is load-bearing, which is the whole point — the previous +/// key had two of them: +/// - `source_mbid`: the source artist is *excluded* from their own scene, so +/// two artists from the same city with the same seeds produce different rows. +/// - `area_id`: the MB area id as the caller supplied it, falling back to the +/// area NAME when there is none. Deliberately the pre-resolution identity: +/// city → parent-subdivision resolution is a pure function of this id, so +/// keying on it lets the cache be consulted BEFORE those up-to-5 MB hops are +/// spent. Two cities in one subdivision therefore keep separate entries, +/// which is also what Tauri did. +/// - `country`: the MB query is `area:"<country>"` whenever a country is known, +/// so it, not the subdivision, is what narrows the search. +/// - the seeds: they pick the search genres AND drive the affinity scoring. +/// - `page_size`: a 30-row response must never be served to a 100-row caller. +/// - `catalog_scope`: the Qobuz account territory decides which candidates +/// validate. Currently `None` everywhere (no accessor exists on the core); +/// the slot is here so filling it later is a key change, not a schema change. +/// Until then, invalidate the scene cache on account switch. +pub struct SceneCacheKey<'a> { + pub source_mbid: &'a str, + pub area_id: &'a str, + pub country: Option<&'a str>, + pub seeds: &'a AffinitySeeds, + pub page_size: usize, + pub catalog_scope: Option<&'a str>, +} + +/// Build the versioned scene cache key. +/// +/// The seed lists are hashed rather than inlined because they are unbounded; +/// everything else stays readable in the key so a human can eyeball the DB. +pub fn build_scene_cache_key_v1(key: &SceneCacheKey<'_>) -> String { + format!( + "{}|{}|{}|{}|{:016x}|{}|{}", + SCENE_CACHE_KEY_VERSION, + key.source_mbid, + key.area_id, + key.country.unwrap_or("-"), + seed_signature(key.seeds), + key.page_size, + key.catalog_scope.unwrap_or("-"), + ) +} + +/// Stable 64-bit signature of the affinity seeds. +/// +/// Two deliberate choices: +/// +/// 1. **Order is preserved**, not sorted. It looks like sorting would give a +/// better hit rate, but the pipeline slices these lists — `genres.chain( +/// tags.take(2))`, and `take(3)` on the all-broad fallback — so a different +/// input order is a genuinely different query. Sorting would collapse two +/// different result sets onto one entry. Duplicates are dropped keeping the +/// FIRST occurrence, which is exactly what `take(n)` sees. +/// 2. **FNV-1a, not `DefaultHasher`.** `DefaultHasher`'s output is explicitly +/// not guaranteed stable across Rust releases; a toolchain bump would +/// silently orphan every cached scene. FNV-1a is eight lines and frozen. +/// +/// Genres and tags are hashed as separate sections so `genres=[a] tags=[b]` +/// cannot collide with `genres=[a,b] tags=[]` — they search differently. +fn seed_signature(seeds: &AffinitySeeds) -> u64 { + let mut buf = String::new(); + push_canonical_list(&mut buf, &seeds.genres); + buf.push('\u{1e}'); // record separator between the two sections + push_canonical_list(&mut buf, &seeds.tags); + fnv1a64(buf.as_bytes()) +} + +fn push_canonical_list(buf: &mut String, list: &[String]) { + let mut seen: HashSet<String> = HashSet::new(); + for raw in list { + let canonical = normalize_genre(raw); + if canonical.is_empty() || !seen.insert(canonical.clone()) { + continue; + } + buf.push_str(&canonical); + buf.push('\u{1f}'); // unit separator between entries + } +} + +/// FNV-1a, 64-bit. Deterministic across toolchains, forever, with no dependency. +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +#[cfg(test)] +mod scene_key_tests { + use super::*; + + fn seeds(genres: &[&str], tags: &[&str]) -> AffinitySeeds { + AffinitySeeds { + genres: genres.iter().map(|s| s.to_string()).collect(), + tags: tags.iter().map(|s| s.to_string()).collect(), + normalized_seeds: genres + .iter() + .chain(tags.iter()) + .map(|s| s.to_string()) + .collect(), + } + } + + fn key(source_mbid: &str, page_size: usize, s: &AffinitySeeds) -> String { + build_scene_cache_key_v1(&SceneCacheKey { + source_mbid, + area_id: "area-1", + country: Some("United Kingdom"), + seeds: s, + page_size, + catalog_scope: None, + }) + } + + #[test] + fn page_size_is_part_of_the_key() { + // The whole reason R7 was re-decided: Slint asks for 30 rows and Qt + // asks for 100 against the same DB file. + let s = seeds(&["post-punk"], &[]); + assert_ne!(key("mbid-a", 30, &s), key("mbid-a", 100, &s)); + } + + #[test] + fn source_artist_is_part_of_the_key() { + // The source artist is excluded from their own scene, so two artists + // with identical area+seeds do NOT share a response. + let s = seeds(&["post-punk"], &[]); + assert_ne!(key("mbid-a", 100, &s), key("mbid-b", 100, &s)); + } + + #[test] + fn seed_sections_do_not_collide() { + // genres=[a] tags=[b] searches differently from genres=[a,b] tags=[]. + assert_ne!( + key("mbid-a", 100, &seeds(&["post-punk"], &["new wave"])), + key("mbid-a", 100, &seeds(&["post-punk", "new wave"], &[])), + ); + } + + #[test] + fn seed_order_is_significant_but_duplicates_are_not() { + let s = seeds(&["post-punk", "new wave"], &[]); + assert_ne!(key("mbid-a", 100, &s), key("mbid-a", 100, &seeds(&["new wave", "post-punk"], &[]))); + assert_eq!( + key("mbid-a", 100, &s), + key("mbid-a", 100, &seeds(&["post-punk", "new wave", "post-punk"], &[])), + ); + } +} + /// Format a human-readable date from MB life_span pub fn format_life_span_date(life_span: &LifeSpan, _is_person: bool) -> Option<String> { let begin = life_span.begin.as_deref()?; diff --git a/crates/qbz-integrations/src/musicbrainz/models.rs b/crates/qbz-integrations/src/musicbrainz/models.rs index ac2208238..0f4b4115c 100644 --- a/crates/qbz-integrations/src/musicbrainz/models.rs +++ b/crates/qbz-integrations/src/musicbrainz/models.rs @@ -359,6 +359,8 @@ pub struct Medium { /// A track on a medium #[derive(Debug, Deserialize)] pub struct MediumTrack { + #[serde(default)] + pub id: Option<String>, pub position: Option<u8>, pub number: Option<String>, pub title: Option<String>, diff --git a/crates/qbz-integrations/src/remote_metadata/mod.rs b/crates/qbz-integrations/src/remote_metadata/mod.rs index 2119b7547..ecb6770bf 100644 --- a/crates/qbz-integrations/src/remote_metadata/mod.rs +++ b/crates/qbz-integrations/src/remote_metadata/mod.rs @@ -8,10 +8,35 @@ mod models; pub use models::{ - RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteMetadataError, RemoteProvider, - RemoteSearchRequest, RemoteSearchResponse, RemoteTrackMetadata, + RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteArtistCredit, RemoteMetadataError, + RemoteProvider, RemoteSearchRequest, RemoteSearchResponse, RemoteTrackMetadata, }; +fn musicbrainz_credits( + credits: Option<&Vec<crate::musicbrainz::ArtistCredit>>, +) -> Vec<RemoteArtistCredit> { + credits + .into_iter() + .flatten() + .map(|credit| RemoteArtistCredit { + name: credit + .name + .as_deref() + .unwrap_or(&credit.artist.name) + .to_string(), + join_phrase: credit.joinphrase.clone().unwrap_or_default(), + provider_id: Some(credit.artist.id.clone()), + }) + .collect() +} + +fn display_credit(credits: &[RemoteArtistCredit]) -> String { + credits + .iter() + .map(|credit| format!("{}{}", credit.name, credit.join_phrase)) + .collect() +} + pub fn musicbrainz_release_to_search_result( release: &crate::musicbrainz::ReleaseResult, ) -> RemoteAlbumSearchResult { @@ -206,24 +231,8 @@ pub fn discogs_extended_to_search_result( pub fn musicbrainz_full_to_metadata( release: &crate::musicbrainz::ReleaseFullResponse, ) -> RemoteAlbumMetadata { - // Extract artist from artist-credit - let artist = release - .artist_credit - .as_ref() - .map(|credits| { - credits - .iter() - .map(|c| { - format!( - "{}{}", - c.name.as_deref().unwrap_or(&c.artist.name), - c.joinphrase.as_deref().unwrap_or("") - ) - }) - .collect::<Vec<_>>() - .join("") - }) - .unwrap_or_default(); + let artist_credits = musicbrainz_credits(release.artist_credit.as_ref()); + let artist = display_credit(&artist_credits); // Extract year from date let year = release @@ -267,6 +276,12 @@ pub fn musicbrainz_full_to_metadata( for medium in media { if let Some(tracks) = &medium.tracks { for track in tracks { + let track_credits = track + .recording + .as_ref() + .map(|recording| musicbrainz_credits(recording.artist_credit.as_ref())) + .filter(|credits| !credits.is_empty()) + .unwrap_or_else(|| artist_credits.clone()); all_tracks.push(RemoteTrackMetadata { disc_number: medium.position.unwrap_or(1), track_number: track.position.unwrap_or(1), @@ -281,6 +296,13 @@ pub fn musicbrainz_full_to_metadata( .as_ref() .and_then(|r| r.length.map(|l| l as u32)) }), + artist_credit: display_credit(&track_credits), + artist_credits: track_credits, + recording_id: track + .recording + .as_ref() + .map(|recording| recording.id.clone()), + track_id: track.id.clone(), }); } } @@ -294,6 +316,7 @@ pub fn musicbrainz_full_to_metadata( provider_id: release.id.clone(), title: release.title.clone(), artist, + artist_credits, year, genres, label, @@ -303,6 +326,7 @@ pub fn musicbrainz_full_to_metadata( tracks, disc_count, source_url: Some(format!("https://musicbrainz.org/release/{}", release.id)), + release_group_id: release.release_group.as_ref().map(|group| group.id.clone()), } } @@ -310,18 +334,21 @@ pub fn musicbrainz_full_to_metadata( pub fn discogs_full_to_metadata( release: &crate::discogs::DiscogsReleaseMetadata, ) -> RemoteAlbumMetadata { - // Combine artists with join phrases - let artist = release + let artist_credits = release .artists .as_ref() .map(|artists| { artists .iter() - .map(|a| format!("{}{}", a.name.clone(), a.join.as_deref().unwrap_or(""))) + .map(|artist| RemoteArtistCredit { + name: artist.name.clone(), + join_phrase: artist.join.clone().unwrap_or_default(), + provider_id: artist.id.map(|id| id.to_string()), + }) .collect::<Vec<_>>() - .join("") }) .unwrap_or_default(); + let artist = display_credit(&artist_credits); // Combine genres and styles let genres: Vec<String> = { @@ -361,6 +388,10 @@ pub fn discogs_full_to_metadata( track_number, title: t.title.clone(), duration_ms: t.duration.as_ref().and_then(|d| parse_discogs_duration(d)), + artist_credit: artist.clone(), + artist_credits: artist_credits.clone(), + recording_id: None, + track_id: None, } }) .collect() @@ -375,6 +406,7 @@ pub fn discogs_full_to_metadata( provider_id: release.id.to_string(), title: release.title.clone(), artist, + artist_credits, year: release.year.map(|y| y as u16), genres, label, @@ -384,5 +416,237 @@ pub fn discogs_full_to_metadata( tracks, disc_count, source_url: release.uri.clone(), + release_group_id: None, + } +} + +// --------------------------------------------------------------------------- +// Orchestration — "ask a provider what this record is" +// --------------------------------------------------------------------------- +// +// The converters above are pure; these two are the thin async layer that puts +// a client in front of them. They live HERE rather than in the frontend for +// the reason ADR-006 gives: which provider answers, how a search query is +// built and what an empty answer means are domain decisions, and a second +// frontend must not have to re-derive them. +// +// The Tauri reference is `src-tauri/src/commands_v2/integrations.rs` +// (`v2_remote_metadata_search` / `v2_remote_metadata_get_album`). This is that +// orchestration with its Tauri `State` arguments removed — the clients are +// created per call, because both are thin wrappers over a `reqwest::Client` +// and MusicBrainz's rate limiter lives inside its own client. + +/// Search one provider. Never panics, never blocks; a provider that is +/// disabled, rate-limited or simply silent answers with an EMPTY result set +/// rather than an error, because "nobody has this record" and "the server said +/// no" look identical to a user staring at a list — and the distinction that +/// does matter (rate limiting) rides its own flag. +pub async fn search(request: &RemoteSearchRequest) -> RemoteSearchResponse { + let limit = request.limit(); + match request.provider { + RemoteProvider::MusicBrainz => { + let client = crate::musicbrainz::MusicBrainzClient::new(); + // The query is "artist album" by convention (the caller builds + // it), but MusicBrainz wants the two apart. Splitting on the + // caller's own `artist` hint is exact when it has one, and the + // whole string is a title otherwise. + let artist = request.artist.clone().unwrap_or_default(); + let title = strip_prefix_ci(&request.query, &artist); + match client + .search_releases_extended(&title, &artist, request.catalog_id.as_deref(), limit) + .await + { + Ok(found) => RemoteSearchResponse { + provider: RemoteProvider::MusicBrainz, + results: found + .releases + .iter() + .map(musicbrainz_release_to_search_result) + .collect(), + total_count: usize::try_from(found.count).ok(), + rate_limited: false, + }, + Err(e) => { + log::warn!("[remote-metadata] musicbrainz search failed: {e}"); + empty(RemoteProvider::MusicBrainz, is_rate_limit(&e.to_string())) + } + } + } + RemoteProvider::Discogs => { + let client = crate::discogs::DiscogsClient::new(); + let artist = request.artist.clone().unwrap_or_default(); + let title = strip_prefix_ci(&request.query, &artist); + match client + .search_releases(&artist, &title, request.catalog_id.as_deref(), limit) + .await + { + Ok(found) => RemoteSearchResponse { + provider: RemoteProvider::Discogs, + total_count: Some(found.len()), + results: found + .iter() + .map(discogs_extended_to_search_result) + .collect(), + rate_limited: false, + }, + Err(e) => { + log::warn!("[remote-metadata] discogs search failed: {e}"); + empty(RemoteProvider::Discogs, is_rate_limit(&e)) + } + } + } + } +} + +/// Fetch one release in full, with its track list. +pub async fn get_album( + provider: RemoteProvider, + provider_id: &str, +) -> Result<RemoteAlbumMetadata, RemoteMetadataError> { + match provider { + RemoteProvider::MusicBrainz => { + let client = crate::musicbrainz::MusicBrainzClient::new(); + let full = client + .get_release_with_tracks(provider_id) + .await + .map_err(|e| RemoteMetadataError::ProviderUnavailable(e.to_string()))?; + Ok(musicbrainz_full_to_metadata(&full)) + } + RemoteProvider::Discogs => { + // A Discogs release id is numeric. Saying so beats sending a + // MusicBrainz UUID to Discogs and reporting whatever it answers. + let id: u64 = provider_id + .trim() + .parse() + .map_err(|_| RemoteMetadataError::InvalidProviderId(provider_id.to_string()))?; + let client = crate::discogs::DiscogsClient::new(); + let full = client + .get_release_metadata(id) + .await + .map_err(RemoteMetadataError::ProviderUnavailable)?; + Ok(discogs_full_to_metadata(&full)) + } + } +} + +fn empty(provider: RemoteProvider, rate_limited: bool) -> RemoteSearchResponse { + RemoteSearchResponse { + provider, + results: Vec::new(), + total_count: None, + rate_limited, + } +} + +/// A 429, however the provider spells it. Both clients hand back a formatted +/// string rather than a status, so this reads the string — narrowly, on the +/// two spellings that actually occur, rather than on the word "limit" which +/// appears in perfectly ordinary messages. +fn is_rate_limit(message: &str) -> bool { + let m = message.to_ascii_lowercase(); + m.contains("429") || m.contains("rate limit") || m.contains("too many requests") +} + +/// `"Tool Fear Inoculum"` minus `"Tool"` = `"Fear Inoculum"`. +/// +/// Case-insensitive and prefix-only: a band whose name also appears inside the +/// album title ("Black Sabbath — Black Sabbath") must lose exactly one copy, +/// and only the leading one. +fn strip_prefix_ci(query: &str, artist: &str) -> String { + let q = query.trim(); + let a = artist.trim(); + if a.is_empty() || q.len() < a.len() { + return q.to_string(); + } + if q[..a.len()].eq_ignore_ascii_case(a) { + return q[a.len()..].trim().to_string(); + } + q.to_string() +} + +#[cfg(test)] +mod orchestration_tests { + use super::*; + + #[test] + fn the_artist_is_taken_off_the_front_of_the_query_once() { + assert_eq!( + strip_prefix_ci("Tool Fear Inoculum", "Tool"), + "Fear Inoculum" + ); + assert_eq!( + strip_prefix_ci("Black Sabbath Black Sabbath", "Black Sabbath"), + "Black Sabbath", + "only the LEADING copy comes off" + ); + assert_eq!(strip_prefix_ci("tool Lateralus", "Tool"), "Lateralus"); + } + + #[test] + fn a_query_that_does_not_start_with_the_artist_is_left_alone() { + assert_eq!(strip_prefix_ci("Fear Inoculum", "Tool"), "Fear Inoculum"); + assert_eq!(strip_prefix_ci("Fear Inoculum", ""), "Fear Inoculum"); + // Shorter than the artist: the slice would panic, so it must not be taken. + assert_eq!(strip_prefix_ci("Up", "Peter Gabriel"), "Up"); + } + + #[test] + fn rate_limiting_is_recognised_without_swallowing_ordinary_messages() { + assert!(is_rate_limit("HTTP 429 Too Many Requests")); + assert!(is_rate_limit("rate limit exceeded")); + assert!(!is_rate_limit("Failed to parse Discogs response")); + assert!( + !is_rate_limit("limit must be between 1 and 25"), + "the bare word `limit` is not a 429" + ); + } + + #[test] + fn musicbrainz_converter_keeps_display_credits_components_and_ids_separate() { + let release: crate::musicbrainz::ReleaseFullResponse = serde_json::from_value( + serde_json::json!({ + "id": "release-id", + "title": "Collaborations", + "artist-credit": [ + {"name": "Alpha", "joinphrase": " feat. ", "artist": {"id": "alpha-id", "name": "Alpha"}}, + {"name": "Beta", "joinphrase": "", "artist": {"id": "beta-id", "name": "Beta"}} + ], + "release-group": {"id": "group-id", "primary-type": "Album"}, + "media": [{ + "position": 1, + "tracks": [{ + "id": "track-id", + "position": 1, + "title": "A Song", + "recording": { + "id": "recording-id", + "title": "A Song", + "artist-credit": [ + {"name": "Gamma", "joinphrase": "", "artist": {"id": "gamma-id", "name": "Gamma"}} + ] + } + }] + }] + }), + ) + .unwrap(); + + let metadata = musicbrainz_full_to_metadata(&release); + assert_eq!(metadata.artist, "Alpha feat. Beta"); + assert_eq!(metadata.release_group_id.as_deref(), Some("group-id")); + assert_eq!( + metadata.artist_credits[0].provider_id.as_deref(), + Some("alpha-id") + ); + assert_eq!(metadata.tracks[0].artist_credit, "Gamma"); + assert_eq!( + metadata.tracks[0].recording_id.as_deref(), + Some("recording-id") + ); + assert_eq!(metadata.tracks[0].track_id.as_deref(), Some("track-id")); + assert_eq!( + metadata.tracks[0].artist_credits[0].provider_id.as_deref(), + Some("gamma-id") + ); } } diff --git a/crates/qbz-integrations/src/remote_metadata/models.rs b/crates/qbz-integrations/src/remote_metadata/models.rs index bbeecb330..f3be75330 100644 --- a/crates/qbz-integrations/src/remote_metadata/models.rs +++ b/crates/qbz-integrations/src/remote_metadata/models.rs @@ -72,6 +72,11 @@ pub struct RemoteAlbumMetadata { pub title: String, /// Album artist pub artist: String, + /// Ordered artist-credit components. `artist` remains the display credit + /// including join phrases; these rows preserve identity without guessing + /// how a human-readable credit should be split. + #[serde(default)] + pub artist_credits: Vec<RemoteArtistCredit>, /// Release year pub year: Option<u16>, /// Genres/styles/tags @@ -90,6 +95,19 @@ pub struct RemoteAlbumMetadata { pub disc_count: u8, /// URL to view on provider website pub source_url: Option<String>, + /// MusicBrainz release-group identity when the provider exposes it. + #[serde(default)] + pub release_group_id: Option<String>, +} + +/// One component of an album or track artist credit. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RemoteArtistCredit { + pub name: String, + #[serde(default)] + pub join_phrase: String, + #[serde(default)] + pub provider_id: Option<String>, } /// Single track metadata @@ -103,6 +121,16 @@ pub struct RemoteTrackMetadata { pub title: String, /// Duration in milliseconds (if available) pub duration_ms: Option<u32>, + /// Display credit, including intentional join phrases. + #[serde(default)] + pub artist_credit: String, + /// Ordered, typed credit components. + #[serde(default)] + pub artist_credits: Vec<RemoteArtistCredit>, + #[serde(default)] + pub recording_id: Option<String>, + #[serde(default)] + pub track_id: Option<String>, } /// Search request parameters diff --git a/crates/qbz-jellyfin/Cargo.toml b/crates/qbz-jellyfin/Cargo.toml new file mode 100644 index 000000000..a1a74d2bb --- /dev/null +++ b/crates/qbz-jellyfin/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "qbz-jellyfin" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Jellyfin integration for QBZ: HTTP client, JSON metadata, bit-perfect stream URLs (frontend-agnostic)" + +[dependencies] +# HTTP client for the Jellyfin server API. +reqwest = { workspace = true, features = ["json"] } + +# Jellyfin speaks JSON end to end (Plex speaks XML — that is the whole +# difference between this crate's parsing and `qbz-plex`'s). +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +# reqwest is built with `rustls-tls-webpki-roots-no-provider`, so a +# `reqwest::Client` cannot be CONSTRUCTED until a process-level CryptoProvider +# is installed. The app does that in `qbz_app::install_crypto_provider`; a test +# binary has no app, so the live harness installs it itself (same shape as +# `qbz-offline-cache/tests/purchase_download_harness.rs`). +rustls = { version = "0.23", features = ["aws_lc_rs"] } diff --git a/crates/qbz-jellyfin/src/lib.rs b/crates/qbz-jellyfin/src/lib.rs new file mode 100644 index 000000000..fac58d273 --- /dev/null +++ b/crates/qbz-jellyfin/src/lib.rs @@ -0,0 +1,1112 @@ +//! Jellyfin integration — the protocol half. +//! +//! Sibling of `qbz-plex`, and deliberately shaped like it so `qbz-source`'s +//! `JellyfinSource` reads like `PlexSource`. Frontend-agnostic: no Qt, no +//! settings store, no cache schema — this crate speaks HTTP and returns DTOs. +//! +//! Everything below was MEASURED against Jellyfin 10.11.11 on 2026-08-20; the +//! numbers and the field shapes live in +//! `qbz-nix-docs/qt-frontend/2026-08-20-jellyfin-subsonic/01-research.md`. +//! +//! # The three facts that shape this file +//! +//! **1. `?static=true` is bit-perfect, and it was verified by md5.** The +//! response bytes of `/Audio/{id}/stream?static=true` are byte-identical to the +//! file on disk (checked against the server's own filesystem, not inferred from +//! a content type). It answers with `Content-Length` and `Accept-Ranges: bytes`, +//! which is exactly what QBZ's progressive feeder needs. +//! +//! The transcode path is unmistakable when you hit it: it answers +//! `Transfer-Encoding: chunked` with **no `Content-Length`**. That is not a +//! footnote — it is a cheap runtime assertion, and [`is_direct_response`] +//! exists so a regression is caught rather than quietly resampled. QBZ never +//! asks for a transcode and must never accept one. +//! +//! **2. Quality is available with the listing, but it is not free.** `BitDepth` and +//! `SampleRate` live in `MediaSources[].MediaStreams[]`, which requires +//! `Fields=MediaSources`. Measured on a 500-item page: 0.25 s without it, 4.56 s +//! with. Full sweep of 4924 tracks: **45.8 s**. `Fields=MediaStreams` trims 29 % +//! of the bytes and saves nothing — the cost is server-side media-info +//! hydration, not transfer. Catalog discovery therefore omits `MediaSources`; +//! a secondary, resumable hydration pass requests it only for bounded id +//! batches, with visible and queued rows allowed to jump ahead. +//! +//! **3. Artwork needs no credentials.** `/Items/{id}/Images/Primary` answers +//! 200 unauthenticated. Unlike Plex, whose thumb url has to be re-tokenized on +//! every pass, a Jellyfin cover url is STABLE — so its cache key and its fetch +//! url are the same string, and it can be memoized forever. +//! +//! # What is deliberately absent +//! +//! No writes. No favourites, no playlists, no playback reporting. Read + play +//! is the 2.1 scope; both are supported by the server and neither is exercised +//! by anything today, so shipping them untested would be inventing surface. + +use std::collections::HashMap; +use std::time::Duration; + +use serde::Deserialize; + +/// Matches `qbz-plex`'s client budget: a hung request on a captive portal must +/// not pin a caller indefinitely. The STREAM is never fetched through this +/// client — the player's feeder owns that, with no deadline, because a hi-res +/// FLAC over a slow link legitimately outlives any probe budget. +const HTTP_TIMEOUT: Duration = Duration::from_secs(15); + +/// Page size for the cheap essential-metadata sweep. It stays inside the +/// catalog's initial 100–250 row budget; the expensive quality pass has its +/// own, smaller batch size. +pub const PAGE_SIZE: u32 = 250; + +/// Maximum ids in one secondary `MediaSources` request. A priority batch must +/// be small enough that newly visible/queued tracks are not stuck behind +/// several seconds of unrelated server-side probing. +pub const QUALITY_BATCH_SIZE: usize = 50; + +/// Cover size this client requests, app-wide. One size is deliberate, for the +/// same reason `artwork_qt::PLEX_THUMB_PX` is: the cache key is the url, so a +/// second size means a second download of the same cover. +pub const IMAGE_PX: u32 = 256; + +/// The larger tier, for hero / immersive slots only. +pub const IMAGE_PX_LARGE: u32 = 1024; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Anything that can go wrong talking to a Jellyfin server. +/// +/// A plain `String` would have done what `qbz-plex` does, but the caller has a +/// real decision to make on `Unauthorized` (re-authenticate) that it cannot +/// make by matching on prose. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JellyfinError { + /// The request never completed (DNS, refused, timed out). + Transport(String), + /// 401/403 — the token is gone or the user was disabled. + Unauthorized, + /// Any other non-success status. + Status(u16), + /// The body was not the shape this client expects. + Decode(String), + /// The server answered, and the answer is "no such thing". + NotFound(String), +} + +impl std::fmt::Display for JellyfinError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + JellyfinError::Transport(e) => write!(f, "jellyfin request failed: {e}"), + JellyfinError::Unauthorized => write!(f, "jellyfin rejected the credentials"), + JellyfinError::Status(s) => write!(f, "jellyfin answered {s}"), + JellyfinError::Decode(e) => write!(f, "jellyfin response not understood: {e}"), + JellyfinError::NotFound(what) => write!(f, "jellyfin has no {what}"), + } + } +} + +impl std::error::Error for JellyfinError {} + +type Result<T> = std::result::Result<T, JellyfinError>; + +// --------------------------------------------------------------------------- +// DTOs — only the fields QBZ consumes +// --------------------------------------------------------------------------- + +/// `GET /System/Info/Public` — the unauthenticated probe behind "test +/// connection". It also validates a URL before the user is asked for a +/// password, which is the difference between "wrong address" and "wrong +/// password" in the settings panel. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ServerInfo { + pub server_name: String, + pub version: String, + pub id: String, + #[serde(default)] + pub startup_wizard_completed: bool, +} + +/// What `POST /Users/AuthenticateByName` hands back. +#[derive(Debug, Clone)] +pub struct Session { + pub access_token: String, + pub user_id: String, + pub user_name: String, + pub server_id: String, +} + +/// One music library ("view"), from `GET /Users/{uid}/Views`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MusicLibrary { + pub id: String, + pub name: String, +} + +/// One audio item, flattened out of the `/Items` envelope into the shape the +/// cache stores. +/// +/// Field names are QBZ's, not Jellyfin's: this is the boundary where the +/// vendor's vocabulary stops. +#[derive(Debug, Clone, PartialEq)] +pub struct JellyfinTrack { + /// The item id — the ONLY identifier. Everything else is display data. + pub id: String, + pub title: String, + pub artist: String, + pub album_artist: String, + pub album: String, + /// The parent `MusicAlbum` item id. Present on every row measured (0 of + /// 4924 lacked it), and it is what groups a library into albums. + pub album_id: String, + /// `None` on 75 of 4924 measured rows — ordering must tolerate it. + pub track_number: Option<u32>, + pub disc_number: Option<u32>, + pub duration_ms: u64, + pub year: Option<u32>, + /// Every genre Jellyfin supplied, in server order. `genre` remains the + /// compatibility primary value for older cache readers. + pub genres: Vec<String>, + pub genre: Option<String>, + /// Container as the server names it (`flac`, `mp3`, `ape`). + pub container: String, + pub codec: Option<String>, + /// `None` for lossy codecs — MP3 reports no bit depth. Subsonic reports 0 + /// for the same case; both mean "not applicable" and the mapper folds them. + pub bit_depth: Option<u32>, + pub sample_rate_hz: Option<u32>, + pub channels: Option<u32>, + pub bitrate_bps: Option<u32>, + /// The album's primary-image tag. Absent on 1053 of 4924 measured rows + /// (21 %). Its absence does not mean the album has no image: Jellyfin's + /// primary-image endpoint remains addressable by `album_id` without a tag. + pub album_image_tag: Option<String>, + /// MusicBrainz recording id from `ProviderIds.MusicBrainzTrack`, when the + /// server has one. Cross-source identity for the listen log. + pub recording_mbid: Option<String>, + /// This audio item's own primary-image tag. Jellyfin/Navidrome can expose + /// a different embedded or folder cover for each disc while still grouping + /// every row under one `MusicAlbum`; this must outrank the collection art. + pub item_image_tag: Option<String>, + /// The server's own path. Real, unlike Subsonic's synthesised one, but QBZ + /// never opens it: the file lives on the server. Kept for diagnostics only. + pub server_path: Option<String>, +} + +/// Quality fields hydrated independently from the essential track listing. +/// The item id is the join key; no list position is trusted because Jellyfin +/// may omit an item deleted between discovery and hydration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JellyfinTrackQuality { + pub id: String, + pub container: String, + pub codec: Option<String>, + pub bit_depth: Option<u32>, + pub sample_rate_hz: Option<u32>, + pub channels: Option<u32>, + pub bitrate_bps: Option<u32>, +} + +impl JellyfinTrack { + /// Jellyfin counts in 100 ns ticks. + fn ticks_to_ms(ticks: i64) -> u64 { + (ticks.max(0) as u64) / 10_000 + } +} + +// --- wire shapes (private) -------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct ItemsEnvelope<T> { + items: Vec<T>, + total_record_count: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct ViewDto { + id: String, + name: String, + #[serde(default)] + collection_type: Option<String>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct AuthDto { + access_token: String, + server_id: String, + user: AuthUserDto, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct AuthUserDto { + id: String, + name: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct AudioDto { + id: String, + #[serde(default)] + name: String, + #[serde(default)] + album: Option<String>, + #[serde(default)] + album_id: Option<String>, + #[serde(default)] + album_artist: Option<String>, + #[serde(default)] + artists: Vec<String>, + #[serde(default)] + index_number: Option<u32>, + #[serde(default)] + parent_index_number: Option<u32>, + #[serde(default)] + run_time_ticks: Option<i64>, + #[serde(default)] + production_year: Option<u32>, + #[serde(default)] + genres: Vec<String>, + #[serde(default)] + container: Option<String>, + #[serde(default)] + path: Option<String>, + #[serde(default)] + album_primary_image_tag: Option<String>, + #[serde(default)] + image_tags: HashMap<String, String>, + #[serde(default)] + media_sources: Vec<MediaSourceDto>, + /// `ProviderIds` — Jellyfin's own name for external ids; for an audio + /// item `MusicBrainzTrack` is the RECORDING id (its scanner writes the + /// file's MUSICBRAINZ_TRACKID there). Requested via `Fields=ProviderIds`. + #[serde(default)] + provider_ids: HashMap<String, String>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct MediaSourceDto { + #[serde(default)] + media_streams: Vec<MediaStreamDto>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct MediaStreamDto { + #[serde(default)] + #[serde(rename = "Type")] + kind: String, + #[serde(default)] + codec: Option<String>, + #[serde(default)] + bit_depth: Option<u32>, + #[serde(default)] + sample_rate: Option<u32>, + #[serde(default)] + channels: Option<u32>, + #[serde(default)] + bit_rate: Option<u32>, +} + +impl AudioDto { + fn audio_stream(&self) -> Option<&MediaStreamDto> { + self.media_sources.first().and_then(|source| { + source + .media_streams + .iter() + .find(|stream| stream.kind == "Audio") + }) + } + + fn into_quality(self) -> JellyfinTrackQuality { + let (codec, bit_depth, sample_rate_hz, channels, bitrate_bps) = self + .audio_stream() + .map(|stream| { + ( + stream.codec.clone(), + stream.bit_depth, + stream.sample_rate, + stream.channels, + stream.bit_rate, + ) + }) + .unwrap_or_default(); + JellyfinTrackQuality { + id: self.id, + container: self.container.unwrap_or_default(), + codec, + bit_depth, + sample_rate_hz, + channels, + bitrate_bps, + } + } + + fn into_track(self) -> JellyfinTrack { + // The FIRST audio stream. A cover embedded as `EmbeddedImage` shows up + // in the same array, which is why this filters by type rather than + // taking `[0]` — that would read a JPEG's 8-bit depth as the audio's. + let audio = self + .media_sources + .into_iter() + .next() + .map(|ms| ms.media_streams) + .unwrap_or_default() + .into_iter() + .find(|s| s.kind == "Audio"); + let artist = self.artists.first().cloned().unwrap_or_default(); + let album_artist = self.album_artist.clone().unwrap_or_else(|| artist.clone()); + let genres = self + .genres + .into_iter() + .map(|genre| genre.trim().to_string()) + .filter(|genre| !genre.is_empty()) + .fold(Vec::<String>::new(), |mut out, genre| { + if !out.iter().any(|value| value.eq_ignore_ascii_case(&genre)) { + out.push(genre); + } + out + }); + let item_image_tag = self + .image_tags + .iter() + .find(|(kind, _)| kind.eq_ignore_ascii_case("primary")) + .map(|(_, tag)| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()); + JellyfinTrack { + id: self.id, + title: self.name, + artist: if artist.is_empty() { + album_artist.clone() + } else { + artist + }, + album_artist, + album: self.album.unwrap_or_default(), + album_id: self.album_id.unwrap_or_default(), + track_number: self.index_number, + disc_number: self.parent_index_number, + duration_ms: JellyfinTrack::ticks_to_ms(self.run_time_ticks.unwrap_or(0)), + year: self.production_year, + genre: genres.first().cloned(), + genres, + container: self.container.unwrap_or_default(), + codec: audio.as_ref().and_then(|a| a.codec.clone()), + // A lossy codec reports no bit depth. Preserved as `None` rather + // than defaulted to 16, which would badge an MP3 as CD quality. + bit_depth: audio.as_ref().and_then(|a| a.bit_depth), + sample_rate_hz: audio.as_ref().and_then(|a| a.sample_rate), + channels: audio.as_ref().and_then(|a| a.channels), + bitrate_bps: audio.as_ref().and_then(|a| a.bit_rate), + album_image_tag: self.album_primary_image_tag, + item_image_tag, + server_path: self.path, + recording_mbid: self + .provider_ids + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("MusicBrainzTrack")) + .map(|(_, value)| value.trim().to_string()) + .filter(|value| !value.is_empty()), + } + } +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// A connected Jellyfin server. +/// +/// Holds no credentials store and no cache — those belong to the frontend and +/// to `qbz-source` respectively (ADR-006). +#[derive(Debug, Clone)] +pub struct JellyfinClient { + http: reqwest::Client, + base: String, + token: String, + user_id: String, +} + +/// Normalise a user-typed address: strip a trailing slash, default the scheme +/// to http (a LAN server is the overwhelming case and typing `http://` is the +/// most common omission). +pub fn normalize_base_url(input: &str) -> String { + let s = input.trim().trim_end_matches('/'); + if s.is_empty() { + return String::new(); + } + if s.starts_with("http://") || s.starts_with("https://") { + s.to_string() + } else { + format!("http://{s}") + } +} + +/// The `Authorization` header every Jellyfin client must send, with or without +/// a token. +/// +/// **`DeviceId` is load-bearing, and getting it wrong revokes your own token.** +/// Jellyfin keys the SESSION on it, and a second `AuthenticateByName` under the +/// same DeviceId replaces the first — the earlier token starts answering 401. +/// Measured against 10.11.11: +/// +/// ```text +/// same DeviceId: auth -> T1, auth -> T2; T1 = 401, T2 = 200 +/// different DeviceId: auth -> A, auth -> B; A = 200, B = 200 +/// ``` +/// +/// Two obligations follow, and neither is optional: +/// +/// 1. **Stable per install**, persisted like `local_plex::client_id` — not a +/// constant (two QBZ installs would then fight over one session, each +/// logging the other out) and not fresh per launch (which both litters the +/// server's devices page and drops the previous run's token). +/// 2. **Authenticate ONCE and hold the token.** Two concurrent auths leave +/// whichever finishes first holding a dead one. This is not hypothetical: +/// it is what five parallel live tests did to each other before they were +/// made to share a session. +fn auth_header(device_id: &str, token: Option<&str>) -> String { + let mut h = format!( + r#"MediaBrowser Client="QBZ", Device="{}", DeviceId="{}", Version="{}""#, + std::env::consts::OS, + device_id, + env!("CARGO_PKG_VERSION"), + ); + if let Some(t) = token { + h.push_str(&format!(r#", Token="{t}""#)); + } + h +} + +fn client() -> Result<reqwest::Client> { + reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|e| JellyfinError::Transport(e.to_string())) +} + +/// Reqwest's Display text contains the request URL for common failures. Server +/// addresses are private configuration and authenticated stream URLs carry a +/// token, so protocol errors retain only a coarse, non-sensitive reason. +fn transport_error(error: &reqwest::Error) -> JellyfinError { + let reason = if error.is_timeout() { + "request timed out" + } else if error.is_connect() { + "connection failed" + } else if error.is_decode() { + "response decoding failed" + } else if error.is_body() { + "response body failed" + } else { + "request failed" + }; + JellyfinError::Transport(reason.to_string()) +} + +fn check(status: reqwest::StatusCode) -> Result<()> { + if status.is_success() { + return Ok(()); + } + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(JellyfinError::Unauthorized); + } + Err(JellyfinError::Status(status.as_u16())) +} + +/// `GET /System/Info/Public` — no credentials required. +/// +/// The settings panel's "test connection": it separates "wrong address" from +/// "wrong password" BEFORE the user is asked for one. +pub async fn probe(base_url: &str) -> Result<ServerInfo> { + let base = normalize_base_url(base_url); + let resp = client()? + .get(format!("{base}/System/Info/Public")) + .send() + .await + .map_err(|e| transport_error(&e))?; + check(resp.status())?; + resp.json::<ServerInfo>() + .await + .map_err(|e| JellyfinError::Decode(e.to_string())) +} + +/// `POST /Users/AuthenticateByName`. +pub async fn authenticate( + base_url: &str, + device_id: &str, + username: &str, + password: &str, +) -> Result<Session> { + let base = normalize_base_url(base_url); + let resp = client()? + .post(format!("{base}/Users/AuthenticateByName")) + .header("Authorization", auth_header(device_id, None)) + .json(&serde_json::json!({ "Username": username, "Pw": password })) + .send() + .await + .map_err(|e| transport_error(&e))?; + check(resp.status())?; + let dto: AuthDto = resp + .json() + .await + .map_err(|e| JellyfinError::Decode(e.to_string()))?; + Ok(Session { + access_token: dto.access_token, + user_id: dto.user.id, + user_name: dto.user.name, + server_id: dto.server_id, + }) +} + +impl JellyfinClient { + /// Build a client over an EXISTING token (the stored-credentials path). + pub fn new(base_url: &str, token: &str, user_id: &str) -> Result<Self> { + Ok(Self { + http: client()?, + base: normalize_base_url(base_url), + token: token.to_string(), + user_id: user_id.to_string(), + }) + } + + pub fn base_url(&self) -> &str { + &self.base + } + + async fn get_json<T: for<'de> Deserialize<'de>>(&self, path_and_query: &str) -> Result<T> { + let resp = self + .http + .get(format!("{}{}", self.base, path_and_query)) + .header("X-Emby-Token", &self.token) + .send() + .await + .map_err(|e| transport_error(&e))?; + check(resp.status())?; + resp.json::<T>() + .await + .map_err(|e| JellyfinError::Decode(e.to_string())) + } + + /// The user's MUSIC libraries (`CollectionType == "music"`). + /// + /// A server also exposes a `playlists` view; it is filtered out here rather + /// than by the caller, because "which of these is music" is a question + /// about Jellyfin, and this crate is where Jellyfin's vocabulary lives. + pub async fn music_libraries(&self) -> Result<Vec<MusicLibrary>> { + let env: ItemsEnvelope<ViewDto> = self + .get_json(&format!("/Users/{}/Views", self.user_id)) + .await?; + Ok(env + .items + .into_iter() + .filter(|v| v.collection_type.as_deref() == Some("music")) + .map(|v| MusicLibrary { + id: v.id, + name: v.name, + }) + .collect()) + } + + /// How many audio items a library holds — one request, `Limit=0`. + /// + /// Worth its own call: the sweep costs ~9.3 ms per track, so a UI that + /// wants to show progress needs the denominator before it starts. + pub async fn track_count(&self, library_id: Option<&str>) -> Result<u64> { + let scope = library_id + .map(|id| format!("&parentId={id}")) + .unwrap_or_default(); + let env: ItemsEnvelope<serde_json::Value> = self + .get_json(&format!( + "/Items?userId={}&IncludeItemTypes=Audio&Recursive=true&Limit=0{scope}", + self.user_id + )) + .await?; + Ok(env.total_record_count) + } + + /// One page of essential audio metadata, without `MediaSources`. + /// + /// This is the first useful pass. On the measured server it avoids the + /// per-track media-info probe that made the old first sync take 45.8 s. + /// The returned quality fields are intentionally absent until + /// [`track_quality`] fills them in the secondary pass. + pub async fn essential_tracks_page( + &self, + library_id: Option<&str>, + start_index: u64, + min_date_last_saved: Option<&str>, + ) -> Result<(Vec<JellyfinTrack>, u64)> { + self.tracks_page_with_fields(library_id, start_index, min_date_last_saved, false) + .await + } + + /// Compatibility entry point for callers that explicitly need quality in + /// the listing. The Qt catalog sync uses [`essential_tracks_page`] and the + /// bounded [`track_quality`] path instead. + /// + /// `Fields=MediaSources` is what carries `BitDepth` / `SampleRate`, and it + /// is the expensive part (§ module docs): ~4.5 s per 500-item page against + /// the measured server. There is no cheaper way to get the quality tier, so + /// the caller owns the cost knowingly — it is why [`track_count`] exists. + /// + /// `min_date_last_saved` turns the sweep into a DELTA. Jellyfin honours it + /// (verified: a future date returns 0 items), which Plex has no equivalent + /// for — a re-scan after the first one need not pay the full 45.8 s. + pub async fn tracks_page( + &self, + library_id: Option<&str>, + start_index: u64, + min_date_last_saved: Option<&str>, + ) -> Result<(Vec<JellyfinTrack>, u64)> { + self.tracks_page_with_fields(library_id, start_index, min_date_last_saved, true) + .await + } + + async fn tracks_page_with_fields( + &self, + library_id: Option<&str>, + start_index: u64, + min_date_last_saved: Option<&str>, + include_quality: bool, + ) -> Result<(Vec<JellyfinTrack>, u64)> { + let env: ItemsEnvelope<AudioDto> = self + .get_json(&tracks_page_path( + &self.user_id, + library_id, + start_index, + min_date_last_saved, + include_quality, + )) + .await?; + let total = env.total_record_count; + Ok(( + env.items.into_iter().map(AudioDto::into_track).collect(), + total, + )) + } + + /// Hydrate quality for a bounded set of opaque item ids. + /// + /// The response is joined by id, never by position: an item may disappear + /// between the essential pass and this request. Missing ids are simply not + /// returned so the caller can defer them without corrupting a neighbour. + pub async fn track_quality(&self, item_ids: &[String]) -> Result<Vec<JellyfinTrackQuality>> { + if item_ids.is_empty() { + return Ok(Vec::new()); + } + if item_ids.len() > QUALITY_BATCH_SIZE { + return Err(JellyfinError::Decode(format!( + "quality batch exceeds {QUALITY_BATCH_SIZE} ids" + ))); + } + let ids = item_ids.join(","); + let env: ItemsEnvelope<AudioDto> = self + .get_json(&format!( + "/Items?userId={}&Ids={ids}&IncludeItemTypes=Audio\ + &Limit={QUALITY_BATCH_SIZE}&Fields=MediaSources", + self.user_id + )) + .await?; + Ok(env.items.into_iter().map(AudioDto::into_quality).collect()) + } + + /// [`stream_url`] for this client's server. + pub fn stream_url(&self, item_id: &str) -> String { + stream_url(&self.base, &self.token, item_id) + } + + /// [`image_url`] for this client's server. + pub fn image_url(&self, item_id: &str, tag: Option<&str>, px: u32) -> String { + image_url(&self.base, item_id, tag, px) + } +} + +fn tracks_page_path( + user_id: &str, + library_id: Option<&str>, + start_index: u64, + min_date_last_saved: Option<&str>, + include_quality: bool, +) -> String { + let scope = library_id + .map(|id| format!("&parentId={id}")) + .unwrap_or_default(); + let delta = min_date_last_saved + .map(|date| format!("&minDateLastSaved={date}")) + .unwrap_or_default(); + // ProviderIds on both: a handful of bytes per item, and it is the only + // cross-source identity a media server can hand us. + let fields = if include_quality { + "Path,MediaSources,ParentId,ProductionYear,Genres,ProviderIds" + } else { + // A server-side filesystem path is neither useful nor safe catalog + // metadata on a client. The compatibility path keeps exposing it, but + // the cheap persistent-library pass does not request or store it. + "ParentId,ProductionYear,Genres,ProviderIds" + }; + format!( + "/Items?userId={user_id}&IncludeItemTypes=Audio&Recursive=true\ + &Limit={PAGE_SIZE}&StartIndex={start_index}&Fields={fields}\ + &SortBy=Album,ParentIndexNumber,IndexNumber&SortOrder=Ascending\ + &EnableImages=true&ImageTypeLimit=1{scope}{delta}" + ) +} + +// --------------------------------------------------------------------------- +// URL builders — PURE, and deliberately outside the client +// --------------------------------------------------------------------------- +// +// `qbz-source`'s `JellyfinSource` builds both from stored settings on paths +// where no client exists (the artwork token interpretation must not touch the +// network, by trait contract), and a `reqwest::Client` cannot even be +// CONSTRUCTED without a TLS provider installed — which a unit test does not +// have. Keeping these pure makes both true at once. + +/// The BIT-PERFECT stream url for one item. +/// +/// `static=true` is the whole contract: the server hands back the original file +/// bytes (md5-verified against the file on disk), with a `Content-Length` and +/// `Accept-Ranges: bytes` so QBZ's progressive feeder can Range-stream it. No +/// transcode is requested, and none may be added here — `audioCodec` / +/// `audioBitRate` on this endpoint are exactly how a bit-perfect path becomes a +/// resampled one. +/// +/// The token rides in the query string because the feeder takes a URL, not a +/// request builder. That makes this a SECRET-BEARING string: never log it whole. +pub fn stream_url(base_url: &str, token: &str, item_id: &str) -> String { + format!( + "{}/Audio/{}/stream?static=true&api_key={}", + normalize_base_url(base_url), + item_id, + token + ) +} + +/// The cover url for an album (or any item that has a primary image). +/// +/// **No credentials.** Verified: `/Items/{id}/Images/Primary` answers 200 +/// unauthenticated. That makes the url STABLE — unlike a Plex thumb, whose +/// token is rebuilt every pass — so its cache key and its fetch url can be the +/// SAME string and it can be memoized forever. +/// +/// `tag` is appended when known so a re-uploaded cover busts the cache instead +/// of serving the old one until eviction. +pub fn image_url(base_url: &str, item_id: &str, tag: Option<&str>, px: u32) -> String { + let base = normalize_base_url(base_url); + match tag { + Some(t) => format!("{base}/Items/{item_id}/Images/Primary?maxWidth={px}&tag={t}"), + None => format!("{base}/Items/{item_id}/Images/Primary?maxWidth={px}"), + } +} + +/// Does this response carry the ORIGINAL bytes, or is the server transcoding? +/// +/// The tell measured on 10.11.11: a direct `?static=true` answer has a +/// `Content-Length`; a transcode answers `Transfer-Encoding: chunked` with none. +/// Cheap enough to assert on every stream, and it is the only automatic defence +/// against a server-side policy silently resampling a hi-res track — which is +/// the one failure the audio contract cannot tolerate. +pub fn is_direct_response(content_length: Option<u64>, transfer_encoding: Option<&str>) -> bool { + if let Some(te) = transfer_encoding { + if te.eq_ignore_ascii_case("chunked") { + return false; + } + } + content_length.is_some_and(|n| n > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_urls_are_normalised_the_way_people_type_them() { + assert_eq!( + normalize_base_url("192.168.0.69:8096"), + "http://192.168.0.69:8096" + ); + assert_eq!(normalize_base_url("http://host:8096/"), "http://host:8096"); + assert_eq!( + normalize_base_url(" https://jf.example.com/ "), + "https://jf.example.com" + ); + assert_eq!(normalize_base_url(" "), ""); + } + + #[test] + fn the_auth_header_carries_a_token_only_when_there_is_one() { + let without = auth_header("qbz-abc", None); + assert!(without.contains(r#"DeviceId="qbz-abc""#)); + assert!(!without.contains("Token=")); + assert!(auth_header("qbz-abc", Some("sekrit")).contains(r#"Token="sekrit""#)); + } + + /// A chunked response with no `Content-Length` is the server transcoding. + /// This is the assertion that keeps a bit-perfect path bit-perfect. + #[test] + fn a_chunked_response_is_never_direct() { + assert!(is_direct_response(Some(135_919_471), None)); + assert!(!is_direct_response(None, Some("chunked"))); + assert!(!is_direct_response(None, None)); + assert!(!is_direct_response(Some(0), None)); + // A direct response that also names an identity encoding is fine. + assert!(is_direct_response(Some(1024), Some("identity"))); + } + + /// The cover array of an item carries the EMBEDDED ARTWORK as a stream too + /// (`mjpeg`, `BitDepth: 8`). Taking `MediaStreams[0]` would read the + /// JPEG's depth as the audio's and badge a 24-bit FLAC as 8-bit. + #[test] + fn the_audio_stream_is_picked_by_type_not_by_position() { + let json = serde_json::json!({ + "Id": "abc", + "Name": "Harvester Of Sorrow", + "Album": "...And Justice For All", + "AlbumId": "alb", + "AlbumArtist": "Metallica", + "Artists": ["Metallica"], + "IndexNumber": 6, + "ParentIndexNumber": 1, + "RunTimeTicks": 3484800000i64, + "ProductionYear": 1988, + "Genres": ["Thrash Metal", "Heavy Metal", "thrash metal"], + "Container": "flac", + "AlbumPrimaryImageTag": "748607df", + "ImageTags": { "Primary": "disc-specific" }, + "MediaSources": [{ + "MediaStreams": [ + { "Type": "EmbeddedImage", "Codec": "mjpeg", "BitDepth": 8 }, + { "Type": "Audio", "Codec": "flac", "BitDepth": 24, + "SampleRate": 96000, "Channels": 2, "BitRate": 3120281 } + ] + }] + }); + let t = serde_json::from_value::<AudioDto>(json) + .unwrap() + .into_track(); + assert_eq!( + t.bit_depth, + Some(24), + "read the cover's depth, not the audio's" + ); + assert_eq!(t.sample_rate_hz, Some(96000)); + assert_eq!(t.codec.as_deref(), Some("flac")); + // 3 484 800 000 ticks / 10 000 = 348 480 ms = 348.48 s. + assert_eq!(t.duration_ms, 348_480); + assert_eq!(t.track_number, Some(6)); + assert_eq!(t.disc_number, Some(1)); + assert_eq!(t.genres, ["Thrash Metal", "Heavy Metal"]); + assert_eq!(t.genre.as_deref(), Some("Thrash Metal")); + assert_eq!(t.item_image_tag.as_deref(), Some("disc-specific")); + } + + #[test] + fn essential_page_omits_media_sources_but_preserves_delta_and_scope() { + let essential = tracks_page_path( + "user", + Some("library"), + 250, + Some("2026-08-23T00:00:00Z"), + false, + ); + assert!(essential.contains("Limit=250&StartIndex=250")); + assert!(essential.contains("parentId=library")); + assert!(essential.contains("minDateLastSaved=2026-08-23T00:00:00Z")); + assert!(!essential.contains("MediaSources")); + assert!(!essential.contains("Path")); + + let compatibility = tracks_page_path("user", None, 0, None, true); + assert!(compatibility.contains("MediaSources")); + } + + #[test] + fn secondary_quality_is_keyed_by_item_and_uses_only_the_audio_stream() { + let json = serde_json::json!({ + "Id": "quality-id", + "Container": "flac", + "MediaSources": [{ "MediaStreams": [ + { "Type": "EmbeddedImage", "Codec": "mjpeg", "BitDepth": 8 }, + { "Type": "Audio", "Codec": "flac", "BitDepth": 24, + "SampleRate": 192000, "Channels": 2, "BitRate": 6112000 } + ]}] + }); + let quality = serde_json::from_value::<AudioDto>(json) + .unwrap() + .into_quality(); + assert_eq!(quality.id, "quality-id"); + assert_eq!(quality.container, "flac"); + assert_eq!(quality.codec.as_deref(), Some("flac")); + assert_eq!(quality.bit_depth, Some(24)); + assert_eq!(quality.sample_rate_hz, Some(192_000)); + assert_eq!(quality.bitrate_bps, Some(6_112_000)); + } + + #[test] + fn essential_first_pass_metric_is_bounded_and_smaller_than_quality_payloads() { + const TRACKS: u64 = 4_924; + fn page(start: u64, end: u64, quality: bool) -> String { + let mut items = Vec::new(); + for index in start..end { + let mut item = serde_json::json!({ + "Id": format!("item-{index:05}"), + "Name": format!("Track {index:05}"), + "Album": format!("Album {:04}", index / 10), + "AlbumId": format!("album-{:04}", index / 10), + "AlbumArtist": "Fixture Artist", + "Artists": ["Fixture Artist"], + "IndexNumber": index % 10 + 1, + "ParentIndexNumber": 1, + "RunTimeTicks": 1_800_000_000i64, + "ProductionYear": 2026, + "Genres": ["Fixture"], + "Container": "flac" + }); + if quality { + item["MediaSources"] = serde_json::json!([{ + "MediaStreams": [{ + "Type": "Audio", "Codec": "flac", "BitDepth": 24, + "SampleRate": 96000, "Channels": 2, "BitRate": 3120000 + }] + }]); + } + items.push(item); + } + serde_json::json!({ "Items": items, "TotalRecordCount": TRACKS }).to_string() + } + + let mut essential_pages = Vec::new(); + let mut quality_pages = Vec::new(); + for start in (0..TRACKS).step_by(PAGE_SIZE as usize) { + let end = (start + u64::from(PAGE_SIZE)).min(TRACKS); + essential_pages.push(page(start, end, false)); + quality_pages.push(page(start, end, true)); + } + let essential_bytes = essential_pages.iter().map(String::len).max().unwrap_or(0); + let quality_bytes = quality_pages.iter().map(String::len).max().unwrap_or(0); + let mut parsed = 0usize; + let essential_started = std::time::Instant::now(); + for json in &essential_pages { + let envelope: ItemsEnvelope<AudioDto> = serde_json::from_str(json).unwrap(); + parsed += envelope.items.len(); + } + let essential_elapsed = essential_started.elapsed(); + let quality_started = std::time::Instant::now(); + for json in &quality_pages { + let _: ItemsEnvelope<AudioDto> = serde_json::from_str(json).unwrap(); + } + let quality_elapsed = quality_started.elapsed(); + assert_eq!(parsed, TRACKS as usize); + assert!(essential_bytes < quality_bytes); + println!( + "H_JELLYFIN_METRIC tracks={TRACKS} pages=20 essential_max_bytes={essential_bytes} quality_max_bytes={quality_bytes} essential_parse_ms={} quality_parse_ms={}", + essential_elapsed.as_millis(), + quality_elapsed.as_millis(), + ); + } + + /// MP3 reports NO bit depth. Defaulting it to 16 would badge every lossy + /// track as CD quality, which is the one thing this app must not get wrong. + #[test] + fn a_lossy_track_keeps_an_absent_bit_depth() { + let json = serde_json::json!({ + "Id": "m", "Name": "x", "Container": "mp3", + "MediaSources": [{ "MediaStreams": [ + { "Type": "Audio", "Codec": "mp3", "SampleRate": 44100, "Channels": 2 } + ]}] + }); + let t = serde_json::from_value::<AudioDto>(json) + .unwrap() + .into_track(); + assert_eq!(t.bit_depth, None); + assert_eq!(t.sample_rate_hz, Some(44100)); + } + + /// 17 of 500 measured rows carried no artist at all, and 75 of 4924 no + /// track number. Neither may drop the row. + #[test] + fn a_row_missing_its_tags_still_maps() { + let json = serde_json::json!({ "Id": "bare", "Name": "Untitled" }); + let t = serde_json::from_value::<AudioDto>(json) + .unwrap() + .into_track(); + assert_eq!(t.id, "bare"); + assert_eq!(t.artist, ""); + assert_eq!(t.track_number, None); + assert_eq!(t.duration_ms, 0); + assert_eq!(t.album_image_tag, None); + assert_eq!(t.item_image_tag, None); + } + + /// An artist-less row falls back to the ALBUM artist rather than rendering + /// blank, and vice versa — the two fields disagree often enough on real + /// libraries that picking one is not enough. + #[test] + fn artist_and_album_artist_back_each_other_up() { + let json = serde_json::json!({ + "Id": "a", "Name": "t", "AlbumArtist": "Various Artists", "Artists": [] + }); + let t = serde_json::from_value::<AudioDto>(json) + .unwrap() + .into_track(); + assert_eq!(t.artist, "Various Artists"); + assert_eq!(t.album_artist, "Various Artists"); + } + + #[test] + fn image_urls_carry_no_credentials_and_bust_on_the_tag() { + let with = image_url("http://h:8096", "alb", Some("deadbeef"), IMAGE_PX); + assert!( + !with.contains("tok"), + "a cover url must not carry the token" + ); + assert!(with.contains("tag=deadbeef")); + assert!(with.contains("maxWidth=256")); + assert_eq!( + image_url("http://h:8096", "alb", None, 1024), + "http://h:8096/Items/alb/Images/Primary?maxWidth=1024" + ); + } + + /// `static=true` is the bit-perfect contract. If this assertion ever has to + /// change, the change is a resampled hi-res track. + #[test] + fn the_stream_url_asks_for_the_original_bytes_and_nothing_else() { + let u = stream_url("http://h:8096/", "tok", "item42"); + assert_eq!( + u, + "http://h:8096/Audio/item42/stream?static=true&api_key=tok" + ); + assert!( + !u.contains("audioCodec"), + "a codec parameter IS a transcode" + ); + assert!(!u.contains("audioBitRate")); + } + + #[test] + fn only_music_views_are_returned() { + let json = serde_json::json!({ + "Items": [ + { "Id": "1", "Name": "Music", "CollectionType": "music" }, + { "Id": "2", "Name": "Playlists", "CollectionType": "playlists" }, + { "Id": "3", "Name": "Movies", "CollectionType": "movies" }, + { "Id": "4", "Name": "Odd", "CollectionType": null } + ], + "TotalRecordCount": 4 + }); + let env: ItemsEnvelope<ViewDto> = serde_json::from_value(json).unwrap(); + let music: Vec<_> = env + .items + .into_iter() + .filter(|v| v.collection_type.as_deref() == Some("music")) + .map(|v| v.name) + .collect(); + assert_eq!(music, vec!["Music"]); + } +} diff --git a/crates/qbz-jellyfin/tests/live.rs b/crates/qbz-jellyfin/tests/live.rs new file mode 100644 index 000000000..7203d5e50 --- /dev/null +++ b/crates/qbz-jellyfin/tests/live.rs @@ -0,0 +1,249 @@ +//! LIVE tests against a real Jellyfin server. Skipped unless the environment +//! names one, so CI and a laptop on a train both stay green. +//! +//! QBZ_JELLYFIN_URL=http://192.168.0.69:8096 \ +//! QBZ_JELLYFIN_USER=admin QBZ_JELLYFIN_PASS=... \ +//! cargo test -p qbz-jellyfin --test live -- --nocapture +//! +//! These exist because the unit tests above them cannot fail the way this +//! integration really fails. A pure mapper test proves the JSON shape is +//! understood; it cannot prove the endpoint still exists, that the token still +//! opens it, or that `?static=true` still hands back the original bytes. That +//! last one is the audio contract, and the only honest way to check it is to +//! ask a server. + +use std::env; +use std::sync::Once; + +use tokio::sync::OnceCell; + +/// reqwest is built `...-no-provider`, so a `reqwest::Client` cannot be built +/// until a process-level rustls CryptoProvider exists. In the app that is +/// `qbz_app::install_crypto_provider`; here there is no app. +fn install_crypto_provider() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); +} + +fn server() -> Option<(String, String, String)> { + Some(( + env::var("QBZ_JELLYFIN_URL").ok()?, + env::var("QBZ_JELLYFIN_USER").ok()?, + env::var("QBZ_JELLYFIN_PASS").ok()?, + )) +} + +/// ONE session for the whole test binary. +/// +/// Not an optimisation — a correctness fix, and the reason is a real property +/// of the protocol rather than a test artifact. Jellyfin keys its session on +/// `DeviceId`, and re-authenticating with the SAME one **revokes the previous +/// token**. Measured directly against 10.11.11: +/// +/// ```text +/// same DeviceId: auth -> T1, auth -> T2; T1 = 401, T2 = 200 +/// different DeviceId: auth -> A, auth -> B; A = 200, B = 200 +/// ``` +/// +/// So five tests authenticating in parallel under one DeviceId spend their time +/// invalidating each other, and whichever finishes first is left holding a dead +/// token — which is exactly what the `Unauthorized` on the second call was. The +/// app carries the same obligation: authenticate ONCE, hold the token, and keep +/// the DeviceId stable per install. +static SESSION: OnceCell<qbz_jellyfin::Session> = OnceCell::const_new(); + +async fn session(url: &str, user: &str, pass: &str) -> &'static qbz_jellyfin::Session { + SESSION + .get_or_init(|| async { + qbz_jellyfin::authenticate(url, "qbz-live-test", user, pass) + .await + .expect("authenticate") + }) + .await +} + +/// The authenticated client every live test shares. +async fn client(url: &str, user: &str, pass: &str) -> qbz_jellyfin::JellyfinClient { + let s = session(url, user, pass).await; + qbz_jellyfin::JellyfinClient::new(url, &s.access_token, &s.user_id).unwrap() +} + +macro_rules! skip_without_server { + () => { + match server() { + Some(v) => { + install_crypto_provider(); + v + } + None => { + eprintln!("SKIP: set QBZ_JELLYFIN_URL / _USER / _PASS to run the live tests"); + return; + } + } + }; +} + +#[tokio::test] +async fn probe_reports_a_server_without_credentials() { + let (url, _, _) = skip_without_server!(); + let info = qbz_jellyfin::probe(&url).await.expect("probe"); + eprintln!("server: {} {}", info.server_name, info.version); + assert!(!info.id.is_empty()); + assert!(info.startup_wizard_completed); +} + +#[tokio::test] +async fn a_full_round_trip_yields_tracks_with_real_quality() { + let (url, user, pass) = skip_without_server!(); + let s = session(&url, &user, &pass).await; + assert!(!s.access_token.is_empty()); + + let c = client(&url, &user, &pass).await; + + let libs = c.music_libraries().await.expect("libraries"); + assert!(!libs.is_empty(), "the server exposes no music library"); + let lib = &libs[0]; + eprintln!("library: {} ({})", lib.name, lib.id); + + let total = c.track_count(Some(&lib.id)).await.expect("count"); + assert!(total > 0, "the music library is empty"); + + let (page, reported) = c.tracks_page(Some(&lib.id), 0, None).await.expect("page"); + assert_eq!(reported, total, "the count and the page disagree"); + assert!(!page.is_empty()); + + // The point of `Fields=MediaSources`: SOME row must carry a real bit depth, + // or the quality badge silently degrades to "unknown" for a whole library. + let with_depth = page.iter().filter(|t| t.bit_depth.is_some()).count(); + assert!( + with_depth > 0, + "no row in a {}-track page carried a bit depth — MediaSources is not being requested", + page.len() + ); + // ...and a lossy row must NOT invent one. + for t in page.iter().filter(|t| t.container == "mp3") { + assert_eq!(t.bit_depth, None, "an mp3 reported a bit depth: {}", t.title); + } + // Every row must be identifiable and groupable, or the cache cannot key it. + for t in &page { + assert!(!t.id.is_empty(), "a row arrived with no id"); + assert!(!t.album_id.is_empty(), "row {} has no album id", t.title); + } + eprintln!( + "page: {} rows, {} with a bit depth, {} total in library", + page.len(), + with_depth, + total + ); +} + +/// THE audio contract. `?static=true` must answer with the ORIGINAL bytes: +/// a `Content-Length`, `Accept-Ranges`, and a 206 to a Range request. A +/// chunked answer means the server decided to transcode, which for a hi-res +/// track is exactly the failure this app cannot ship. +#[tokio::test] +async fn the_stream_endpoint_is_direct_and_seekable() { + let (url, user, pass) = skip_without_server!(); + let c = client(&url, &user, &pass).await; + let libs = c.music_libraries().await.expect("libraries"); + let (page, _) = c + .tracks_page(Some(&libs[0].id), 0, None) + .await + .expect("page"); + // A LOSSLESS row, so the assertion is about the case that matters. + let track = page + .iter() + .find(|t| t.container == "flac") + .or_else(|| page.first()) + .expect("a track"); + + let http = reqwest::Client::new(); + let resp = http + .get(c.stream_url(&track.id)) + .header("Range", "bytes=0-65535") + .send() + .await + .expect("range request"); + + assert_eq!(resp.status().as_u16(), 206, "the server refused a Range request"); + let te = resp + .headers() + .get(reqwest::header::TRANSFER_ENCODING) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + assert!( + te.as_deref() != Some("chunked"), + "chunked response — the server is TRANSCODING, not direct-playing" + ); + let ctype = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let bytes = resp.bytes().await.expect("body"); + assert_eq!(bytes.len(), 65536, "short read on a Range request"); + if track.container == "flac" { + assert_eq!(&bytes[..4], b"fLaC", "the body is not a FLAC bitstream"); + } + eprintln!( + "stream: {} {} -> 206, {} bytes, {ctype}", + track.container, + track.title, + bytes.len() + ); +} + +/// Cover urls carry no credentials, and the server really serves them that way. +#[tokio::test] +async fn cover_art_needs_no_token() { + let (url, user, pass) = skip_without_server!(); + let c = client(&url, &user, &pass).await; + let libs = c.music_libraries().await.expect("libraries"); + let (page, _) = c + .tracks_page(Some(&libs[0].id), 0, None) + .await + .expect("page"); + let track = page + .iter() + .find(|t| t.album_image_tag.is_some()) + .expect("no row in the first page has an album cover"); + + let art = qbz_jellyfin::image_url( + c.base_url(), + &track.album_id, + track.album_image_tag.as_deref(), + qbz_jellyfin::IMAGE_PX, + ); + let token = &session(&url, &user, &pass).await.access_token; + assert!(!art.contains(token), "the cover url leaked the token"); + + let resp = reqwest::Client::new().get(&art).send().await.expect("cover"); + assert!(resp.status().is_success(), "cover fetch failed: {}", resp.status()); + let ctype = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + assert!(ctype.starts_with("image/"), "cover url returned {ctype}"); + let bytes = resp.bytes().await.expect("cover body"); + assert!(bytes.len() > 1024, "cover is {} bytes", bytes.len()); + eprintln!("cover: {ctype}, {} bytes", bytes.len()); +} + +/// The delta sweep really filters. Without this, an incremental re-scan would +/// silently be a full one. +#[tokio::test] +async fn a_future_delta_returns_nothing() { + let (url, user, pass) = skip_without_server!(); + let c = client(&url, &user, &pass).await; + let libs = c.music_libraries().await.expect("libraries"); + let (page, _) = c + .tracks_page(Some(&libs[0].id), 0, Some("2999-01-01T00:00:00Z")) + .await + .expect("delta page"); + assert!(page.is_empty(), "a year-2999 delta returned {} rows", page.len()); +} diff --git a/crates/qbz-library/Cargo.toml b/crates/qbz-library/Cargo.toml index 7704c2761..23884c328 100644 --- a/crates/qbz-library/Cargo.toml +++ b/crates/qbz-library/Cargo.toml @@ -17,15 +17,19 @@ serde_json = "1" thiserror = "2" # Database -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } # Filesystem walkdir = "2" +notify = "8" dirs = "6" # Audio metadata -lofty = "0.23" +lofty.workspace = true qbz-dsd = { path = "../qbz-dsd" } +# SACD images found by the folder scan: signature sniff + Scarlet Book TOC +# parse + the disc memory (sacd_scan.rs). qbz-disc depends on nothing here. +qbz-disc = { path = "../qbz-disc" } # Image processing (thumbnails) image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } diff --git a/crates/qbz-library/src/cue_parser.rs b/crates/qbz-library/src/cue_parser.rs index ae16c01f1..d246e1250 100644 --- a/crates/qbz-library/src/cue_parser.rs +++ b/crates/qbz-library/src/cue_parser.rs @@ -12,6 +12,11 @@ pub struct CueSheet { pub file_path: String, /// Referenced audio file (resolved to absolute path) pub audio_file: String, + /// Distinct audio files named by FILE directives. QBZ only expands a + /// sheet into virtual tracks when this contains exactly one entry; a + /// multi-file CUE describes files that are already split and is treated + /// as a sidecar instead of a second copy of the album. + audio_files: Vec<String>, /// Album title pub title: Option<String>, /// Album performer/artist @@ -20,6 +25,22 @@ pub struct CueSheet { pub tracks: Vec<CueTrack>, } +impl CueSheet { + /// Whether every track in this sheet addresses one shared audio image. + /// + /// `cue_to_tracks` models virtual ranges inside a single decoder source. + /// A CUE with several distinct FILE directives is a metadata sidecar for + /// already-split files and must not enter that path. + pub fn is_single_file_image(&self) -> bool { + self.audio_files.len() == 1 + } + + /// Number of distinct audio files referenced by the sheet. + pub fn audio_file_count(&self) -> usize { + self.audio_files.len() + } +} + /// A track within a CUE sheet #[derive(Debug, Clone)] pub struct CueTrack { @@ -67,7 +88,7 @@ pub struct CueParser; impl CueParser { /// Parse a CUE file pub fn parse(cue_path: &Path) -> Result<CueSheet, LibraryError> { - log::debug!("Parsing CUE file: {}", cue_path.display()); + log::debug!("Parsing CUE sheet"); // Try UTF-8 first, then fall back to Latin-1 let content = fs::read_to_string(cue_path).or_else(|_| { @@ -83,6 +104,7 @@ impl CueParser { let mut sheet = CueSheet { file_path: cue_path.to_string_lossy().to_string(), audio_file: String::new(), + audio_files: Vec::new(), title: None, performer: None, tracks: Vec::new(), @@ -103,11 +125,16 @@ impl CueParser { if line.to_uppercase().starts_with("FILE ") { if let Some(filename) = Self::extract_quoted(line) { // Resolve path relative to CUE file - if let Some(parent) = cue_path.parent() { - let audio_path = parent.join(&filename); - sheet.audio_file = audio_path.to_string_lossy().to_string(); + let resolved = if let Some(parent) = cue_path.parent() { + parent.join(&filename).to_string_lossy().into_owned() } else { - sheet.audio_file = filename; + filename + }; + if sheet.audio_file.is_empty() { + sheet.audio_file.clone_from(&resolved); + } + if !sheet.audio_files.contains(&resolved) { + sheet.audio_files.push(resolved); } } } @@ -181,9 +208,9 @@ impl CueParser { } log::info!( - "Parsed CUE: {} tracks, audio file: {}", + "Parsed CUE: {} tracks, {} audio file(s)", sheet.tracks.len(), - sheet.audio_file + sheet.audio_file_count() ); Ok(sheet) @@ -216,6 +243,13 @@ pub fn cue_to_tracks( format: AudioFormat, properties: &AudioProperties, ) -> Vec<LocalTrack> { + // This converter expresses every row as a time range in one decoder + // source. Feeding it a multi-file CUE used to bind all rows to the last + // FILE directive and publish zero-duration duplicates of the split files. + if !cue.is_single_file_image() || audio_duration_secs == 0 { + return Vec::new(); + } + let mut tracks = Vec::new(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -229,13 +263,27 @@ pub fn cue_to_tracks( for (i, cue_track) in cue.tracks.iter().enumerate() { // Calculate end time (next track's start or audio end) - let end_secs = if i + 1 < cue.tracks.len() { + let requested_end = if i + 1 < cue.tracks.len() { cue.tracks[i + 1].start_secs } else { audio_duration_secs as f64 }; + let end_secs = requested_end.min(audio_duration_secs as f64); - let duration = (end_secs - cue_track.start_secs).max(0.0) as u64; + let range_secs = end_secs - cue_track.start_secs; + if !range_secs.is_finite() || range_secs <= 0.0 { + log::warn!( + "Ignoring unplayable CUE track {}: start={} end={} file={}", + cue_track.number, + cue_track.start_secs, + end_secs, + cue.audio_file + ); + continue; + } + // A valid sub-second range is still playable; keep its visible + // duration non-zero while cue_start/end retain frame precision. + let duration = range_secs.ceil().max(1.0) as u64; tracks.push(LocalTrack { id: 0, @@ -257,6 +305,7 @@ pub fn cue_to_tracks( disc_number: inferred_disc, year: None, genre: None, + genres: Vec::new(), catalog_number: None, duration_secs: duration, format: format.clone(), @@ -268,10 +317,17 @@ pub fn cue_to_tracks( cue_start_secs: Some(cue_track.start_secs), cue_end_secs: Some(end_secs), artwork_path: None, + collection_artwork_path: None, last_modified: 0, indexed_at: now, source: None, qobuz_track_id: None, + isrc: None, + musicbrainz_recording_id: None, + musicbrainz_track_id: None, + musicbrainz_release_id: None, + musicbrainz_release_group_id: None, + musicbrainz_artist_id: None, is_network_mount: false, }); } @@ -311,4 +367,48 @@ mod tests { assert_eq!(CueParser::extract_track_number("TRACK 01 AUDIO"), Some(1)); assert_eq!(CueParser::extract_track_number("TRACK 12 AUDIO"), Some(12)); } + + #[test] + fn multi_file_cue_is_a_split_file_sidecar_not_an_image() { + let cue = CueParser::parse_content( + "PERFORMER \"Opeth\"\n\ + TITLE \"Sorceress\"\n\ + FILE \"01. Persephone.flac\" WAVE\n\ + TRACK 01 AUDIO\n\ + TITLE \"Persephone\"\n\ + INDEX 01 00:00:00\n\ + FILE \"02. Sorceress.flac\" WAVE\n\ + TRACK 02 AUDIO\n\ + TITLE \"Sorceress\"\n\ + INDEX 01 00:00:00\n", + Path::new("/music/Sorceress/disc.cue"), + ) + .unwrap(); + + assert_eq!(cue.audio_file_count(), 2); + assert!(!cue.is_single_file_image()); + assert!( + cue_to_tracks(&cue, 240, AudioFormat::Flac, &AudioProperties::default()).is_empty() + ); + } + + #[test] + fn invalid_or_out_of_bounds_ranges_are_not_published() { + let cue = CueParser::parse_content( + "FILE \"album.flac\" WAVE\n\ + TRACK 01 AUDIO\n\ + TITLE \"Playable\"\n\ + INDEX 01 00:00:00\n\ + TRACK 02 AUDIO\n\ + TITLE \"Past EOF\"\n\ + INDEX 01 10:00:00\n", + Path::new("/music/album.cue"), + ) + .unwrap(); + + let tracks = cue_to_tracks(&cue, 300, AudioFormat::Flac, &AudioProperties::default()); + assert_eq!(tracks.len(), 1); + assert_eq!(tracks[0].title, "Playable"); + assert_eq!(tracks[0].duration_secs, 300); + } } diff --git a/crates/qbz-library/src/database.rs b/crates/qbz-library/src/database.rs index e8d80e76c..346e8d641 100644 --- a/crates/qbz-library/src/database.rs +++ b/crates/qbz-library/src/database.rs @@ -1,9 +1,15 @@ //! SQLite database layer for library persistence use rusqlite::{params, Connection, OptionalExtension}; -use std::path::Path; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; +use std::time::Duration; -use crate::{AudioFormat, FolderTreeEntry, LibraryError, LocalAlbum, LocalArtist, LocalTrack}; +use crate::{ + reachability::{probe_default, Reach}, + AudioFormat, FolderTreeEntry, LibraryError, LocalAlbum, LocalArtist, LocalTrack, +}; #[derive(Debug, Clone)] pub struct AlbumTrackUpdate { @@ -33,13 +39,53 @@ pub struct LibraryDatabase { conn: Connection, } +/// Qt creates several library-backed singletons and starts the folder scanner +/// during the same startup turn. Each owns a separate SQLite connection, but +/// schema discovery plus `ALTER TABLE` is a check-then-act sequence and must +/// not run concurrently inside this process. +static SCHEMA_INIT_LOCK: Mutex<()> = Mutex::new(()); + +/// Scope reconciliation walks every indexed SACD against every registered +/// root. One pass per database and process is enough: runtime folder changes +/// update ownership directly, while the next process start repairs legacy or +/// externally modified state again. +static SACD_SCOPE_RECONCILED: LazyLock<Mutex<HashSet<PathBuf>>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + +/// A SQL predicate restricting the shared remote mirror to the sources the user +/// has enabled. +/// +/// The words are VALIDATED, not escaped: only `[a-z]` survives, and anything +/// else drops the entry. They arrive from `SourceId::as_str`, so they are +/// already a closed set — but this string is interpolated into SQL, and a +/// validator that cannot be bypassed beats an escape that can be forgotten. +/// An all-invalid list yields `0`, which shows nothing rather than everything. +fn remote_source_filter(sources: &[&str]) -> String { + let clean: Vec<String> = sources + .iter() + .filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase())) + .map(|w| format!("'{w}'")) + .collect(); + if clean.is_empty() { + "0".to_string() + } else { + format!("source IN ({})", clean.join(", ")) + } +} + impl LibraryDatabase { /// Open or create database at path pub fn open(db_path: &Path) -> Result<Self, LibraryError> { - log::info!("Opening library database at: {}", db_path.display()); + log::info!("Opening library database"); let conn = Connection::open(db_path) .map_err(|e| LibraryError::Database(format!("Failed to open database: {}", e)))?; + conn.busy_timeout(Duration::from_millis(2_500)) + .map_err(|e| LibraryError::Database(format!("Failed to set busy timeout: {}", e)))?; + + let schema_guard = SCHEMA_INIT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); // Enable WAL mode for better concurrent access conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") @@ -48,14 +94,28 @@ impl LibraryDatabase { let db = Self { conn }; db.init_schema()?; db.run_migrations()?; + let needs_sacd_scope_reconcile = SACD_SCOPE_RECONCILED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(db_path) + .is_none(); + if needs_sacd_scope_reconcile { + db.reconcile_sacd_catalog_scope()?; + SACD_SCOPE_RECONCILED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(db_path.to_path_buf()); + } // First-class LOCAL playlists (offline-mode D7) — separate module, // same database file. Idempotent CREATE IF NOT EXISTS. crate::local_playlists::init_schema(&db.conn) .map_err(|e| LibraryError::Database(format!("local_playlists schema: {}", e)))?; // Qobuz playlist snapshot (offline-mode B7/B8) — names + membership // captured opportunistically while online. Idempotent. - crate::qobuz_playlist_snapshot::init_schema(&db.conn) - .map_err(|e| LibraryError::Database(format!("qobuz_playlist_snapshot schema: {}", e)))?; + crate::qobuz_playlist_snapshot::init_schema(&db.conn).map_err(|e| { + LibraryError::Database(format!("qobuz_playlist_snapshot schema: {}", e)) + })?; + drop(schema_guard); Ok(db) } @@ -82,6 +142,7 @@ impl LibraryDatabase { disc_number INTEGER, year INTEGER, genre TEXT, + genres_json TEXT NOT NULL DEFAULT '[]', duration_secs INTEGER NOT NULL, format TEXT NOT NULL, bit_depth INTEGER, @@ -108,6 +169,84 @@ impl LibraryDatabase { CREATE INDEX IF NOT EXISTS idx_local_tracks_album_lookup ON local_tracks(album, album_artist, artist); + -- A SACD image is one physical file but exposes several virtual + -- `sacd:/path/image.iso#N` local tracks. Keep that relationship + -- outside `local_tracks`: the latter remains the authoritative + -- playback row, while the disc fingerprint lets a successful + -- re-import update those rows in place after the image moves. + CREATE TABLE IF NOT EXISTS local_sacd_images ( + fingerprint TEXT PRIMARY KEY, + image_path TEXT NOT NULL UNIQUE, + image_size_bytes INTEGER NOT NULL, + image_modified_ns INTEGER NOT NULL, + observed_at INTEGER NOT NULL, + parser_revision INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS local_sacd_tracks ( + fingerprint TEXT NOT NULL, + track_number INTEGER NOT NULL CHECK(track_number BETWEEN 1 AND 255), + local_track_id INTEGER NOT NULL UNIQUE, + PRIMARY KEY(fingerprint, track_number) + ); + + CREATE INDEX IF NOT EXISTS idx_local_sacd_tracks_local_id + ON local_sacd_tracks(local_track_id); + + -- A registered Local Library root owns an indexed SACD. Keeping + -- this separate from the image allows overlapping roots without + -- making a manually opened image persistent. + CREATE TABLE IF NOT EXISTS local_sacd_roots ( + root_id INTEGER NOT NULL, + fingerprint TEXT NOT NULL, + observed_generation INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(root_id, fingerprint) + ); + + CREATE INDEX IF NOT EXISTS idx_local_sacd_roots_fingerprint + ON local_sacd_roots(fingerprint); + + -- Incremental scanner state. `local_tracks` remains authoritative; + -- these tables only remember what each root observed and whether + -- a completed generation is allowed to prune stale rows. + CREATE TABLE IF NOT EXISTS local_scan_roots ( + root_id INTEGER PRIMARY KEY, + generation INTEGER NOT NULL DEFAULT 0, + phase TEXT NOT NULL DEFAULT 'idle', + checkpoint_path TEXT NOT NULL DEFAULT '', + discovered INTEGER NOT NULL DEFAULT 0, + processed INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'idle', + prune_authorized INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS local_scan_files ( + root_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + file_kind TEXT NOT NULL CHECK (file_kind IN ('audio','cue')), + file_id TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + mtime_ns INTEGER NOT NULL, + dependency_fingerprint TEXT NOT NULL DEFAULT '', + cue_audio_path TEXT, + extraction_ok INTEGER NOT NULL DEFAULT 1, + observed_generation INTEGER NOT NULL, + PRIMARY KEY (root_id, file_path, file_kind) + ); + + CREATE INDEX IF NOT EXISTS idx_local_scan_files_generation + ON local_scan_files(root_id, observed_generation, file_kind, file_path); + + -- CUE references are generation-scoped and disk-backed so a pass + -- never needs a HashSet containing every referenced audio path. + CREATE TABLE IF NOT EXISTS local_scan_cue_refs ( + root_id INTEGER NOT NULL, + generation INTEGER NOT NULL, + audio_path TEXT NOT NULL, + PRIMARY KEY (root_id, generation, audio_path) + ); + -- Playlist folders (local organization for Qobuz playlists) CREATE TABLE IF NOT EXISTS playlist_folders ( id TEXT PRIMARY KEY, @@ -174,6 +313,11 @@ impl LibraryDatabase { CREATE INDEX IF NOT EXISTS idx_playlist_local_tracks_playlist ON playlist_local_tracks(qobuz_playlist_id); + -- The Add-to-Playlist picker asks the inverse question — "which + -- playlists hold this track" (playlist_membership.rs). + CREATE INDEX IF NOT EXISTS idx_playlist_local_tracks_track + ON playlist_local_tracks(local_track_id, qobuz_playlist_id); + -- Plex tracks added to playlists. Kept in its own table because -- Plex tracks live on a remote server and have a TEXT rating key, -- not the i64 filesystem id used by local_tracks. No foreign key @@ -192,6 +336,30 @@ impl LibraryDatabase { CREATE INDEX IF NOT EXISTS idx_playlist_plex_tracks_playlist ON playlist_plex_tracks(qobuz_playlist_id); + CREATE INDEX IF NOT EXISTS idx_playlist_plex_tracks_key + ON playlist_plex_tracks(plex_rating_key, qobuz_playlist_id); + + -- Jellyfin/Subsonic tracks added to playlists (2026-08-30). Their + -- library rows are media-cache projections with no local_tracks + -- rowid, so they get their own sidecar keyed by the server item + -- id — the plex_rating_key pattern, source-qualified because two + -- protocols share the table. + CREATE TABLE IF NOT EXISTS playlist_remote_tracks ( + id INTEGER PRIMARY KEY, + qobuz_playlist_id INTEGER NOT NULL, + source TEXT NOT NULL, + item_id TEXT NOT NULL, + position INTEGER NOT NULL, + added_at INTEGER NOT NULL, + UNIQUE(qobuz_playlist_id, source, item_id) + ); + + CREATE INDEX IF NOT EXISTS idx_playlist_remote_tracks_playlist + ON playlist_remote_tracks(qobuz_playlist_id); + + CREATE INDEX IF NOT EXISTS idx_playlist_remote_tracks_item + ON playlist_remote_tracks(source, item_id, qobuz_playlist_id); + -- Custom track order per playlist (user-defined arrangement) CREATE TABLE IF NOT EXISTS playlist_track_custom_order ( id INTEGER PRIMARY KEY, @@ -257,6 +425,8 @@ impl LibraryDatabase { ) .map_err(|e| LibraryError::Database(format!("Failed to create schema: {}", e)))?; + crate::purchase_copies::init_schema(&self.conn)?; + Ok(()) } @@ -287,6 +457,42 @@ impl LibraryDatabase { /// Run schema migrations for existing databases fn run_migrations(&self) -> Result<(), LibraryError> { + let has_sacd_parser_revision: bool = self + .conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('local_sacd_images') WHERE name = 'parser_revision'", + [], + |row| row.get::<_, i32>(0), + ) + .map(|count| count > 0) + .unwrap_or(false); + if !has_sacd_parser_revision { + log::info!("Running migration: adding parser_revision to local_sacd_images"); + if let Err(error) = self.conn.execute_batch( + "ALTER TABLE local_sacd_images ADD COLUMN parser_revision INTEGER NOT NULL DEFAULT 0;", + ) { + // The in-process lock handles QBZ's startup fan-out. This + // second check also covers two QBZ processes migrating the + // same profile: accept the losing ALTER only if the intended + // schema is now observable, never by matching error text. + let installed_by_peer = self + .conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('local_sacd_images') WHERE name = 'parser_revision'", + [], + |row| row.get::<_, i32>(0), + ) + .map(|count| count > 0) + .unwrap_or(false); + if !installed_by_peer { + return Err(LibraryError::Database(format!( + "SACD parser revision migration failed: {error}" + ))); + } + log::debug!("SACD parser revision migration completed by another process"); + } + } + // Migration: Add qobuz download tracking fields let has_source: bool = self .conn @@ -551,6 +757,10 @@ impl LibraryDatabase { .map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?; } + // Migration: cross-source identity tags (ISRC + MusicBrainz ids). + // Additive; NULL until the next scan re-reads the file's tags. + self.ensure_identity_columns()?; + // Migration: Change sample_rate from INTEGER to REAL for decimal precision (44.1kHz, 88.2kHz, etc.) // Check if sample_rate is currently INTEGER let sample_rate_type: String = self @@ -701,6 +911,8 @@ impl LibraryDatabase { LibraryError::Database(format!("Failed to re-add catalog_number: {}", e)) })?; } + // Same for the identity columns (they post-date the rebuild too). + self.ensure_identity_columns()?; log::info!("Migration completed: sample_rate is now REAL"); } @@ -726,6 +938,26 @@ impl LibraryDatabase { .map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?; } + // Multi-genre metadata is additive: old rows keep their singular + // `genre`, and readers use it whenever this JSON array is empty. + let has_genres_json: bool = self + .conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('local_tracks') WHERE name = 'genres_json'", + [], + |row| row.get::<_, i32>(0), + ) + .map(|count| count > 0) + .unwrap_or(false); + if !has_genres_json { + log::info!("Running migration: adding genres_json to local_tracks"); + self.conn + .execute_batch( + "ALTER TABLE local_tracks ADD COLUMN genres_json TEXT NOT NULL DEFAULT '[]';", + ) + .map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?; + } + // Migration: Add canonical_name column to artist_images for artist name normalization let has_canonical_name: bool = self.conn .query_row( @@ -826,6 +1058,8 @@ impl LibraryDatabase { })?; } + crate::purchase_copies::migrate_legacy_registry(&self.conn)?; + Ok(()) } @@ -895,6 +1129,40 @@ impl LibraryDatabase { /// Remove a folder from the library pub fn remove_folder(&self, path: &str) -> Result<(), LibraryError> { + let root_id = self + .conn + .query_row( + "SELECT id FROM library_folders WHERE path = ?", + params![path], + |row| row.get::<_, i64>(0), + ) + .optional() + .map_err(|e| LibraryError::Database(e.to_string()))?; + if let Some(root_id) = root_id { + // A newly registered overlapping root may not have scanned yet. + // Materialize every path-based owner before dropping this one so + // removing the outer folder cannot orphan a still-owned image. + self.reconcile_sacd_catalog_scope()?; + self.remove_sacd_root(root_id)?; + self.conn + .execute( + "DELETE FROM local_scan_files WHERE root_id = ?", + params![root_id], + ) + .map_err(|e| LibraryError::Database(e.to_string()))?; + self.conn + .execute( + "DELETE FROM local_scan_cue_refs WHERE root_id = ?", + params![root_id], + ) + .map_err(|e| LibraryError::Database(e.to_string()))?; + self.conn + .execute( + "DELETE FROM local_scan_roots WHERE root_id = ?", + params![root_id], + ) + .map_err(|e| LibraryError::Database(e.to_string()))?; + } self.conn .execute("DELETE FROM library_folders WHERE path = ?", params![path]) .map_err(|e| LibraryError::Database(e.to_string()))?; @@ -1065,6 +1333,25 @@ impl LibraryDatabase { params![new_path, id], ) .map_err(|e| LibraryError::Database(e.to_string()))?; + self.conn + .execute( + "DELETE FROM local_scan_files WHERE root_id = ?", + params![id], + ) + .map_err(|e| LibraryError::Database(e.to_string()))?; + self.conn + .execute( + "DELETE FROM local_scan_cue_refs WHERE root_id = ?", + params![id], + ) + .map_err(|e| LibraryError::Database(e.to_string()))?; + self.conn + .execute( + "DELETE FROM local_scan_roots WHERE root_id = ?", + params![id], + ) + .map_err(|e| LibraryError::Database(e.to_string()))?; + self.reconcile_sacd_catalog_scope()?; Ok(()) } @@ -1085,6 +1372,25 @@ impl LibraryDatabase { /// Insert or update a track (skips if file is already a Qobuz cached track) pub fn insert_track(&self, track: &LocalTrack) -> Result<i64, LibraryError> { + let is_network_mount = crate::mount_info::is_network_path(Path::new(&track.file_path)); + self.insert_track_with_mount_hint(track, is_network_mount) + } + + /// Scanner-only insert path. Mount classification is a property of the + /// root and is computed once before enumeration, not once per track. + pub(crate) fn insert_scanned_track( + &self, + track: &LocalTrack, + is_network_mount: bool, + ) -> Result<i64, LibraryError> { + self.insert_track_with_mount_hint(track, is_network_mount) + } + + fn insert_track_with_mount_hint( + &self, + track: &LocalTrack, + is_network_mount: bool, + ) -> Result<i64, LibraryError> { // Don't overwrite Qobuz cached tracks with scanned data if self.is_qobuz_cached_track_by_path(&track.file_path)? { log::debug!( @@ -1118,25 +1424,110 @@ impl LibraryDatabase { } else { "user" }; + let genres_json = Self::track_genres_json(track); - // Detect whether the audio file sits on a network-backed - // filesystem. Done per-insert instead of per-scan-start because - // mount topology can change between folder scans; the cost is - // negligible (one /proc/mounts read, cached by the kernel - // page cache). - let is_network_mount = crate::mount_info::is_network_path( - std::path::Path::new(&track.file_path), - ); + // Re-indexing an already-known file must KEEP ITS ROWID. + // + // `INSERT OR REPLACE` resolves a conflict by DELETING the old row and + // inserting a new one, which hands out a fresh rowid. `local_tracks.id` + // is a foreign key elsewhere — `playlist_local_tracks.local_track_id` + // (a local file added to a Qobuz playlist) — and SQLite's foreign keys + // are OFF here (the only pragmas set are journal_mode and synchronous), + // so the cascade never fires and the playlist row survives pointing at + // an id that no longer exists. The INNER JOIN that reads it then + // returns nothing: the track silently vanishes from the playlist. The + // scan re-inserts EVERY file (there is no mtime skip), so this happened + // on every rescan. Verified against a scratch database. + // + // First-class LOCAL playlists are unaffected — `local_playlist_tracks` + // stores `local_path`, not an id. + // + // Two identities, because there are two unique constraints: the table's + // `UNIQUE(file_path, cue_start_secs)`, and the partial index + // `idx_tracks_file_nocue ON local_tracks(file_path) WHERE cue_file_path + // IS NULL` (NULL `cue_start_secs` values do not collide under the + // former, so the latter is what actually catches an ordinary track). + let existing_id: Option<i64> = if track.cue_file_path.is_none() { + self.conn + .query_row( + "SELECT id FROM local_tracks WHERE file_path = ?1 AND cue_file_path IS NULL", + params![track.file_path], + |row| row.get(0), + ) + .optional() + .map_err(|e| LibraryError::Database(e.to_string()))? + } else { + self.conn + .query_row( + "SELECT id FROM local_tracks + WHERE file_path = ?1 + AND cue_start_secs IS ?2", + params![track.file_path, track.cue_start_secs], + |row| row.get(0), + ) + .optional() + .map_err(|e| LibraryError::Database(e.to_string()))? + }; + + if let Some(id) = existing_id { + self.conn + .execute( + r#"UPDATE local_tracks SET + title = ?1, artist = ?2, album = ?3, album_artist = ?4, + track_number = ?5, disc_number = ?6, year = ?7, genre = ?8, + genres_json = ?9, catalog_number = ?10, duration_secs = ?11, format = ?12, + bit_depth = ?13, sample_rate = ?14, channels = ?15, + file_size_bytes = ?16, cue_file_path = ?17, cue_start_secs = ?18, + cue_end_secs = ?19, artwork_path = ?20, last_modified = ?21, + indexed_at = ?22, album_group_key = ?23, album_group_title = ?24, + source = ?25, is_network_mount = ?26 + WHERE id = ?27"#, + params![ + track.title, + track.artist, + track.album, + track.album_artist, + track.track_number, + track.disc_number, + track.year, + track.genre, + genres_json, + track.catalog_number, + track.duration_secs as i64, + track.format.to_string(), + track.bit_depth, + track.sample_rate, + track.channels, + track.file_size_bytes as i64, + track.cue_file_path, + track.cue_start_secs, + track.cue_end_secs, + track.artwork_path, + track.last_modified, + track.indexed_at, + track.album_group_key, + track.album_group_title, + source, + is_network_mount as i64, + id, + ], + ) + .map_err(|e| LibraryError::Database(e.to_string()))?; + return Ok(id); + } self.conn .execute( r#"INSERT OR REPLACE INTO local_tracks (file_path, title, artist, album, album_artist, track_number, - disc_number, year, genre, catalog_number, duration_secs, format, bit_depth, + disc_number, year, genre, genres_json, catalog_number, duration_secs, format, bit_depth, sample_rate, channels, file_size_bytes, cue_file_path, cue_start_secs, cue_end_secs, artwork_path, last_modified, indexed_at, - album_group_key, album_group_title, source, is_network_mount) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + album_group_key, album_group_title, source, is_network_mount, + isrc, musicbrainz_recording_id, musicbrainz_track_id, + musicbrainz_release_id, musicbrainz_release_group_id, musicbrainz_artist_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?)"#, params![ track.file_path, track.title, @@ -1147,13 +1538,14 @@ impl LibraryDatabase { track.disc_number, track.year, track.genre, + genres_json, track.catalog_number, - track.duration_secs, + track.duration_secs as i64, track.format.to_string(), track.bit_depth, track.sample_rate, track.channels, - track.file_size_bytes, + track.file_size_bytes as i64, track.cue_file_path, track.cue_start_secs, track.cue_end_secs, @@ -1164,6 +1556,12 @@ impl LibraryDatabase { track.album_group_title, source, is_network_mount as i64, + track.isrc, + track.musicbrainz_recording_id, + track.musicbrainz_track_id, + track.musicbrainz_release_id, + track.musicbrainz_release_group_id, + track.musicbrainz_artist_id, ], ) .map_err(|e| LibraryError::Database(e.to_string()))?; @@ -1171,6 +1569,40 @@ impl LibraryDatabase { Ok(self.conn.last_insert_rowid()) } + /// Add the ISRC + MusicBrainz id columns when absent (idempotent, cheap: + /// one `pragma_table_info` probe). Called from the migration chain AND + /// after the sample_rate table rebuild, which recreates `local_tracks` + /// without any column added by a later ALTER. + fn ensure_identity_columns(&self) -> Result<(), LibraryError> { + let has_isrc: bool = self + .conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('local_tracks') WHERE name = 'isrc'", + [], + |row| row.get::<_, i64>(0), + ) + .map(|count| count > 0) + .unwrap_or(false); + if !has_isrc { + log::info!( + "Running migration: adding identity columns (isrc, musicbrainz_*) to local_tracks" + ); + self.conn + .execute_batch( + "ALTER TABLE local_tracks ADD COLUMN isrc TEXT; + ALTER TABLE local_tracks ADD COLUMN musicbrainz_recording_id TEXT; + ALTER TABLE local_tracks ADD COLUMN musicbrainz_track_id TEXT; + ALTER TABLE local_tracks ADD COLUMN musicbrainz_release_id TEXT; + ALTER TABLE local_tracks ADD COLUMN musicbrainz_release_group_id TEXT; + ALTER TABLE local_tracks ADD COLUMN musicbrainz_artist_id TEXT; + CREATE INDEX IF NOT EXISTS idx_tracks_isrc ON local_tracks(isrc); + CREATE INDEX IF NOT EXISTS idx_tracks_mb_recording ON local_tracks(musicbrainz_recording_id);", + ) + .map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?; + } + Ok(()) + } + /// Get a track by ID pub fn get_track(&self, id: i64) -> Result<Option<LocalTrack>, LibraryError> { let mut stmt = self @@ -1265,9 +1697,17 @@ impl LibraryDatabase { /// Clear all LOCAL library tracks (preserves Qobuz downloads) pub fn clear_all_tracks(&self) -> Result<(), LibraryError> { self.conn - .execute( - "DELETE FROM local_tracks WHERE source IS NULL OR source != 'qobuz_download'", - [], + .execute_batch( + "BEGIN IMMEDIATE; + DELETE FROM local_tracks WHERE source IS NULL OR source != 'qobuz_download'; + DELETE FROM local_sacd_tracks; + DELETE FROM local_sacd_roots; + DELETE FROM local_sacd_images; + DELETE FROM local_scan_files; + DELETE FROM local_scan_cue_refs; + DELETE FROM local_scan_roots; + UPDATE library_folders SET last_scan=NULL; + COMMIT;", ) .map_err(|e| LibraryError::Database(e.to_string()))?; Ok(()) @@ -1376,6 +1816,7 @@ impl LibraryDatabase { MAX(format) as format, MAX(bit_depth) as bit_depth, MAX(sample_rate) as sample_rate, + json_group_array(json(genres_json)) as genre_sets, MAX(group_key) as directory_path, MAX(source) as source FROM ( @@ -1390,6 +1831,11 @@ impl LibraryDatabase { format, bit_depth, sample_rate, + COALESCE( + NULLIF(genres_json, '[]'), + CASE WHEN genre IS NULL OR TRIM(genre) = '' + THEN '[]' ELSE json_array(TRIM(genre)) END + ) AS genres_json, COALESCE(source, 'user') as source FROM local_tracks WHERE 1=1 {} {} @@ -1418,6 +1864,7 @@ impl LibraryDatabase { MAX(format) as format, MAX(bit_depth) as bit_depth, MAX(sample_rate) as sample_rate, + json_group_array(json(genres_json)) as genre_sets, MAX(group_key) as directory_path, MAX(source) as source FROM ( @@ -1432,6 +1879,11 @@ impl LibraryDatabase { format, bit_depth, sample_rate, + COALESCE( + NULLIF(genres_json, '[]'), + CASE WHEN genre IS NULL OR TRIM(genre) = '' + THEN '[]' ELSE json_array(TRIM(genre)) END + ) AS genres_json, COALESCE(source, 'user') as source FROM local_tracks WHERE 1=1 {} {} @@ -1473,21 +1925,27 @@ impl LibraryDatabase { all_artists, year: row.get(4)?, catalog_number: row.get(5)?, + genres: Self::genres_from_sets_json( + row.get::<_, Option<String>>(12)?.as_deref(), + ), artwork_path, + artwork_source: None, track_count: row.get(7)?, - total_duration_secs: row.get(8)?, + total_duration_secs: row.get::<_, i64>(8)? as u64, format: Self::parse_format( &row.get::<_, Option<String>>(9)?.unwrap_or_default(), ), bit_depth: row.get(10)?, sample_rate: row.get::<_, Option<f64>>(11)?.unwrap_or(44100.0), directory_path: row - .get::<_, Option<String>>(12)? + .get::<_, Option<String>>(13)? .unwrap_or_else(|| group_key.clone()), source_folders: None, source: row - .get::<_, Option<String>>(13)? + .get::<_, Option<String>>(14)? .unwrap_or_else(|| "user".to_string()), + sources: Vec::new(), + identity_tracks: Vec::new(), }) }) .map_err(|e| LibraryError::Database(e.to_string()))?; @@ -1526,9 +1984,10 @@ impl LibraryDatabase { /// List the immediate children of a folder in the local-library /// filesystem hierarchy. /// - /// Walks `local_tracks.file_path` and computes one row per direct - /// child. Returns folders first (alphabetical, case-insensitive), - /// then tracks (alphabetical, case-insensitive). + /// Walks the physical file hierarchy and computes one row per direct + /// child. SACD virtual tracks are projected beneath their backing image, + /// so the image is an expandable synthetic folder. Returns folders first + /// (alphabetical, case-insensitive), then tracks likewise. /// /// Filters `COALESCE(source, 'user') = 'user'` so Qobuz offline /// downloads are excluded; Plex rows already live outside @@ -1545,7 +2004,7 @@ impl LibraryDatabase { ) -> Result<Vec<FolderTreeEntry>, LibraryError> { let escaped_prefix = escape_like_pattern(parent_path); - // Network folder filter: exclude tracks whose file_path starts + // Network folder filter: exclude tracks whose physical path starts // with any registered network-mount folder path. Mirrors the // mechanism used by `get_albums_with_full_filter` so tree rail // visibility matches flat-mode + recursive playback. @@ -1553,15 +2012,16 @@ impl LibraryDatabase { "AND NOT EXISTS ( \ SELECT 1 FROM library_folders nf \ WHERE nf.is_network = 1 \ - AND local_tracks.file_path LIKE nf.path || '%' \ + AND folder_tracks.physical_path LIKE nf.path || '%' \ )" } else { "" }; - // SQL strategy (CTE form for readability; SQLite uses - // idx_tracks_file_path on the LIKE prefix in the candidates step): - // suffix = file_path with the parent prefix + '/' stripped + // SQL strategy (CTE form for readability): + // browse_path = file_path for ordinary tracks; for SACD tracks, + // image_path + a stable synthetic leaf label + // suffix = browse_path with the parent prefix + '/' stripped // child_segment = leading path component of suffix // kind = 'folder' if suffix contains a '/', else 'track' // Group by (child_segment, kind) so folders aggregate over all @@ -1569,13 +2029,29 @@ impl LibraryDatabase { // MIN(file_path) so we can recover the absolute path for tracks // (folders ignore it and reconstruct path from parent + segment). let sql = format!( - "WITH candidates AS ( \ + "WITH folder_tracks AS ( \ + SELECT local_tracks.*, \ + COALESCE( \ + sacd_images.image_path || '/' || \ + printf('%03d - %s', sacd_tracks.track_number, \ + replace(local_tracks.title, '/', '∕')), \ + local_tracks.file_path \ + ) AS browse_path, \ + COALESCE(sacd_images.image_path, local_tracks.file_path) \ + AS physical_path \ + FROM local_tracks \ + LEFT JOIN local_sacd_tracks sacd_tracks \ + ON sacd_tracks.local_track_id=local_tracks.id \ + LEFT JOIN local_sacd_images sacd_images \ + ON sacd_images.fingerprint=sacd_tracks.fingerprint \ + ), \ + candidates AS ( \ SELECT \ - substr(file_path, length(?1) + 2) AS suffix, \ + substr(browse_path, length(?1) + 2) AS suffix, \ file_path, \ artwork_path \ - FROM local_tracks \ - WHERE file_path LIKE ?2 || '/%' ESCAPE '\\' \ + FROM folder_tracks \ + WHERE browse_path LIKE ?2 || '/%' ESCAPE '\\' \ AND COALESCE(source, 'user') = 'user' \ {network_filter} \ ), \ @@ -1609,7 +2085,7 @@ impl LibraryDatabase { .map_err(|e| LibraryError::Database(e.to_string()))?; // ?1 bound with the unescaped path (used in length() arithmetic - // on the row's stored file_path; that storage is unescaped). + // on the computed browse_path, which is itself unescaped). // ?2 bound with the LIKE-escaped pattern prefix. let rows = stmt .query_map(params![parent_path, escaped_prefix], |row| { @@ -1639,8 +2115,8 @@ impl LibraryDatabase { "track" => { // Use the actual file_path so paths with edge-case // characters round-trip exactly as stored. - let path = one_file_path - .unwrap_or_else(|| format!("{}/{}", parent_path, segment)); + let path = + one_file_path.unwrap_or_else(|| format!("{}/{}", parent_path, segment)); entries.push(FolderTreeEntry::Track { path, segment }); } _ => { @@ -1672,9 +2148,10 @@ impl LibraryDatabase { /// List the direct-child tracks of a folder (NON-recursive). /// - /// Returns rows from `local_tracks` whose `file_path` is exactly - /// `folder_path + "/" + filename` — files in subfolders are - /// excluded. Mirrors the source filter from + /// Returns rows whose projected browse path is exactly + /// `folder_path + "/" + filename` — files in subfolders are excluded. + /// For SACD this makes the virtual tracks direct children of the image. + /// Mirrors the source filter from /// [`Self::list_folder_children`] so Qobuz downloads do not appear. /// Ordering matches the canonical album-track ordering used by /// [`Self::get_album_tracks`]: disc, then track number, then title. @@ -1692,16 +2169,32 @@ impl LibraryDatabase { "AND NOT EXISTS ( \ SELECT 1 FROM library_folders nf \ WHERE nf.is_network = 1 \ - AND local_tracks.file_path LIKE nf.path || '%' \ + AND folder_tracks.physical_path LIKE nf.path || '%' \ )" } else { "" }; let sql = format!( - "SELECT {cols} FROM local_tracks \ - WHERE file_path LIKE ?1 || '/%' ESCAPE '\\' \ - AND substr(file_path, length(?2) + 2) NOT LIKE '%/%' \ + "WITH folder_tracks AS ( \ + SELECT local_tracks.*, \ + COALESCE( \ + sacd_images.image_path || '/' || \ + printf('%03d - %s', sacd_tracks.track_number, \ + replace(local_tracks.title, '/', '∕')), \ + local_tracks.file_path \ + ) AS browse_path, \ + COALESCE(sacd_images.image_path, local_tracks.file_path) \ + AS physical_path \ + FROM local_tracks \ + LEFT JOIN local_sacd_tracks sacd_tracks \ + ON sacd_tracks.local_track_id=local_tracks.id \ + LEFT JOIN local_sacd_images sacd_images \ + ON sacd_images.fingerprint=sacd_tracks.fingerprint \ + ) \ + SELECT {cols} FROM folder_tracks \ + WHERE browse_path LIKE ?1 || '/%' ESCAPE '\\' \ + AND substr(browse_path, length(?2) + 2) NOT LIKE '%/%' \ AND COALESCE(source, 'user') = 'user' \ {network_filter} \ ORDER BY disc_number ASC NULLS LAST, \ @@ -1717,8 +2210,7 @@ impl LibraryDatabase { .map_err(|e| LibraryError::Database(e.to_string()))?; // ?1 = LIKE-escaped pattern (matches paths under the folder). - // ?2 = unescaped path used for substr arithmetic on stored - // file_path (which is itself unescaped). + // ?2 = unescaped path used for substr arithmetic on browse_path. let rows = stmt .query_map(params![escaped_prefix, folder_path], |row| { Self::row_to_track(row) @@ -1735,8 +2227,8 @@ impl LibraryDatabase { /// List ALL tracks recursively under a folder (every descendant, at /// any depth). Mirrors the source filter and LIKE-escape strategy /// from [`Self::list_folder_tracks`] but does NOT require the - /// `file_path` to live directly inside `folder_path` — every row - /// matching `file_path LIKE folder_path || '/%'` is included. + /// browse path to live directly inside `folder_path` — every descendant + /// row is included. /// /// Used by the tree-mode multi-select to populate the union of /// `selectedTrackIds` when the user ticks a folder-row checkbox. @@ -1744,9 +2236,8 @@ impl LibraryDatabase { /// can build queue items for "Play Next" / "Add to Queue" without /// a second round-trip. /// - /// Ordering: by `file_path` ASC. This produces a stable, on-disk - /// reading order for cross-album / cross-disc subtrees, matching - /// the way `handlePlayRecursive` sorts before queuing. + /// Ordering: by projected browse path ASC. This produces a stable tree + /// order for ordinary files and SACD virtual leaves alike. pub fn list_folder_tracks_recursive( &self, folder_path: &str, @@ -1762,18 +2253,34 @@ impl LibraryDatabase { "AND NOT EXISTS ( \ SELECT 1 FROM library_folders nf \ WHERE nf.is_network = 1 \ - AND local_tracks.file_path LIKE nf.path || '%' \ + AND folder_tracks.physical_path LIKE nf.path || '%' \ )" } else { "" }; let sql = format!( - "SELECT {cols} FROM local_tracks \ - WHERE file_path LIKE ?1 || '/%' ESCAPE '\\' \ + "WITH folder_tracks AS ( \ + SELECT local_tracks.*, \ + COALESCE( \ + sacd_images.image_path || '/' || \ + printf('%03d - %s', sacd_tracks.track_number, \ + replace(local_tracks.title, '/', '∕')), \ + local_tracks.file_path \ + ) AS browse_path, \ + COALESCE(sacd_images.image_path, local_tracks.file_path) \ + AS physical_path \ + FROM local_tracks \ + LEFT JOIN local_sacd_tracks sacd_tracks \ + ON sacd_tracks.local_track_id=local_tracks.id \ + LEFT JOIN local_sacd_images sacd_images \ + ON sacd_images.fingerprint=sacd_tracks.fingerprint \ + ) \ + SELECT {cols} FROM folder_tracks \ + WHERE browse_path LIKE ?1 || '/%' ESCAPE '\\' \ AND COALESCE(source, 'user') = 'user' \ {network_filter} \ - ORDER BY file_path ASC", + ORDER BY browse_path ASC", cols = Self::TRACK_COLUMNS, network_filter = network_filter, ); @@ -1794,8 +2301,8 @@ impl LibraryDatabase { Ok(tracks) } - /// Lightweight `COUNT(*)` of every user track whose `file_path` lives - /// recursively under `folder_path`. Used by the tree-mode rail to + /// Lightweight `COUNT(*)` of every user track whose projected browse path + /// lives recursively under `folder_path`. Used by the tree-mode rail to /// populate the recursive descendant count on top-level scan-root /// rows (which are synthesized client-side and don't go through /// [`Self::list_folder_children`], so they don't carry their own @@ -1816,15 +2323,31 @@ impl LibraryDatabase { "AND NOT EXISTS ( \ SELECT 1 FROM library_folders nf \ WHERE nf.is_network = 1 \ - AND local_tracks.file_path LIKE nf.path || '%' \ + AND folder_tracks.physical_path LIKE nf.path || '%' \ )" } else { "" }; let sql = format!( - "SELECT COUNT(*) FROM local_tracks \ - WHERE file_path LIKE ?1 || '/%' ESCAPE '\\' \ + "WITH folder_tracks AS ( \ + SELECT local_tracks.*, \ + COALESCE( \ + sacd_images.image_path || '/' || \ + printf('%03d - %s', sacd_tracks.track_number, \ + replace(local_tracks.title, '/', '∕')), \ + local_tracks.file_path \ + ) AS browse_path, \ + COALESCE(sacd_images.image_path, local_tracks.file_path) \ + AS physical_path \ + FROM local_tracks \ + LEFT JOIN local_sacd_tracks sacd_tracks \ + ON sacd_tracks.local_track_id=local_tracks.id \ + LEFT JOIN local_sacd_images sacd_images \ + ON sacd_images.fingerprint=sacd_tracks.fingerprint \ + ) \ + SELECT COUNT(*) FROM folder_tracks \ + WHERE browse_path LIKE ?1 || '/%' ESCAPE '\\' \ AND COALESCE(source, 'user') = 'user' \ {network_filter}", network_filter = network_filter, @@ -1903,6 +2426,11 @@ impl LibraryDatabase { format, bit_depth, sample_rate, + COALESCE( + NULLIF(genres_json, '[]'), + CASE WHEN genre IS NULL OR TRIM(genre) = '' + THEN '[]' ELSE json_array(TRIM(genre)) END + ) AS genres_json, album_group_key AS source_folder, COALESCE(source, 'user') AS source FROM local_tracks @@ -1927,6 +2455,7 @@ impl LibraryDatabase { MAX(format) AS format, MAX(bit_depth) AS bit_depth, MAX(sample_rate) AS sample_rate, + json_group_array(json(genres_json)) AS genre_sets, GROUP_CONCAT(DISTINCT source_folder) AS source_folders, MAX(source) AS source FROM grouped @@ -1948,10 +2477,9 @@ impl LibraryDatabase { let group_key: String = row.get(0)?; let album: String = row.get(1)?; let artist: String = row.get(2)?; - let all_artists: String = - row.get::<_, Option<String>>(3)?.unwrap_or_default(); + let all_artists: String = row.get::<_, Option<String>>(3)?.unwrap_or_default(); let artwork_path: Option<String> = row.get(6)?; - let source_folders: Option<String> = row.get(12)?; + let source_folders: Option<String> = row.get(13)?; Ok(LocalAlbum { id: group_key.clone(), @@ -1960,9 +2488,13 @@ impl LibraryDatabase { all_artists, year: row.get(4)?, catalog_number: row.get(5)?, + genres: Self::genres_from_sets_json( + row.get::<_, Option<String>>(12)?.as_deref(), + ), artwork_path, + artwork_source: None, track_count: row.get(7)?, - total_duration_secs: row.get(8)?, + total_duration_secs: row.get::<_, i64>(8)? as u64, format: Self::parse_format( &row.get::<_, Option<String>>(9)?.unwrap_or_default(), ), @@ -1971,8 +2503,10 @@ impl LibraryDatabase { directory_path: String::new(), source_folders, source: row - .get::<_, Option<String>>(13)? + .get::<_, Option<String>>(14)? .unwrap_or_else(|| "user".to_string()), + sources: Vec::new(), + identity_tracks: Vec::new(), }) }) .map_err(|e| LibraryError::Database(e.to_string()))?; @@ -2015,29 +2549,32 @@ impl LibraryDatabase { include_qobuz_downloads: bool, exclude_network_folders: bool, plex_cache_path: Option<&std::path::Path>, + remote_cache_path: Option<&std::path::Path>, + remote_sources: &[&str], group_mode: crate::album_grouping::AlbumGroupMode, ) -> Result<crate::models::AlbumsMetadataPage, LibraryError> { - // Best-effort ATTACH of the Plex cache so the union below can - // see `plex_cache.plex_cache_tracks`. DETACH first defensively - // so a stale attachment from a previous call (or another - // connection user) doesn't fail the new one. Failure to - // attach is non-fatal — we fall back to local-only. - let plex_attached = if let Some(path) = plex_cache_path { - if path.exists() { - let _ = self.conn.execute("DETACH DATABASE plex_cache", []); - let path_str = path.to_string_lossy().replace('\'', "''"); - self.conn - .execute( - &format!("ATTACH DATABASE '{}' AS plex_cache", path_str), - [], - ) - .is_ok() - } else { - false - } - } else { - false - }; + // TWO attachments, deliberately. `plex_cache` is the original + // per-source mirror; `remote_cache` is the shared one every source + // added after it writes into (`qbz-media-cache`), keyed by a `source` + // column. Plex will fold into the second and this will collapse back + // to one — see that crate's header for why it is not folded in the + // same change that introduced Jellyfin and Subsonic. + // + // Best-effort, and independently so: a missing or unreadable Plex + // cache must not cost the user their Jellyfin rows, and vice versa. + let plex_attached = self.attach_best_effort("plex_cache", plex_cache_path); + // The shared mirror holds EVERY remote source, so which of them the + // union may show is a separate question from whether the file is + // there. Turning Jellyfin off has to hide Jellyfin's rows without + // touching Subsonic's, and an empty list means "no remote source is + // enabled" — do not attach at all. + let remote_filter = remote_source_filter(remote_sources); + let remote_attached = !remote_sources.is_empty() + && self.attach_best_effort("remote_cache", remote_cache_path); + let plex_has_genres_json = plex_attached + && self.attached_has_column("plex_cache", "plex_cache_tracks", "genres_json"); + let remote_has_genres_json = remote_attached + && self.attached_has_column("remote_cache", "remote_cache_tracks", "genres_json"); let result = self.get_albums_metadata_page_inner( offset, limit, @@ -2047,14 +2584,53 @@ impl LibraryDatabase { include_qobuz_downloads, exclude_network_folders, plex_attached, + remote_attached, + plex_has_genres_json, + remote_has_genres_json, + &remote_filter, group_mode, ); if plex_attached { let _ = self.conn.execute("DETACH DATABASE plex_cache", []); } + if remote_attached { + let _ = self.conn.execute("DETACH DATABASE remote_cache", []); + } result } + /// ATTACH `path` under `alias`, reporting whether the union may use it. + /// + /// DETACH first, defensively: a stale attachment left by a previous call + /// (or by another user of this connection) makes the new ATTACH fail, and + /// the failure mode of that is a silently local-only library. + /// + /// A failure is NON-FATAL by design. The alternative — refusing to list any + /// albums because one mirror is missing — turns "your Jellyfin server is + /// off" into "your music library is empty". + fn attach_best_effort(&self, alias: &str, path: Option<&std::path::Path>) -> bool { + let Some(path) = path.filter(|p| p.exists()) else { + return false; + }; + let _ = self.conn.execute(&format!("DETACH DATABASE {alias}"), []); + let path_str = path.to_string_lossy().replace('\'', "''"); + self.conn + .execute(&format!("ATTACH DATABASE '{path_str}' AS {alias}"), []) + .is_ok() + } + + fn attached_has_column(&self, schema: &str, table: &str, column: &str) -> bool { + let sql = format!("PRAGMA {schema}.table_info({table})"); + let Ok(mut stmt) = self.conn.prepare(&sql) else { + return false; + }; + let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(1)) else { + return false; + }; + let found = rows.filter_map(Result::ok).any(|name| name == column); + found + } + /// Resolve a folder cover for an album that has no `artwork_path` in the /// index — e.g. an offline-cached (CMAF) album whose downloader wrote a /// `cover.jpg` into the track folder but didn't backfill `artwork_path`. @@ -2102,7 +2678,10 @@ impl LibraryDatabase { }; // Check the folder and its parent (covers multi-disc layouts where // the art sits one level up). - let dirs = [Some(folder.clone()), folder.parent().map(|x| x.to_path_buf())]; + let dirs = [ + Some(folder.clone()), + folder.parent().map(|x| x.to_path_buf()), + ]; for dir in dirs.into_iter().flatten() { for name in NAMES { let cover = dir.join(name); @@ -2125,6 +2704,10 @@ impl LibraryDatabase { include_qobuz_downloads: bool, exclude_network_folders: bool, plex_attached: bool, + remote_attached: bool, + plex_has_genres_json: bool, + remote_has_genres_json: bool, + remote_filter: &str, group_mode: crate::album_grouping::AlbumGroupMode, ) -> Result<crate::models::AlbumsMetadataPage, LibraryError> { let source_filter = if include_qobuz_downloads { @@ -2169,8 +2752,14 @@ impl LibraryDatabase { // arm (plex stores duration_ms / sampling_rate_hz as INTEGER // while local uses REAL for sample_rate and seconds-INTEGER // for duration). + let plex_genres_expr = if plex_has_genres_json { + "genres_json" + } else { + "'[]'" + }; let plex_cte = if plex_attached { - r#", + format!( + r#", plex_aggregated AS ( SELECT -- `album_key` is populated by plex/mod.rs::plex_album_key() @@ -2220,19 +2809,103 @@ impl LibraryDatabase { END ) AS bit_depth, CAST(MAX(sampling_rate_hz) AS REAL) AS sample_rate, + json_group_array(json_array( + COALESCE(title, ''), + CAST(COALESCE(duration_ms, 0) / 1000 AS INTEGER) + )) AS identity_tracks, + json_array('plex') AS source_words, + json_group_array(json(COALESCE( + NULLIF({plex_genres_expr}, '[]'), + CASE WHEN genre IS NULL OR TRIM(genre) = '' + THEN '[]' ELSE json_array(TRIM(genre)) END + ))) AS genre_sets, CAST(NULL AS TEXT) AS source_folders, 'plex' AS source FROM plex_cache.plex_cache_tracks GROUP BY COALESCE(album_key, 'plex:' || rating_key) )"# + ) } else { - "" + String::new() }; - let unioned_clause = if plex_attached { - "SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated" + // The SHARED remote mirror: Jellyfin, Subsonic, and whatever comes + // next. ONE arm for all of them because the table carries a `source` + // column — that is the entire reason it exists. + // + // The group key is `<source>:<album id>`, matching Plex's `plex:<hash>` + // convention: it namespaces two servers that happen to use the same + // album id, and it is what lets a source `claim` an album card that + // arrives with no source word (which is every card that round-trips + // through a QML string property). + // + // Unlike Plex there is no bit-depth guess here. Both protocols report + // it directly — Jellyfin in MediaSources, Subsonic as an OpenSubsonic + // field — and the mappers already fold "not applicable" (Jellyfin's + // null, Subsonic's 0) to NULL. Inventing 16 for a lossless CD-rate row + // would be guessing where the server answered. + let remote_genres_expr = if remote_has_genres_json { + "genres_json" } else { - "SELECT * FROM aggregated" + "'[]'" + }; + let remote_cte = if remote_attached { + format!( + r#", + remote_aggregated AS ( + SELECT + source || ':' || album_id AS group_key, + COALESCE(NULLIF(TRIM(album), ''), 'Unknown Album') AS title, + CASE WHEN COUNT(DISTINCT album_artist) > 1 + THEN 'Various Artists' + ELSE COALESCE(NULLIF(MIN(album_artist), ''), MIN(artist), 'Unknown Artist') + END AS artist, + GROUP_CONCAT(DISTINCT artist) AS all_artists, + MIN(year) AS year, + CAST(NULL AS TEXT) AS catalog_number, + COALESCE( + MAX(CASE WHEN collection_artwork_token IS NOT NULL + AND TRIM(collection_artwork_token) != '' + THEN collection_artwork_token END), + MAX(CASE WHEN artwork_token IS NOT NULL + AND TRIM(artwork_token) != '' + THEN artwork_token END) + ) AS artwork, + COUNT(*) AS track_count, + CAST(SUM(COALESCE(duration_ms, 0)) / 1000 AS INTEGER) AS total_duration, + COALESCE(MAX(container), MAX(codec)) AS format, + MAX(bit_depth) AS bit_depth, + CAST(MAX(sample_rate_hz) AS REAL) AS sample_rate, + json_group_array(json_array( + COALESCE(title, ''), + CAST(COALESCE(duration_ms, 0) / 1000 AS INTEGER) + )) AS identity_tracks, + json_group_array(DISTINCT source) AS source_words, + json_group_array(json(COALESCE( + NULLIF({remote_genres_expr}, '[]'), + CASE WHEN genre IS NULL OR TRIM(genre) = '' + THEN '[]' ELSE json_array(TRIM(genre)) END + ))) AS genre_sets, + CAST(NULL AS TEXT) AS source_folders, + source AS source + FROM remote_cache.remote_cache_tracks + WHERE album_id != '' AND {remote_filter} + GROUP BY source, album_id + )"# + ) + } else { + String::new() + }; + + let unioned_clause = match (plex_attached, remote_attached) { + (true, true) => { + "SELECT * FROM aggregated \ + UNION ALL SELECT * FROM plex_aggregated \ + UNION ALL SELECT * FROM remote_aggregated" + } + (true, false) => "SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated", + (false, true) => "SELECT * FROM aggregated UNION ALL SELECT * FROM remote_aggregated", + (false, false) => "SELECT * FROM aggregated", }; let query = format!( @@ -2261,7 +2934,13 @@ impl LibraryDatabase { sample_rate, album_group_key AS source_folder, COALESCE(source, 'user') AS source, - artist AS track_artist + artist AS track_artist, + COALESCE(title, '') AS track_title, + COALESCE( + NULLIF(genres_json, '[]'), + CASE WHEN genre IS NULL OR TRIM(genre) = '' + THEN '[]' ELSE json_array(TRIM(genre)) END + ) AS genres_json FROM local_tracks WHERE 1=1 {source_filter} {network_filter} ), @@ -2272,7 +2951,7 @@ impl LibraryDatabase { THEN 'Unknown Album' ELSE MIN(title) END AS title, - CASE WHEN COUNT(DISTINCT track_artist) > 1 + CASE WHEN COUNT(DISTINCT artist) > 1 THEN 'Various Artists' ELSE MIN(artist) END AS artist, @@ -2285,11 +2964,17 @@ impl LibraryDatabase { MAX(format) AS format, MAX(bit_depth) AS bit_depth, MAX(sample_rate) AS sample_rate, + json_group_array(json_array( + track_title, + CAST(COALESCE(duration_secs, 0) AS INTEGER) + )) AS identity_tracks, + json_group_array(DISTINCT source) AS source_words, + json_group_array(json(genres_json)) AS genre_sets, GROUP_CONCAT(DISTINCT source_folder) AS source_folders, MAX(source) AS source FROM grouped GROUP BY group_key - ){plex_cte}, + ){plex_cte}{remote_cte}, filtered AS ( SELECT * FROM ({unioned_clause}) WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2) @@ -2297,7 +2982,7 @@ impl LibraryDatabase { SELECT group_key, title, artist, all_artists, year, catalog_number, artwork, track_count, total_duration, format, bit_depth, - sample_rate, source_folders, source, + sample_rate, identity_tracks, source_words, genre_sets, source_folders, source, COUNT(*) OVER () AS total FROM filtered ORDER BY {order_clause} @@ -2323,11 +3008,32 @@ impl LibraryDatabase { let group_key: String = row.get(0)?; let title: String = row.get(1)?; let artist: String = row.get(2)?; - let all_artists: String = - row.get::<_, Option<String>>(3)?.unwrap_or_default(); + let all_artists: String = row.get::<_, Option<String>>(3)?.unwrap_or_default(); let artwork_path: Option<String> = row.get(6)?; - let source_folders: Option<String> = row.get(12)?; - let total: u64 = row.get::<_, i64>(14)? as u64; + let identity_json = row + .get::<_, Option<String>>(12)? + .unwrap_or_else(|| "[]".to_string()); + let identity_tracks = + serde_json::from_str::<Vec<(String, u64)>>(&identity_json) + .unwrap_or_default() + .into_iter() + .map(|(title, duration_secs)| crate::models::AlbumTrackEvidence { + title, + duration_secs, + }) + .collect(); + let sources = serde_json::from_str::<Vec<String>>( + &row.get::<_, Option<String>>(13)? + .unwrap_or_else(|| "[]".to_string()), + ) + .unwrap_or_default(); + let genres = + Self::genres_from_sets_json(row.get::<_, Option<String>>(14)?.as_deref()); + let source_folders: Option<String> = row.get(15)?; + let source = row + .get::<_, Option<String>>(16)? + .unwrap_or_else(|| "user".to_string()); + let total: u64 = row.get::<_, i64>(17)? as u64; Ok(( LocalAlbum { @@ -2337,9 +3043,11 @@ impl LibraryDatabase { all_artists, year: row.get(4)?, catalog_number: row.get(5)?, + genres, artwork_path, + artwork_source: None, track_count: row.get(7)?, - total_duration_secs: row.get(8)?, + total_duration_secs: row.get::<_, i64>(8)? as u64, format: Self::parse_format( &row.get::<_, Option<String>>(9)?.unwrap_or_default(), ), @@ -2347,9 +3055,13 @@ impl LibraryDatabase { sample_rate: row.get::<_, Option<f64>>(11)?.unwrap_or(44100.0), directory_path: String::new(), source_folders, - source: row - .get::<_, Option<String>>(13)? - .unwrap_or_else(|| "user".to_string()), + sources: if sources.is_empty() { + vec![source.clone()] + } else { + sources + }, + source, + identity_tracks, }, total, )) @@ -2374,6 +3086,8 @@ impl LibraryDatabase { include_qobuz_downloads, exclude_network_folders, plex_attached, + remote_attached, + remote_filter, group_mode, )?; } @@ -2392,6 +3106,8 @@ impl LibraryDatabase { include_qobuz_downloads: bool, exclude_network_folders: bool, plex_attached: bool, + remote_attached: bool, + remote_filter: &str, group_mode: crate::album_grouping::AlbumGroupMode, ) -> Result<u64, LibraryError> { let source_filter = if include_qobuz_downloads { @@ -2429,10 +3145,35 @@ impl LibraryDatabase { } else { "" }; - let unioned_clause = if plex_attached { - "SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated" + // Grouped and filtered EXACTLY as the page query groups and filters + // it — same key, same `album_id != ''` guard. A count that disagrees + // with the page it counts is worse than no count: the grid renders a + // scrollbar for rows that are not there. + let remote_cte = if remote_attached { + format!( + r#", + remote_aggregated AS ( + SELECT + source || ':' || album_id AS group_key, + COALESCE(NULLIF(TRIM(album), ''), 'Unknown Album') AS title, + COALESCE(NULLIF(MIN(album_artist), ''), MIN(artist), 'Unknown Artist') AS artist + FROM remote_cache.remote_cache_tracks + WHERE album_id != '' AND {remote_filter} + GROUP BY source, album_id + )"# + ) } else { - "SELECT * FROM aggregated" + String::new() + }; + let unioned_clause = match (plex_attached, remote_attached) { + (true, true) => { + "SELECT * FROM aggregated \ + UNION ALL SELECT * FROM plex_aggregated \ + UNION ALL SELECT * FROM remote_aggregated" + } + (true, false) => "SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated", + (false, true) => "SELECT * FROM aggregated UNION ALL SELECT * FROM remote_aggregated", + (false, false) => "SELECT * FROM aggregated", }; let query = format!( @@ -2463,13 +3204,13 @@ impl LibraryDatabase { THEN 'Unknown Album' ELSE MIN(title) END AS title, - CASE WHEN COUNT(DISTINCT track_artist) > 1 + CASE WHEN COUNT(DISTINCT artist) > 1 THEN 'Various Artists' ELSE MIN(artist) END AS artist FROM grouped GROUP BY group_key - ){plex_cte} + ){plex_cte}{remote_cte} SELECT COUNT(*) FROM ({unioned_clause}) WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2) @@ -2478,16 +3219,15 @@ impl LibraryDatabase { source_filter = source_filter, network_filter = network_filter, plex_cte = plex_cte, + remote_cte = remote_cte, unioned_clause = unioned_clause, ); let total: i64 = self .conn - .query_row( - &query, - rusqlite::params![has_search, search_like], - |row| row.get(0), - ) + .query_row(&query, rusqlite::params![has_search, search_like], |row| { + row.get(0) + }) .map_err(|e| LibraryError::Database(e.to_string()))?; Ok(total as u64) } @@ -2663,7 +3403,9 @@ impl LibraryDatabase { /// per-track embedded covers. Per-track artwork is now resolved /// individually at scan time. Kept compilable for any caller that /// might still exist; do not introduce new callers. - #[deprecated(note = "Was destructive in scan loop; per-track artwork is resolved during scan instead")] + #[deprecated( + note = "Was destructive in scan loop; per-track artwork is resolved during scan instead" + )] pub fn update_album_group_artwork( &self, group_key: &str, @@ -2821,6 +3563,140 @@ impl LibraryDatabase { Ok(()) } + /// Commit one metadata-editor draft and its optional front cover as a + /// single exact-row transaction. A stale/deleted row aborts the entire + /// draft instead of leaving tags, index metadata and artwork out of sync. + pub fn update_tracks_metadata_and_artwork_by_id( + &mut self, + updates: &[TrackMetadataUpdateFull], + artwork_path: Option<&str>, + ) -> Result<(), LibraryError> { + if updates.is_empty() { + return Err(LibraryError::Database( + "Metadata update requires at least one track".to_string(), + )); + } + if artwork_path.is_some_and(|path| path.trim().is_empty()) { + return Err(LibraryError::Database( + "Artwork path cannot be blank".to_string(), + )); + } + let unique = updates + .iter() + .map(|update| update.id) + .collect::<std::collections::HashSet<_>>(); + if unique.len() != updates.len() { + return Err(LibraryError::Database( + "Metadata update contains duplicate track ids".to_string(), + )); + } + let tx = self + .conn + .transaction() + .map_err(|error| LibraryError::Database(error.to_string()))?; + { + let mut statement = tx + .prepare( + r#" + UPDATE local_tracks + SET + title = ?1, + artist = ?2, + album = ?3, + album_artist = ?4, + album_group_title = ?5, + track_number = ?6, + disc_number = ?7, + year = ?8, + genre = ?9, + catalog_number = ?10, + artwork_path = COALESCE(?11, artwork_path) + WHERE id = ?12 + "#, + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + for update in updates { + let changed = statement + .execute(params![ + update.title.trim(), + update.artist.trim(), + update.album.trim(), + update + .album_artist + .as_ref() + .map(|value| value.trim().to_string()), + update.album_group_title.trim(), + update.track_number, + update.disc_number, + update.year, + update.genre.as_ref().map(|value| value.trim().to_string()), + update + .catalog_number + .as_ref() + .map(|value| value.trim().to_string()), + artwork_path, + update.id, + ]) + .map_err(|error| LibraryError::Database(error.to_string()))?; + if changed != 1 { + return Err(LibraryError::Database(format!( + "Metadata target track {} no longer exists", + update.id + ))); + } + } + } + tx.commit() + .map_err(|error| LibraryError::Database(error.to_string()))?; + Ok(()) + } + + /// Apply an editor-selected cover only to the snapshotted physical rows. + /// Album-group-wide artwork updates are intentionally avoided because a + /// multi-disc collection may carry a different cover on every disc. + pub fn update_tracks_artwork_by_id( + &mut self, + ids: &[i64], + artwork_path: &str, + ) -> Result<(), LibraryError> { + if ids.is_empty() || artwork_path.trim().is_empty() { + return Err(LibraryError::Database( + "Artwork update requires track ids and a path".to_string(), + )); + } + let unique = ids + .iter() + .copied() + .collect::<std::collections::HashSet<_>>(); + if unique.len() != ids.len() { + return Err(LibraryError::Database( + "Artwork update contains duplicate track ids".to_string(), + )); + } + let tx = self + .conn + .transaction() + .map_err(|error| LibraryError::Database(error.to_string()))?; + { + let mut statement = tx + .prepare("UPDATE local_tracks SET artwork_path = ?1 WHERE id = ?2") + .map_err(|error| LibraryError::Database(error.to_string()))?; + for id in ids { + let changed = statement + .execute(params![artwork_path, id]) + .map_err(|error| LibraryError::Database(error.to_string()))?; + if changed != 1 { + return Err(LibraryError::Database(format!( + "Artwork target track {id} no longer exists" + ))); + } + } + } + tx.commit() + .map_err(|error| LibraryError::Database(error.to_string()))?; + Ok(()) + } + pub fn find_album_group_key( &self, album: &str, @@ -2928,6 +3804,33 @@ impl LibraryDatabase { include_qobuz_downloads: bool, exclude_network_folders: bool, sort: &str, + ) -> Result<Vec<LocalTrack>, LibraryError> { + self.search_with_filter_page_faceted( + query, + offset, + limit, + include_qobuz_downloads, + exclude_network_folders, + sort, + &[], + false, + &[], + &[], + ) + } + + pub fn search_with_filter_page_faceted( + &self, + query: &str, + offset: u64, + limit: u64, + include_qobuz_downloads: bool, + exclude_network_folders: bool, + sort: &str, + formats: &[String], + other_formats: bool, + quality_tiers: &[String], + source_buckets: &[String], ) -> Result<Vec<LocalTrack>, LibraryError> { let pattern = format!("%{}%", query); let source_filter = if include_qobuz_downloads { @@ -2944,6 +3847,30 @@ impl LibraryDatabase { } else { "" }; + let media_filter = track_media_filter_sql( + "format", + "bit_depth", + "sample_rate", + formats, + other_formats, + quality_tiers, + ); + let source_bucket_filter = if source_buckets.is_empty() { + String::new() + } else { + let mut values = Vec::new(); + if source_buckets.iter().any(|value| value == "local") { + values.push("COALESCE(source,'local') NOT IN ('qobuz_download','qobuz_purchase')"); + } + if source_buckets.iter().any(|value| value == "offline") { + values.push("source IN ('qobuz_download','qobuz_purchase')"); + } + if values.is_empty() { + "AND 0".to_string() + } else { + format!("AND ({})", values.join(" OR ")) + } + }; // ORDER BY clause is built from a validated allowlist so user // input never reaches the SQL string directly. NULL years always // sort last regardless of direction; the fallback ("default" or @@ -2956,6 +3883,9 @@ impl LibraryDatabase { "title-desc" => "title COLLATE NOCASE DESC, artist COLLATE NOCASE, id", "artist-asc" => "COALESCE(album_artist, artist) COLLATE NOCASE, album COLLATE NOCASE, disc_number, track_number, id", "artist-desc" => "COALESCE(album_artist, artist) COLLATE NOCASE DESC, album COLLATE NOCASE, disc_number, track_number, id", + // Internal Tracks grouping order. Group headers display the + // performing artist, so album_artist must not lead this query. + "group-artist" => "artist COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE, id", "year-desc" => "year IS NULL, year DESC, album COLLATE NOCASE, disc_number, track_number, id", "year-asc" => "year IS NULL, year ASC, album COLLATE NOCASE, disc_number, track_number, id", "added-desc" => "indexed_at DESC, album COLLATE NOCASE, disc_number, track_number, id", @@ -2964,17 +3894,20 @@ impl LibraryDatabase { COALESCE(album_artist, artist) COLLATE NOCASE, \ disc_number, \ track_number, \ - title COLLATE NOCASE", + title COLLATE NOCASE, \ + id", }; let sql = format!( "SELECT {} FROM local_tracks \ WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1) \ - {} {} \ + {} {} {} {} \ ORDER BY {} \ LIMIT ?2 OFFSET ?3", Self::TRACK_COLUMNS, source_filter, network_filter, + media_filter, + source_bucket_filter, order_clause, ); let mut stmt = self @@ -3037,8 +3970,8 @@ impl LibraryDatabase { track_count: row.get(0)?, album_count: row.get(1)?, artist_count: row.get(2)?, - total_duration_secs: row.get(3)?, - total_size_bytes: row.get(4)?, + total_duration_secs: row.get::<_, i64>(3)? as u64, + total_size_bytes: row.get::<_, i64>(4)? as u64, }) }) .map_err(|e| LibraryError::Database(e.to_string())) @@ -3049,43 +3982,104 @@ impl LibraryDatabase { /// Convert a database row to LocalTrack /// Column list for SELECT queries (avoids fragile SELECT * with positional indices) const TRACK_COLUMNS: &'static str = "id, file_path, title, artist, album, album_artist, \ - track_number, disc_number, year, genre, duration_secs, format, \ + track_number, disc_number, year, genre, genres_json, duration_secs, format, \ bit_depth, sample_rate, channels, file_size_bytes, \ cue_file_path, cue_start_secs, cue_end_secs, artwork_path, \ last_modified, indexed_at, album_group_key, album_group_title, \ - source, qobuz_track_id, catalog_number, is_network_mount"; + source, qobuz_track_id, catalog_number, is_network_mount, \ + isrc, musicbrainz_recording_id, musicbrainz_track_id, \ + musicbrainz_release_id, musicbrainz_release_group_id, musicbrainz_artist_id"; + + fn track_genres_json(track: &LocalTrack) -> String { + let mut genres = Vec::<String>::new(); + for value in track.genres.iter().chain(track.genre.iter()) { + let value = value.trim(); + if !value.is_empty() + && !genres + .iter() + .any(|existing| existing.eq_ignore_ascii_case(value)) + { + genres.push(value.to_string()); + } + } + serde_json::to_string(&genres).unwrap_or_else(|_| "[]".to_string()) + } + + fn genres_from_json(raw: Option<&str>, primary: Option<&str>) -> Vec<String> { + let mut genres = raw + .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok()) + .unwrap_or_default(); + if genres.is_empty() { + if let Some(value) = primary.filter(|value| !value.trim().is_empty()) { + genres.push(value.trim().to_string()); + } + } + genres + } + + fn genres_from_sets_json(raw: Option<&str>) -> Vec<String> { + let sets = raw + .and_then(|value| serde_json::from_str::<Vec<Vec<String>>>(value).ok()) + .unwrap_or_default(); + let mut genres = Vec::<String>::new(); + for value in sets.into_iter().flatten() { + let value = value.trim(); + if !value.is_empty() + && !genres + .iter() + .any(|existing| existing.eq_ignore_ascii_case(value)) + { + genres.push(value.to_string()); + } + } + genres.sort_by_key(|value| value.to_lowercase()); + genres + } fn row_to_track(row: &rusqlite::Row) -> rusqlite::Result<LocalTrack> { + let genre: Option<String> = row.get(9)?; + let genres = Self::genres_from_json( + row.get::<_, Option<String>>(10)?.as_deref(), + genre.as_deref(), + ); Ok(LocalTrack { - id: row.get(0)?, // id - file_path: row.get(1)?, // file_path - title: row.get(2)?, // title - artist: row.get(3)?, // artist - album: row.get(4)?, // album - album_artist: row.get(5)?, // album_artist - track_number: row.get(6)?, // track_number - disc_number: row.get(7)?, // disc_number - year: row.get(8)?, // year - genre: row.get(9)?, // genre - duration_secs: row.get(10)?, // duration_secs - format: Self::parse_format(&row.get::<_, String>(11)?), // format - bit_depth: row.get(12)?, // bit_depth - sample_rate: row.get::<_, f64>(13)?, // sample_rate - channels: row.get(14)?, // channels - file_size_bytes: row.get(15)?, // file_size_bytes - cue_file_path: row.get(16)?, // cue_file_path - cue_start_secs: row.get(17)?, // cue_start_secs - cue_end_secs: row.get(18)?, // cue_end_secs - artwork_path: row.get(19)?, // artwork_path - last_modified: row.get(20)?, // last_modified - indexed_at: row.get(21)?, // indexed_at - album_group_key: row.get::<_, Option<String>>(22)?.unwrap_or_default(), // album_group_key - album_group_title: row.get::<_, Option<String>>(23)?.unwrap_or_default(), // album_group_title - source: row.get(24).ok().flatten(), // source - qobuz_track_id: row.get(25).ok().flatten(), // qobuz_track_id - catalog_number: row.get(26).ok().flatten(), // catalog_number + id: row.get(0)?, // id + file_path: row.get(1)?, // file_path + title: row.get(2)?, // title + artist: row.get(3)?, // artist + album: row.get(4)?, // album + album_artist: row.get(5)?, // album_artist + track_number: row.get(6)?, // track_number + disc_number: row.get(7)?, // disc_number + year: row.get(8)?, // year + genre, + genres, + duration_secs: row.get::<_, i64>(11)? as u64, // duration_secs + format: Self::parse_format(&row.get::<_, String>(12)?), // format + bit_depth: row.get(13)?, // bit_depth + sample_rate: row.get::<_, f64>(14)?, // sample_rate + channels: row.get(15)?, // channels + file_size_bytes: row.get::<_, i64>(16)? as u64, // file_size_bytes + cue_file_path: row.get(17)?, // cue_file_path + cue_start_secs: row.get(18)?, // cue_start_secs + cue_end_secs: row.get(19)?, // cue_end_secs + artwork_path: row.get(20)?, // artwork_path + collection_artwork_path: None, + last_modified: row.get(21)?, // last_modified + indexed_at: row.get(22)?, // indexed_at + album_group_key: row.get::<_, Option<String>>(23)?.unwrap_or_default(), // album_group_key + album_group_title: row.get::<_, Option<String>>(24)?.unwrap_or_default(), // album_group_title + source: row.get(25).ok().flatten(), // source + qobuz_track_id: row.get(26).ok().flatten(), // qobuz_track_id + catalog_number: row.get(27).ok().flatten(), // catalog_number + isrc: row.get(29).ok().flatten(), + musicbrainz_recording_id: row.get(30).ok().flatten(), + musicbrainz_track_id: row.get(31).ok().flatten(), + musicbrainz_release_id: row.get(32).ok().flatten(), + musicbrainz_release_group_id: row.get(33).ok().flatten(), + musicbrainz_artist_id: row.get(34).ok().flatten(), is_network_mount: row - .get::<_, Option<i64>>(27) + .get::<_, Option<i64>>(28) .ok() .flatten() .map(|v| v != 0) @@ -3094,6 +4088,18 @@ impl LibraryDatabase { } /// Parse format string to AudioFormat + /// Inverse of `AudioFormat`'s `Display` (`models.rs:24`), which is what + /// the scanner stores. It MUST answer every variant that `Display` can + /// write: an unlisted one folds to `Unknown` silently and the format is + /// destroyed on the way out of the DB, not on the way in. + /// + /// `"DSD"` was exactly that hole. `MetadataExtractor::detect_format` has + /// mapped `.dsf`/`.dff` to `AudioFormat::Dsd` since DSD landed + /// (`metadata.rs:824`) and the rows on disk say `DSD` — but every read + /// handed back `Unknown`, so a DSD track showed "UNKNOWN" as its format, + /// took the CD quality badge (depth 1 is a KNOWN depth < 24) and printed + /// "1-bit / 2822.4 kHz". The now-playing bar was right all along because + /// it reads the decoder, not the row. fn parse_format(s: &str) -> AudioFormat { match s.to_uppercase().as_str() { "FLAC" => AudioFormat::Flac, @@ -3102,11 +4108,71 @@ impl LibraryDatabase { "AIFF" => AudioFormat::Aiff, "APE" => AudioFormat::Ape, "MP3" => AudioFormat::Mp3, + "DSD" => AudioFormat::Dsd, _ => AudioFormat::Unknown, } } } +/// Allowlisted SQL for the Local Library quality/format funnel. Values never +/// enter SQL; callers can only turn these fixed predicates on or off. +fn track_media_filter_sql( + format_col: &str, + depth_col: &str, + rate_col: &str, + formats: &[String], + other_formats: bool, + quality_tiers: &[String], +) -> String { + let known = ["flac", "alac", "ape", "wav", "mp3", "aac"]; + let selected = known + .iter() + .copied() + .filter(|value| { + formats + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(value)) + }) + .collect::<Vec<_>>(); + let mut clauses = Vec::new(); + if !selected.is_empty() || other_formats { + let mut formats_sql = selected + .into_iter() + .map(|value| format!("LOWER({format_col})='{value}'")) + .collect::<Vec<_>>(); + if other_formats { + formats_sql.push(format!( + "LOWER({format_col}) NOT IN ('flac','alac','ape','wav','mp3','aac')" + )); + } + clauses.push(format!("AND ({})", formats_sql.join(" OR "))); + } + let hires = quality_tiers.iter().any(|value| value == "hires"); + let cd = quality_tiers.iter().any(|value| value == "cd"); + let lossy = quality_tiers.iter().any(|value| value == "lossy"); + if hires || cd || lossy { + let khz = format!( + "CASE WHEN COALESCE({rate_col},0)>=1000 THEN COALESCE({rate_col},0)/1000.0 ELSE COALESCE({rate_col},0) END" + ); + let mut quality = Vec::new(); + if hires { + quality.push(format!( + "(LOWER({format_col}) IN ('dsd','dsf','dff') OR COALESCE({depth_col},0)>=24)" + )); + } + if cd { + quality.push(format!( + "(LOWER({format_col}) NOT IN ('mp3','dsd','dsf','dff') AND (({depth_col} IS NOT NULL AND {depth_col}<24) OR ({depth_col} IS NULL AND {khz}>=44.1)))" + )); + } + if lossy { + quality.push(format!("LOWER({format_col})='mp3'")); + } + clauses.push(format!("AND ({})", quality.join(" OR "))); + } + clauses.join(" ") +} + /// Escape `%`, `_` and `\` characters so the input can be embedded as a /// LIKE pattern fragment. Pair with `LIKE ?n || '/%' ESCAPE '\'` at the /// SQL site. Used by [`LibraryDatabase::list_folder_children`] and @@ -4121,6 +5187,104 @@ impl LibraryDatabase { Ok(()) } + /// Attach a Jellyfin/Subsonic track to a Qobuz playlist by its server + /// item id — the Plex pattern, source-qualified. Re-adding MOVES the row + /// to the new slot rather than duplicating it (INSERT OR REPLACE on the + /// UNIQUE key, edge E4). + pub fn add_remote_track_to_playlist( + &self, + qobuz_playlist_id: u64, + source: &str, + item_id: &str, + position: i32, + ) -> Result<(), LibraryError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + self.conn + .execute( + "INSERT OR REPLACE INTO playlist_remote_tracks + (qobuz_playlist_id, source, item_id, position, added_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![qobuz_playlist_id as i64, source, item_id, position, now], + ) + .map_err(|e| { + LibraryError::Database(format!("Failed to add remote track to playlist: {}", e)) + })?; + + Ok(()) + } + + /// Remove a Jellyfin/Subsonic track from a playlist. + pub fn remove_remote_track_from_playlist( + &self, + qobuz_playlist_id: u64, + source: &str, + item_id: &str, + ) -> Result<(), LibraryError> { + self.conn + .execute( + "DELETE FROM playlist_remote_tracks + WHERE qobuz_playlist_id = ?1 AND source = ?2 AND item_id = ?3", + params![qobuz_playlist_id as i64, source, item_id], + ) + .map_err(|e| { + LibraryError::Database(format!( + "Failed to remove remote track from playlist: {}", + e + )) + })?; + Ok(()) + } + + /// `(source, item_id, position)` of every remote sidecar row of one + /// playlist, position ASC. + pub fn get_playlist_remote_tracks_with_position( + &self, + qobuz_playlist_id: u64, + ) -> Result<Vec<(String, String, i32)>, LibraryError> { + let mut stmt = self + .conn + .prepare( + "SELECT source, item_id, position FROM playlist_remote_tracks + WHERE qobuz_playlist_id = ?1 + ORDER BY position ASC, added_at ASC, id ASC", + ) + .map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?; + let rows = stmt + .query_map(params![qobuz_playlist_id as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i32>(2)?, + )) + }) + .map_err(|e| LibraryError::Database(format!("Failed to query: {}", e)))?; + let mut out = Vec::new(); + for row in rows { + out.push( + row.map_err(|e| LibraryError::Database(format!("Failed to read row: {}", e)))?, + ); + } + Ok(out) + } + + /// Number of remote sidecar rows of one playlist. + pub fn get_playlist_remote_track_count( + &self, + qobuz_playlist_id: u64, + ) -> Result<u32, LibraryError> { + self.conn + .query_row( + "SELECT COUNT(*) FROM playlist_remote_tracks WHERE qobuz_playlist_id = ?1", + params![qobuz_playlist_id as i64], + |row| row.get::<_, u32>(0), + ) + .map_err(|e| LibraryError::Database(format!("Failed to count remote tracks: {}", e))) + } + /// Remove a Plex track from a playlist. pub fn remove_plex_track_from_playlist( &self, @@ -4162,10 +5326,7 @@ impl LibraryDatabase { Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)) }) .map_err(|e| { - LibraryError::Database(format!( - "Failed to query playlist plex tracks: {}", - e - )) + LibraryError::Database(format!("Failed to query playlist plex tracks: {}", e)) })?; rows.collect::<Result<Vec<_>, _>>().map_err(|e| { @@ -4202,7 +5363,7 @@ impl LibraryDatabase { .prepare( "SELECT t.id, t.file_path, t.title, t.artist, t.album, t.album_artist, t.album_group_key, t.album_group_title, t.track_number, t.disc_number, - t.year, t.genre, t.duration_secs, t.format, t.bit_depth, t.sample_rate, + t.year, t.genre, t.genres_json, t.duration_secs, t.format, t.bit_depth, t.sample_rate, t.channels, t.file_size_bytes, t.cue_file_path, t.cue_start_secs, t.cue_end_secs, t.artwork_path, t.last_modified, t.indexed_at, t.source, t.qobuz_track_id, t.is_network_mount, plt.position @@ -4228,22 +5389,33 @@ impl LibraryDatabase { disc_number: row.get(9)?, year: row.get(10)?, genre: row.get(11)?, + genres: Self::genres_from_json( + row.get::<_, Option<String>>(12)?.as_deref(), + row.get::<_, Option<String>>(11)?.as_deref(), + ), catalog_number: None, - duration_secs: row.get(12)?, - format: Self::parse_format(&row.get::<_, String>(13)?), - bit_depth: row.get(14)?, - sample_rate: row.get::<_, f64>(15)?, - channels: row.get(16)?, - file_size_bytes: row.get(17)?, - cue_file_path: row.get(18)?, - cue_start_secs: row.get(19)?, - cue_end_secs: row.get(20)?, - artwork_path: row.get(21)?, - last_modified: row.get(22)?, - indexed_at: row.get(23)?, - source: row.get(24)?, - qobuz_track_id: row.get(25)?, - is_network_mount: row.get::<_, i64>(26)? != 0, + duration_secs: row.get::<_, i64>(13)? as u64, + format: Self::parse_format(&row.get::<_, String>(14)?), + bit_depth: row.get(15)?, + sample_rate: row.get::<_, f64>(16)?, + channels: row.get(17)?, + file_size_bytes: row.get::<_, i64>(18)? as u64, + cue_file_path: row.get(19)?, + cue_start_secs: row.get(20)?, + cue_end_secs: row.get(21)?, + artwork_path: row.get(22)?, + collection_artwork_path: None, + last_modified: row.get(23)?, + indexed_at: row.get(24)?, + source: row.get(25)?, + qobuz_track_id: row.get(26)?, + isrc: None, + musicbrainz_recording_id: None, + musicbrainz_track_id: None, + musicbrainz_release_id: None, + musicbrainz_release_group_id: None, + musicbrainz_artist_id: None, + is_network_mount: row.get::<_, i64>(27)? != 0, }) }) .map_err(|e| { @@ -4265,7 +5437,7 @@ impl LibraryDatabase { .prepare( "SELECT t.id, t.file_path, t.title, t.artist, t.album, t.album_artist, t.album_group_key, t.album_group_title, t.track_number, t.disc_number, - t.year, t.genre, t.duration_secs, t.format, t.bit_depth, t.sample_rate, + t.year, t.genre, t.genres_json, t.duration_secs, t.format, t.bit_depth, t.sample_rate, t.channels, t.file_size_bytes, t.cue_file_path, t.cue_start_secs, t.cue_end_secs, t.artwork_path, t.last_modified, t.indexed_at, t.source, t.qobuz_track_id, t.is_network_mount, plt.position @@ -4292,24 +5464,35 @@ impl LibraryDatabase { disc_number: row.get(9)?, year: row.get(10)?, genre: row.get(11)?, + genres: Self::genres_from_json( + row.get::<_, Option<String>>(12)?.as_deref(), + row.get::<_, Option<String>>(11)?.as_deref(), + ), catalog_number: None, - duration_secs: row.get(12)?, - format: Self::parse_format(&row.get::<_, String>(13)?), - bit_depth: row.get(14)?, - sample_rate: row.get::<_, f64>(15)?, - channels: row.get(16)?, - file_size_bytes: row.get(17)?, - cue_file_path: row.get(18)?, - cue_start_secs: row.get(19)?, - cue_end_secs: row.get(20)?, - artwork_path: row.get(21)?, - last_modified: row.get(22)?, - indexed_at: row.get(23)?, - source: row.get(24)?, - qobuz_track_id: row.get(25)?, - is_network_mount: row.get::<_, i64>(26)? != 0, + duration_secs: row.get::<_, i64>(13)? as u64, + format: Self::parse_format(&row.get::<_, String>(14)?), + bit_depth: row.get(15)?, + sample_rate: row.get::<_, f64>(16)?, + channels: row.get(17)?, + file_size_bytes: row.get::<_, i64>(18)? as u64, + cue_file_path: row.get(19)?, + cue_start_secs: row.get(20)?, + cue_end_secs: row.get(21)?, + artwork_path: row.get(22)?, + collection_artwork_path: None, + last_modified: row.get(23)?, + indexed_at: row.get(24)?, + source: row.get(25)?, + qobuz_track_id: row.get(26)?, + isrc: None, + musicbrainz_recording_id: None, + musicbrainz_track_id: None, + musicbrainz_release_id: None, + musicbrainz_release_group_id: None, + musicbrainz_artist_id: None, + is_network_mount: row.get::<_, i64>(27)? != 0, }, - playlist_position: row.get(27)?, + playlist_position: row.get(28)?, }) }) .map_err(|e| { @@ -4405,6 +5588,29 @@ impl LibraryDatabase { *result.entry(playlist_id).or_insert(0) += count; } + let mut remote_stmt = self + .conn + .prepare( + "SELECT qobuz_playlist_id, COUNT(*) as count + FROM playlist_remote_tracks + GROUP BY qobuz_playlist_id", + ) + .map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?; + + let remote_rows = remote_stmt + .query_map([], |row| { + let playlist_id: i64 = row.get(0)?; + let count: u32 = row.get(1)?; + Ok((playlist_id as u64, count)) + }) + .map_err(|e| LibraryError::Database(format!("Failed to query: {}", e)))?; + + for row in remote_rows { + let (playlist_id, count) = + row.map_err(|e| LibraryError::Database(format!("Failed to read row: {}", e)))?; + *result.entry(playlist_id).or_insert(0) += count; + } + Ok(result) } @@ -4464,6 +5670,7 @@ impl LibraryDatabase { ) -> Result<i32, LibraryError> { let local_count = self.get_playlist_local_track_count(qobuz_playlist_id)?; let plex_count = self.get_playlist_plex_track_count(qobuz_playlist_id)?; + let remote_count = self.get_playlist_remote_track_count(qobuz_playlist_id)?; let max_pos: Option<i32> = self .conn .query_row( @@ -4473,6 +5680,9 @@ impl LibraryDatabase { UNION ALL SELECT MAX(position) AS p FROM playlist_plex_tracks WHERE qobuz_playlist_id = ?1 + UNION ALL + SELECT MAX(position) AS p FROM playlist_remote_tracks + WHERE qobuz_playlist_id = ?1 )", params![qobuz_playlist_id as i64], |row| row.get(0), @@ -4480,7 +5690,7 @@ impl LibraryDatabase { .map_err(|e| { LibraryError::Database(format!("Failed to read max sidecar position: {}", e)) })?; - let count_based = (qobuz_track_count + local_count + plex_count) as i32; + let count_based = (qobuz_track_count + local_count + plex_count + remote_count) as i32; Ok(count_based.max(max_pos.map(|p| p + 1).unwrap_or(0))) } @@ -4562,6 +5772,33 @@ impl LibraryDatabase { rows.push(("plex", rowid, key, pos)); } } + { + let mut stmt = self + .conn + .prepare( + "SELECT id, source || ':' || item_id, position FROM playlist_remote_tracks + WHERE qobuz_playlist_id = ?1 + ORDER BY position ASC, added_at ASC, id ASC", + ) + .map_err(|e| { + LibraryError::Database(format!("Failed to prepare heal query: {}", e)) + })?; + let mapped = stmt + .query_map(params![qobuz_playlist_id as i64], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i32>(2)?, + )) + }) + .map_err(|e| LibraryError::Database(format!("Failed to query heal rows: {}", e)))?; + for r in mapped { + let (rowid, key, pos) = r.map_err(|e| { + LibraryError::Database(format!("Failed to read heal row: {}", e)) + })?; + rows.push(("remote", rowid, key, pos)); + } + } if rows.is_empty() { return Ok(Vec::new()); } @@ -4577,14 +5814,13 @@ impl LibraryDatabase { if moves.is_empty() { return Ok(Vec::new()); } - let mut next = - ((qobuz_track_count as i32) + sidecar_total as i32).max(max_pos + 1); + let mut next = ((qobuz_track_count as i32) + sidecar_total as i32).max(max_pos + 1); let mut healed = Vec::with_capacity(moves.len()); for (kind, rowid, reference, old) in moves { - let sql = if kind == "local" { - "UPDATE playlist_local_tracks SET position = ?1 WHERE id = ?2" - } else { - "UPDATE playlist_plex_tracks SET position = ?1 WHERE id = ?2" + let sql = match kind { + "local" => "UPDATE playlist_local_tracks SET position = ?1 WHERE id = ?2", + "plex" => "UPDATE playlist_plex_tracks SET position = ?1 WHERE id = ?2", + _ => "UPDATE playlist_remote_tracks SET position = ?1 WHERE id = ?2", }; self.conn.execute(sql, params![next, rowid]).map_err(|e| { LibraryError::Database(format!("Failed to heal sidecar position: {}", e)) @@ -5193,6 +6429,39 @@ impl LibraryDatabase { Ok(result) } + /// Whether an artist-image lookup (positive or negative) was completed + /// within `max_age_secs`. + /// + /// Negative rows deliberately keep `image_url` NULL. Remembering those + /// misses is what prevents every visit to a long Artists rail from + /// repeating the same Qobuz/Last.fm/Discogs requests. Custom artwork is + /// a positive answer regardless of the remote lookup timestamp. + pub fn artist_image_resolution_is_fresh( + &self, + artist_name: &str, + max_age_secs: i64, + ) -> Result<bool, LibraryError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let cutoff = now.saturating_sub(max_age_secs.max(0)); + self.conn + .query_row( + "SELECT EXISTS( + SELECT 1 FROM artist_images + WHERE artist_name = ?1 + AND (custom_image_path IS NOT NULL OR fetched_at >= ?2) + )", + params![artist_name, cutoff], + |row| row.get::<_, i64>(0), + ) + .map(|value| value != 0) + .map_err(|e| { + LibraryError::Database(format!("Failed to query artist image freshness: {e}")) + }) + } + /// Get all custom artist images (for bulk lookup) pub fn get_all_custom_artist_images( &self, @@ -5243,9 +6512,7 @@ impl LibraryDatabase { let url: Option<String> = row.get(2)?; Ok((row.get::<_, String>(0)?, custom.or(url))) }) - .map_err(|e| { - LibraryError::Database(format!("Failed to query artist images: {}", e)) - })?; + .map_err(|e| LibraryError::Database(format!("Failed to query artist images: {}", e)))?; let mut map = std::collections::HashMap::new(); for row in rows.flatten() { @@ -5314,9 +6581,18 @@ impl LibraryDatabase { .as_secs() as i64; self.conn.execute( - "INSERT OR REPLACE INTO artist_images + "INSERT INTO artist_images (artist_name, image_url, source, custom_image_path, canonical_name, fetched_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(artist_name) DO UPDATE SET + image_url = excluded.image_url, + source = excluded.source, + custom_image_path = COALESCE(excluded.custom_image_path, + artist_images.custom_image_path), + canonical_name = COALESCE(excluded.canonical_name, + artist_images.canonical_name), + fetched_at = excluded.fetched_at, + updated_at = excluded.updated_at", params![artist_name, image_url, source, custom_image_path, canonical_name, now, now], ) .map_err(|e| LibraryError::Database(format!("Failed to cache artist image: {}", e)))?; @@ -5641,8 +6917,101 @@ impl LibraryDatabase { Ok(()) } - /// Remove a downloaded purchase record (e.g. user deleted the file). - pub fn remove_downloaded_purchase(&self, track_id: i64) -> Result<(), LibraryError> { + /// Every registered download of ONE purchased track: `(format_id, + /// file_path)`, newest first. This is the playback resolver's read + /// (`purchase_playback_qt`): it decides per format and probes each path + /// itself with the bounded reachability probe, so this accessor neither + /// stats nor prunes — the prune stays with `get_downloaded_purchase_track_ids` + /// so there is exactly one writer. + pub fn get_downloaded_purchase_files( + &self, + track_id: i64, + ) -> Result<Vec<(i64, String)>, LibraryError> { + let mut stmt = self + .conn + .prepare( + "SELECT format_id, file_path FROM downloaded_purchases + WHERE track_id = ?1 ORDER BY downloaded_at DESC", + ) + .map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?; + let rows = stmt + .query_map(rusqlite::params![track_id], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| { + LibraryError::Database(format!("Failed to query downloaded purchase files: {}", e)) + })?; + rows.collect::<Result<Vec<_>, _>>() + .map_err(|e| LibraryError::Database(format!("Failed to collect purchase files: {}", e))) + } + + /// The DISTINCT folders the registered downloads of one purchased album + /// sit in (a folder per format, e.g. `…/Album [DSF][DSD128]` and + /// `…/Album [FLAC][16-bit,44.1kHz]`), newest first. No stat, no prune. + pub fn get_downloaded_purchase_folders( + &self, + album_id: &str, + ) -> Result<Vec<String>, LibraryError> { + let mut stmt = self + .conn + .prepare( + "SELECT file_path FROM downloaded_purchases + WHERE album_id = ?1 ORDER BY downloaded_at DESC", + ) + .map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?; + let paths = stmt + .query_map(rusqlite::params![album_id], |row| row.get::<_, String>(0)) + .map_err(|e| { + LibraryError::Database(format!("Failed to query purchase folders: {}", e)) + })? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| LibraryError::Database(format!("Failed to collect folders: {}", e)))?; + let mut folders: Vec<String> = Vec::new(); + for p in paths { + if let Some(dir) = std::path::Path::new(&p).parent() { + let dir = dir.to_string_lossy().into_owned(); + if !folders.contains(&dir) { + folders.push(dir); + } + } + } + Ok(folders) + } + + /// Remove ONE downloaded purchase record (e.g. the user deleted the file). + /// + /// The table's primary key is `(track_id, format_id)` — the same purchased + /// track may be downloaded in several formats at once, and the whole + /// "downloaded" UI is format-scoped because of it. This delete is therefore + /// keyed by the full pair. It previously deleted by `track_id` alone, which + /// silently dropped the OTHER formats' rows; that never fired because nothing + /// called it, and it is corrected here before Qt gives it a caller. The stale + /// prune in `get_downloaded_purchase_track_ids` already deletes by the pair. + pub fn remove_downloaded_purchase( + &self, + track_id: i64, + format_id: i64, + ) -> Result<(), LibraryError> { + self.conn + .execute( + "DELETE FROM downloaded_purchases WHERE track_id = ?1 AND format_id = ?2", + rusqlite::params![track_id, format_id], + ) + .map_err(|e| { + LibraryError::Database(format!("Failed to remove downloaded purchase: {}", e)) + })?; + Ok(()) + } + + /// Remove EVERY downloaded record for a track, across all formats. + /// + /// Split out from `remove_downloaded_purchase` so that erasing all formats is + /// something a caller has to ask for by name rather than something it gets by + /// accident from an under-specified key. + pub fn remove_downloaded_purchase_all_formats( + &self, + track_id: i64, + ) -> Result<(), LibraryError> { self.conn .execute( "DELETE FROM downloaded_purchases WHERE track_id = ?1", @@ -5654,8 +7023,79 @@ impl LibraryDatabase { Ok(()) } + /// Count DISTINCT downloaded tracks per purchased album id. + /// + /// This is what makes the Albums tab's "downloaded" mark and its + /// "Hide downloaded" filter work at all. The reference derived an album's + /// downloaded state from `album.tracks.items` in the purchases response, but + /// `getUserPurchases?type=albums` carries no nested tracks page (measured + /// against a live account, contract §2.5b), so that predicate is unsatisfiable + /// and the filter can never hide anything. The local registry is the only + /// place that knows, and `album_id` — written since the table was created and + /// never read until now — is the column that answers it. There is already an + /// index on it (`idx_downloaded_purchases_album`). + /// + /// Counting is format-AGNOSTIC (`DISTINCT track_id`), matching the reference's + /// list-level rule: a track downloaded in two formats counts once. Rows with a + /// NULL `album_id` are skipped — they cannot be attributed to an album. + /// + /// **Rows whose file no longer exists on disk are excluded**, the same way + /// `get_downloaded_purchase_track_ids` excludes them. Without that, an album + /// whose files the user deleted keeps reporting as downloaded — and, worse, + /// stays HIDDEN behind "Hide downloaded" — until some unrelated call happens + /// to prune first. Making the answer depend on which accessor ran last is the + /// kind of order-coupling nobody remembers six months later, so this query + /// stands on its own. + /// + /// Unlike the track-ids accessor this one only FILTERS; it does not delete. + /// Pruning is left to that accessor so there is exactly one writer, and a + /// read taken while a download is mid-flight cannot race it. + /// + /// The caller compares each count against the album's `tracks_count`; this + /// method deliberately does not decide "fully downloaded", because only the + /// caller holds the purchase metadata. + pub fn get_downloaded_purchase_album_counts( + &self, + ) -> Result<std::collections::HashMap<String, u32>, LibraryError> { + let mut stmt = self + .conn + .prepare( + "SELECT album_id, track_id, file_path FROM downloaded_purchases + WHERE album_id IS NOT NULL", + ) + .map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?; + + let rows: Vec<(String, i64, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .map_err(|e| { + LibraryError::Database(format!("Failed to query purchase album counts: {}", e)) + })? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| { + LibraryError::Database(format!("Failed to collect album counts: {}", e)) + })?; + + // DISTINCT over (album_id, track_id) is done here rather than in SQL so + // the existence check can drop a row before it is counted. + let mut seen: std::collections::HashSet<(String, i64)> = std::collections::HashSet::new(); + let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new(); + + for (album_id, track_id, file_path) in rows { + if probe_default(std::path::Path::new(&file_path)) != Reach::Present { + continue; + } + if seen.insert((album_id.clone(), track_id)) { + *counts.entry(album_id).or_insert(0) += 1; + } + } + + Ok(counts) + } + /// Get all downloaded track IDs for fast lookup (any format). - /// Automatically removes stale entries where the file no longer exists on disk. + /// Automatically removes stale entries only when the filesystem positively + /// answers that the file is missing. Timeouts and I/O errors are treated as + /// unreachable and never prune durable ownership/download state. pub fn get_downloaded_purchase_track_ids(&self) -> Result<Vec<i64>, LibraryError> { let mut stmt = self .conn @@ -5674,10 +7114,10 @@ impl LibraryDatabase { let mut valid_ids: Vec<i64> = Vec::new(); for (track_id, format_id, file_path) in &rows { - if std::path::Path::new(file_path).exists() { - valid_ids.push(*track_id); - } else { - stale.push((*track_id, *format_id)); + match probe_default(std::path::Path::new(file_path)) { + Reach::Present => valid_ids.push(*track_id), + Reach::Missing => stale.push((*track_id, *format_id)), + Reach::Unreachable => {} } } @@ -5718,6 +7158,143 @@ impl LibraryDatabase { } } +#[cfg(test)] +mod track_page_order_tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn artist_group_orders_by_the_track_artist_not_album_artist() { + let tmp = TempDir::new().unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + for (path, title, artist, album_artist, album) in [ + ( + "/music/zed.flac", + "First", + "Zed Performer", + "Alpha Album Artist", + "A Album", + ), + ( + "/music/alpha.flac", + "Second", + "Alpha Performer", + "Zed Album Artist", + "Z Album", + ), + ] { + let track = LocalTrack { + file_path: path.to_string(), + title: title.to_string(), + artist: artist.to_string(), + album_artist: Some(album_artist.to_string()), + album: album.to_string(), + album_group_key: path.to_string(), + album_group_title: album.to_string(), + ..Default::default() + }; + db.insert_track(&track).unwrap(); + } + + let grouped = db + .search_with_filter_page("", 0, 10, true, false, "group-artist") + .unwrap(); + assert_eq!(grouped[0].artist, "Alpha Performer"); + + let album_artist_sorted = db + .search_with_filter_page("", 0, 10, true, false, "artist-asc") + .unwrap(); + assert_eq!(album_artist_sorted[0].artist, "Zed Performer"); + } +} + +#[cfg(test)] +mod local_genre_and_filter_tests { + use super::*; + use tempfile::TempDir; + + fn fixture() -> (TempDir, LibraryDatabase) { + let tmp = TempDir::new().unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + (tmp, db) + } + + fn insert( + db: &LibraryDatabase, + name: &str, + format: AudioFormat, + depth: Option<u32>, + genres: &[&str], + ) { + let row = LocalTrack { + file_path: format!("/music/Album/{name}"), + title: name.to_string(), + artist: "Fixture Artist".into(), + album_artist: Some("Fixture Artist".into()), + album: "Fixture Album".into(), + album_group_key: "/music/Album".into(), + album_group_title: "Fixture Album".into(), + track_number: Some(if name.starts_with('a') { 1 } else { 2 }), + genre: genres.first().map(|value| value.to_string()), + genres: genres.iter().map(|value| value.to_string()).collect(), + format, + bit_depth: depth, + sample_rate: 44_100.0, + ..Default::default() + }; + db.insert_track(&row).unwrap(); + } + + #[test] + fn folder_album_unions_all_track_genres_case_insensitively() { + let (_tmp, db) = fixture(); + insert( + &db, + "a.flac", + AudioFormat::Flac, + Some(24), + &["Progressive Rock", "Art Rock"], + ); + insert( + &db, + "b.mp3", + AudioFormat::Mp3, + None, + &["art rock", "Psychedelic"], + ); + let albums = db.get_albums_with_full_filter(false, true, false).unwrap(); + assert_eq!(albums.len(), 1); + assert_eq!( + albums[0].genres, + ["Art Rock", "Progressive Rock", "Psychedelic"] + ); + } + + #[test] + fn local_format_and_quality_filters_run_before_page_limits() { + let (_tmp, db) = fixture(); + insert(&db, "a.flac", AudioFormat::Flac, Some(24), &["Rock"]); + insert(&db, "b.mp3", AudioFormat::Mp3, None, &["Rock"]); + let rows = db + .search_with_filter_page_faceted( + "", + 0, + 1, + true, + false, + "title-asc", + &["mp3".to_string()], + false, + &["lossy".to_string()], + &["local".to_string()], + ) + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].title, "b.mp3"); + assert_eq!(rows[0].genres, ["Rock"]); + } +} + #[cfg(test)] mod metadata_grouping_tests { use super::*; @@ -5778,7 +7355,14 @@ mod metadata_grouping_tests { "/m/mix/cd", ); - let albums = db.get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Metadata).unwrap(); + let albums = db + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Metadata, + ) + .unwrap(); let vespertine = albums .iter() .find(|a| a.title == "Vespertine") @@ -5793,7 +7377,14 @@ mod metadata_grouping_tests { insert_track_for_test(&db, "/m/folder/01.flac", None, None, "A", "/m/folder"); insert_track_for_test(&db, "/m/folder/02.flac", None, None, "B", "/m/folder"); - let albums = db.get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Metadata).unwrap(); + let albums = db + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Metadata, + ) + .unwrap(); assert_eq!(albums.len(), 1, "single folder fallback group"); assert_eq!(albums[0].track_count, 2); assert_eq!(albums[0].artist, "Various Artists"); @@ -5806,7 +7397,14 @@ mod metadata_grouping_tests { insert_track_for_test(&db, "/m/ghost/01.flac", None, None, "X", ""); insert_track_for_test(&db, "/m/ghost/02.flac", None, None, "Y", ""); - let albums = db.get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Metadata).unwrap(); + let albums = db + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Metadata, + ) + .unwrap(); let unknown = albums .iter() .find(|a| a.title == "Unknown Album") @@ -5835,7 +7433,14 @@ mod metadata_grouping_tests { "/m/comp", ); - let albums = db.get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Metadata).unwrap(); + let albums = db + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Metadata, + ) + .unwrap(); let comp = albums .iter() .find(|a| a.title == "Comp") @@ -5852,8 +7457,16 @@ mod metadata_grouping_tests { // album_artist. Metadata mode splits per track artist; Folder mode // keeps ONE card. for (i, artist) in [ - "MAKE-UP", "MAKE-UP PROJECT", "Horie", "Kageyama", "Furuya", - "Trooper", "Matsuzawa", "Marina", "Broadway", "Oren", + "MAKE-UP", + "MAKE-UP PROJECT", + "Horie", + "Kageyama", + "Furuya", + "Trooper", + "Matsuzawa", + "Marina", + "Broadway", + "Oren", ] .iter() .enumerate() @@ -5869,11 +7482,25 @@ mod metadata_grouping_tests { } // Metadata mode: one group per album|artist pair (the #411 split). - let albums = db.get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Metadata).unwrap(); + let albums = db + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Metadata, + ) + .unwrap(); assert_eq!(albums.len(), 10, "metadata mode splits per track artist"); // Folder mode: ONE album, Various Artists, everyone in all_artists. - let albums = db.get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Folder).unwrap(); + let albums = db + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Folder, + ) + .unwrap(); assert_eq!(albums.len(), 1, "folder mode keeps the compilation whole"); let comp = &albums[0]; assert_eq!(comp.title, "Saint Seiya Best"); @@ -5902,7 +7529,14 @@ mod metadata_grouping_tests { "EELS", "/m/eels", ); - let albums = db2.get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Folder).unwrap(); + let albums = db2 + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Folder, + ) + .unwrap(); assert_eq!(albums.len(), 1); assert_eq!(albums[0].artist, "EELS"); @@ -5910,7 +7544,14 @@ mod metadata_grouping_tests { let (_tmp3, db3) = fresh_db(); insert_track_for_test(&db3, "/m/ghost/01.flac", None, None, "X", ""); insert_track_for_test(&db3, "/m/ghost/02.flac", None, None, "Y", ""); - let albums = db3.get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Folder).unwrap(); + let albums = db3 + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Folder, + ) + .unwrap(); let unknown = albums .iter() .find(|a| a.title == "Unknown Album") @@ -6014,7 +7655,12 @@ mod metadata_grouping_tests { ); let albums = db - .get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Metadata) + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Metadata, + ) .unwrap(); let mix = albums .iter() @@ -6040,7 +7686,12 @@ mod metadata_grouping_tests { ); let albums = db - .get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Metadata) + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Metadata, + ) .unwrap(); let a = albums .iter() @@ -6087,7 +7738,12 @@ mod metadata_grouping_tests { ); let albums = db - .get_albums_metadata_grouped(false, true, false, crate::album_grouping::AlbumGroupMode::Metadata) + .get_albums_metadata_grouped( + false, + true, + false, + crate::album_grouping::AlbumGroupMode::Metadata, + ) .unwrap(); let old = albums .iter() @@ -6156,6 +7812,140 @@ mod folder_tree_tests { db.insert_track(&t).unwrap(); } + // ── §12-5: the gold purchase badge, end to end ─────────────────────── + // + // The badge chain is: a purchase download writes `downloaded_purchases` + // (file_path, format_id) → a later library scan inserts the same file → + // `insert_track` stamps `source = 'qobuz_purchase'` → the UI branches on + // that literal to draw the gold mark. + // + // The join is by EXACT `file_path` string equality, which is the whole + // reason these tests exist. Nobody here can smoke-test Purchases, and a + // path that differs by one character — a Unicode title sanitized + // differently, a trailing space from an empty quality folder — breaks the + // stamp silently: the file is there, the registry row is there, and the + // badge simply never appears with nothing logged. + + #[test] + fn a_scanned_file_matching_the_registry_is_stamped_as_a_purchase() { + let tmp = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("lib.db")).unwrap(); + + let path = "/music/Artist/Album [FLAC][24-bit,96kHz]/01 - Song.flac"; + db.mark_purchase_downloaded(1001, Some("alb-1"), path, 7) + .unwrap(); + + insert_at(&db, path, Some(1), Some(1), "Song"); + + let source: String = db + .with_connection(|conn| { + conn.query_row( + "SELECT source FROM local_tracks WHERE file_path = ?1", + rusqlite::params![path], + |row| row.get(0), + ) + .map_err(|e| LibraryError::Database(e.to_string())) + }) + .unwrap(); + + assert_eq!( + source, "qobuz_purchase", + "the scan must stamp a file that the purchase registry already knows" + ); + } + + #[test] + fn a_scanned_file_the_registry_does_not_know_stays_user() { + let tmp = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("lib.db")).unwrap(); + + insert_at( + &db, + "/music/Other/Album/01 - Song.flac", + Some(1), + Some(1), + "Song", + ); + + let source: String = db + .with_connection(|conn| { + conn.query_row( + "SELECT source FROM local_tracks WHERE file_path = ?1", + rusqlite::params!["/music/Other/Album/01 - Song.flac"], + |row| row.get(0), + ) + .map_err(|e| LibraryError::Database(e.to_string())) + }) + .unwrap(); + + assert_eq!(source, "user"); + } + + /// The failure mode the exact-equality join actually has. A registry row and + /// a scanned file that differ by ONE character — here the trailing space an + /// implementer gets by formatting the album folder as `"{album} {quality}"` + /// with an empty quality — do not join, and the badge silently never appears. + /// + /// This is a characterisation test: it asserts the join is exact, so that if + /// anyone ever makes it fuzzy they have to come here and say so deliberately. + #[test] + fn a_one_character_path_difference_breaks_the_stamp_silently() { + let tmp = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("lib.db")).unwrap(); + + db.mark_purchase_downloaded(1002, Some("alb-2"), "/music/A/Album /01 - S.flac", 6) + .unwrap(); + insert_at(&db, "/music/A/Album/01 - S.flac", Some(1), Some(1), "S"); + + let source: String = db + .with_connection(|conn| { + conn.query_row( + "SELECT source FROM local_tracks WHERE file_path = ?1", + rusqlite::params!["/music/A/Album/01 - S.flac"], + |row| row.get(0), + ) + .map_err(|e| LibraryError::Database(e.to_string())) + }) + .unwrap(); + + assert_eq!( + source, "user", + "documented: the join is exact, so a one-character path drift loses the badge" + ); + } + + /// A re-scan re-stamps. The contract records that if a scan runs BEFORE the + /// registry write the row is stamped `'user'` until the next scan of that + /// folder — this proves the "until" half, which is why no repair migration + /// or re-stamp UI is owed. + #[test] + fn a_rescan_after_the_registry_write_upgrades_the_stamp() { + let tmp = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("lib.db")).unwrap(); + let path = "/music/A/Album/01 - Late.flac"; + + // Scan first: nothing in the registry yet. + insert_at(&db, path, Some(1), Some(1), "Late"); + + // The download registers afterwards, then the folder is scanned again. + db.mark_purchase_downloaded(1003, Some("alb-3"), path, 6) + .unwrap(); + insert_at(&db, path, Some(1), Some(1), "Late"); + + let source: String = db + .with_connection(|conn| { + conn.query_row( + "SELECT source FROM local_tracks WHERE file_path = ?1", + rusqlite::params![path], + |row| row.get(0), + ) + .map_err(|e| LibraryError::Database(e.to_string())) + }) + .unwrap(); + + assert_eq!(source, "qobuz_purchase", "the next scan re-stamps it"); + } + /// Insert a row directly with `source = 'qobuz_download'`. /// `insert_track` overrides the source field so we go through raw /// SQL to model the offline-cache code path that DOES write that @@ -6195,6 +7985,81 @@ mod folder_tree_tests { insert_qobuz_download_at(db, "/m/A/album1/qcache.flac", "QobuzCache"); } + fn seed_sacd_fixture(db: &LibraryDatabase) { + let image = "/m/SACD/Opera.iso"; + db.with_connection(|conn| { + conn.execute( + "INSERT INTO local_sacd_images \ + (fingerprint,image_path,image_size_bytes,image_modified_ns,observed_at) \ + VALUES ('sacd-folder-test',?1,1,1,1)", + rusqlite::params![image], + ) + .unwrap(); + }); + for (number, title) in [(1, "Prelude / Dawn"), (2, "Finale")] { + let path = format!("sacd:{image}#{number}"); + insert_at(db, &path, Some(1), Some(number), title); + db.with_connection(|conn| { + let id: i64 = conn + .query_row( + "SELECT id FROM local_tracks WHERE file_path=?1", + rusqlite::params![path], + |row| row.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO local_sacd_tracks \ + (fingerprint,track_number,local_track_id) \ + VALUES ('sacd-folder-test',?1,?2)", + rusqlite::params![number, id], + ) + .unwrap(); + }); + } + } + + #[test] + fn sacd_image_is_an_expandable_folder_with_virtual_track_children() { + let (_tmp, db) = fresh_db(); + seed_sacd_fixture(&db); + + let root = db.list_folder_children("/m/SACD", false).unwrap(); + assert!(matches!( + root.as_slice(), + [FolderTreeEntry::Folder { + path, + segment, + track_count_under: 2, + .. + }] if path == "/m/SACD/Opera.iso" && segment == "Opera.iso" + )); + + let image = db + .list_folder_children("/m/SACD/Opera.iso", false) + .unwrap(); + assert_eq!(image.len(), 2); + assert!(matches!( + &image[0], + FolderTreeEntry::Track { path, segment } + if path == "sacd:/m/SACD/Opera.iso#1" + && segment == "001 - Prelude ∕ Dawn" + )); + assert!(db.list_folder_tracks("/m/SACD", false).unwrap().is_empty()); + assert_eq!( + db.list_folder_tracks("/m/SACD/Opera.iso", false) + .unwrap() + .len(), + 2 + ); + assert_eq!( + db.list_folder_tracks_recursive("/m/SACD", false) + .unwrap() + .len(), + 2 + ); + assert_eq!(db.count_folder_tracks_recursive("/m/SACD", false).unwrap(), 2); + } + #[test] fn list_folder_children_returns_folders_before_tracks() { let (_tmp, db) = fresh_db(); @@ -6272,7 +8137,13 @@ mod folder_tree_tests { // Folder containing a literal '%' in the filename. Without // escape_like_pattern, the '%' would behave as a wildcard and // either over-match or fail to match. - insert_at(&db, "/m/percent_test/100%.flac", Some(1), Some(1), "Hundred"); + insert_at( + &db, + "/m/percent_test/100%.flac", + Some(1), + Some(1), + "Hundred", + ); // A second literal-percent path that should NOT show up under // /m/percent_test (different parent). insert_at( @@ -6338,7 +8209,13 @@ mod folder_tree_tests { // Expected sort: disc ASC, track ASC, title ASC (NOCASE). insert_at(&db, "/m/order/disc2-track1.flac", Some(2), Some(1), "D2T1"); insert_at(&db, "/m/order/disc1-track2.flac", Some(1), Some(2), "D1T2"); - insert_at(&db, "/m/order/disc1-track1-bee.flac", Some(1), Some(1), "Bee"); + insert_at( + &db, + "/m/order/disc1-track1-bee.flac", + Some(1), + Some(1), + "Bee", + ); insert_at( &db, "/m/order/disc1-track1-ant.flac", @@ -6360,9 +8237,15 @@ mod folder_tree_tests { // /m/A/album1 has direct tracks t1.flac, t2.flac AND a deeper // file at /m/A/album1/Disc 1/t3.flac. The recursive listing // must return all three. - let tracks = db.list_folder_tracks_recursive("/m/A/album1", false).unwrap(); + let tracks = db + .list_folder_tracks_recursive("/m/A/album1", false) + .unwrap(); let titles: Vec<_> = tracks.iter().map(|track| track.title.clone()).collect(); - assert_eq!(tracks.len(), 3, "recursive listing must include subfolder tracks"); + assert_eq!( + tracks.len(), + 3, + "recursive listing must include subfolder tracks" + ); assert!(titles.contains(&"Alpha".to_string())); assert!(titles.contains(&"Beta".to_string())); assert!(titles.contains(&"Gamma".to_string())); @@ -6401,11 +8284,25 @@ mod folder_tree_tests { // Folder containing literal '_' that LIKE would otherwise treat // as a single-character wildcard. With escape_like_pattern, the // sibling /m/percentXtest must not contaminate the result set. - insert_at(&db, "/m/percent_test/100%.flac", Some(1), Some(1), "Hundred"); - insert_at(&db, "/m/percent_test/inner/200.flac", Some(1), Some(1), "TwoHundred"); + insert_at( + &db, + "/m/percent_test/100%.flac", + Some(1), + Some(1), + "Hundred", + ); + insert_at( + &db, + "/m/percent_test/inner/200.flac", + Some(1), + Some(1), + "TwoHundred", + ); insert_at(&db, "/m/percentXtest/decoy.flac", Some(1), Some(1), "Decoy"); - let tracks = db.list_folder_tracks_recursive("/m/percent_test", false).unwrap(); + let tracks = db + .list_folder_tracks_recursive("/m/percent_test", false) + .unwrap(); let titles: Vec<_> = tracks.iter().map(|track| track.title.clone()).collect(); assert_eq!(tracks.len(), 2, "underscore in parent path must be escaped"); assert!(titles.contains(&"Hundred".to_string())); @@ -6419,7 +8316,9 @@ mod folder_tree_tests { // Vec rather than an error — frontend treats empty as "nothing // to play/queue" and skips the toast. let (_tmp, db) = fresh_db(); - let tracks = db.list_folder_tracks_recursive("/m/does/not/exist", false).unwrap(); + let tracks = db + .list_folder_tracks_recursive("/m/does/not/exist", false) + .unwrap(); assert!(tracks.is_empty()); } @@ -6479,7 +8378,11 @@ mod folder_tree_tests { // --- list_folder_tracks (direct children) ------------------ let direct_all = db.list_folder_tracks("/m/net/album", false).unwrap(); - assert_eq!(direct_all.len(), 1, "net1.flac must appear when exclude=false"); + assert_eq!( + direct_all.len(), + 1, + "net1.flac must appear when exclude=false" + ); let direct_filtered = db.list_folder_tracks("/m/net/album", true).unwrap(); assert!( @@ -6493,11 +8396,13 @@ mod folder_tree_tests { // --- list_folder_tracks_recursive -------------------------- let recursive_all = db.list_folder_tracks_recursive("/m/net", false).unwrap(); - assert_eq!(recursive_all.len(), 2, "both net tracks visible when exclude=false"); + assert_eq!( + recursive_all.len(), + 2, + "both net tracks visible when exclude=false" + ); - let recursive_filtered = db - .list_folder_tracks_recursive("/m/net", true) - .unwrap(); + let recursive_filtered = db.list_folder_tracks_recursive("/m/net", true).unwrap(); assert!( recursive_filtered.is_empty(), "network tracks leaked into recursive listing when exclude=true" @@ -6505,9 +8410,7 @@ mod folder_tree_tests { // Recursive listing on a non-network root still returns its // tracks even when exclude=true. - let recursive_local = db - .list_folder_tracks_recursive("/m/local", true) - .unwrap(); + let recursive_local = db.list_folder_tracks_recursive("/m/local", true).unwrap(); assert_eq!(recursive_local.len(), 2); } @@ -6633,6 +8536,47 @@ mod sidecar_position_tests { .unwrap() } + #[test] + fn reindexing_a_track_keeps_its_rowid_so_playlists_survive_a_rescan() { + let (_tmp, db) = fresh_db(); + let ids = seed_local_tracks(&db, 1); + db.add_local_track_to_playlist(7, ids[0], 0).unwrap(); + assert_eq!(local_positions(&db, 7).len(), 1); + + // What a rescan does: the same file, re-extracted, inserted again. + let mut again = LocalTrack::default(); + again.file_path = "/t/track0.flac".into(); + again.title = "T0 (retagged)".into(); + again.artist = "A".into(); + again.album = "B".into(); + let id_after = db.insert_track(&again).unwrap(); + + assert_eq!( + id_after, ids[0], + "re-indexing must UPDATE in place; a new rowid orphans every \ + playlist_local_tracks row that points at the old one" + ); + assert_eq!( + db.get_track(ids[0]).unwrap().unwrap().title, + "T0 (retagged)", + "the row must still take the fresh metadata" + ); + + let rows = local_positions(&db, 7); + assert_eq!(rows.len(), 1, "the playlist row must survive"); + let resolvable: i64 = db + .conn + .query_row( + "SELECT COUNT(*) FROM playlist_local_tracks p + JOIN local_tracks t ON t.id = p.local_track_id + WHERE p.qobuz_playlist_id = 7", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(resolvable, 1, "and it must still resolve to a track"); + } + #[test] fn next_position_empty_sidecar_appends_after_qobuz_block() { let (_tmp, db) = fresh_db(); @@ -6739,3 +8683,482 @@ mod sidecar_position_tests { assert!(db.heal_playlist_sidecar_positions(7, 5).unwrap().is_empty()); } } + +#[cfg(test)] +mod remote_union_tests { + use super::*; + use tempfile::TempDir; + + /// A local library with one album, plus a shared remote mirror holding one + /// Jellyfin album and one Subsonic album. + fn bench() -> (TempDir, LibraryDatabase, std::path::PathBuf) { + let tmp = TempDir::new().unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + + let mut t = LocalTrack::default(); + t.file_path = "/m/local/01.flac".into(); + t.title = "Local One".into(); + t.album = "Local Album".into(); + t.album_artist = Some("Local Artist".into()); + t.artist = "Local Artist".into(); + t.album_group_key = "/m/local".into(); + t.album_group_title = "Local Album".into(); + t.duration_secs = 100; + t.format = crate::AudioFormat::Flac; + t.bit_depth = Some(16); + t.sample_rate = 44100.0; + db.insert_track(&t).unwrap(); + + // The shared mirror, written with plain SQL so this test does not + // depend on `qbz-media-cache` (which depends on nothing here, and must + // keep not depending on it). + let remote = tmp.path().join("remote_cache.db"); + let conn = rusqlite::Connection::open(&remote).unwrap(); + conn.execute_batch( + "CREATE TABLE remote_cache_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, + item_id TEXT NOT NULL, server_id TEXT NOT NULL DEFAULT '', + library_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', + artist TEXT NOT NULL DEFAULT '', album_artist TEXT NOT NULL DEFAULT '', + album TEXT NOT NULL DEFAULT '', album_id TEXT NOT NULL DEFAULT '', + track_number INTEGER, disc_number INTEGER, + duration_ms INTEGER NOT NULL DEFAULT 0, year INTEGER, genre TEXT, + container TEXT NOT NULL DEFAULT '', codec TEXT, bit_depth INTEGER, + sample_rate_hz INTEGER, channels INTEGER, bitrate_kbps INTEGER, + artwork_token TEXT, collection_artwork_token TEXT, + size_bytes INTEGER, updated_at INTEGER NOT NULL, + UNIQUE (source, item_id));", + ) + .unwrap(); + let add = |source: &str, item: &str, album: &str, album_id: &str, depth: i64| { + conn.execute( + "INSERT INTO remote_cache_tracks + (source,item_id,title,artist,album_artist,album,album_id, + duration_ms,container,bit_depth,sample_rate_hz,updated_at) + VALUES (?1,?2,?3,?4,?4,?5,?6,120000,'flac',?7,96000,1)", + rusqlite::params![ + source, + item, + format!("{item} title"), + "Remote Artist", + album, + album_id, + depth + ], + ) + .unwrap(); + }; + add("jellyfin", "jf-1", "Jellyfin Album", "jf-alb", 24); + add("jellyfin", "jf-2", "Jellyfin Album", "jf-alb", 24); + add("subsonic", "sub-1", "Subsonic Album", "sub-alb", 16); + (tmp, db, remote) + } + + fn titles( + db: &LibraryDatabase, + remote: Option<&std::path::Path>, + sources: &[&str], + ) -> Vec<String> { + let page = db + .get_albums_metadata_page( + 0, + 50, + None, + "title", + "asc", + true, + false, + None, + remote, + sources, + crate::album_grouping::AlbumGroupMode::Folder, + ) + .unwrap(); + let mut v: Vec<String> = page.albums.iter().map(|a| a.title.clone()).collect(); + v.sort(); + v + } + + /// Both mirrors' albums appear beside the local ones, in ONE result set — + /// which is the entire point of unioning rather than concatenating pages. + #[test] + fn enabled_remote_albums_join_the_local_ones() { + let (_t, db, remote) = bench(); + assert_eq!( + titles(&db, Some(&remote), &["jellyfin", "subsonic"]), + vec!["Jellyfin Album", "Local Album", "Subsonic Album"] + ); + } + + /// Turning ONE source off hides ITS rows and leaves the other's alone. The + /// mirror is shared, so this is a filter rather than a detach — and getting + /// it wrong takes the wrong server's music off the screen. + #[test] + fn disabling_one_source_leaves_the_other_visible() { + let (_t, db, remote) = bench(); + assert_eq!( + titles(&db, Some(&remote), &["subsonic"]), + vec!["Local Album", "Subsonic Album"] + ); + assert_eq!( + titles(&db, Some(&remote), &["jellyfin"]), + vec!["Jellyfin Album", "Local Album"] + ); + } + + /// No source enabled, or no mirror on disk: local-only, and NOT an error. + /// Refusing to list any albums because a server is off would turn "Jellyfin + /// is unplugged" into "your library is empty". + #[test] + fn no_enabled_sources_is_local_only_not_a_failure() { + let (_t, db, remote) = bench(); + assert_eq!(titles(&db, Some(&remote), &[]), vec!["Local Album"]); + assert_eq!(titles(&db, None, &["jellyfin"]), vec!["Local Album"]); + let missing = std::path::PathBuf::from("/nonexistent/remote_cache.db"); + assert_eq!( + titles(&db, Some(&missing), &["jellyfin"]), + vec!["Local Album"] + ); + } + + /// The two tracks of the Jellyfin album collapse into ONE card, and the + /// quality the server reported survives the aggregation. A grouping bug + /// here shows as duplicate cards, which is what the album_id key prevents. + #[test] + fn a_remote_album_aggregates_its_tracks_and_keeps_its_quality() { + let (_t, db, remote) = bench(); + let page = db + .get_albums_metadata_page( + 0, + 50, + None, + "title", + "asc", + true, + false, + None, + Some(&remote), + &["jellyfin"], + crate::album_grouping::AlbumGroupMode::Folder, + ) + .unwrap(); + let jf = page + .albums + .iter() + .find(|a| a.title == "Jellyfin Album") + .expect("the jellyfin album"); + assert_eq!( + jf.track_count, 2, + "the two tracks did not group into one card" + ); + assert_eq!(jf.bit_depth, Some(24)); + assert_eq!(jf.sample_rate, 96000.0); + assert_eq!(jf.source, "jellyfin", "the row lost its source word"); + assert_eq!(jf.sources, vec!["jellyfin"]); + assert_eq!( + jf.identity_tracks.len(), + 2, + "cross-source association evidence was not published" + ); + // The group key is PREFIXED, which is what lets the source claim the + // card when it comes back with no source word attached. + assert_eq!(jf.id, "jellyfin:jf-alb"); + assert_eq!(page.total, 2, "the count disagrees with the page it counts"); + } + + /// The interpolated filter is validated, not escaped. Anything that is not + /// plain lowercase drops out, and an all-invalid list shows NOTHING rather + /// than everything — the safe direction for a predicate that reaches SQL. + #[test] + fn the_source_filter_rejects_anything_that_is_not_a_source_word() { + assert_eq!( + remote_source_filter(&["jellyfin"]), + "source IN ('jellyfin')" + ); + assert_eq!( + remote_source_filter(&["jellyfin", "subsonic"]), + "source IN ('jellyfin', 'subsonic')" + ); + assert_eq!(remote_source_filter(&[]), "0"); + assert_eq!( + remote_source_filter(&["x'; DROP TABLE local_tracks; --"]), + "0" + ); + assert_eq!(remote_source_filter(&["Jellyfin"]), "0"); + assert_eq!(remote_source_filter(&[""]), "0"); + // One bad entry does not smuggle itself in beside a good one. + assert_eq!( + remote_source_filter(&["jellyfin", "'; --"]), + "source IN ('jellyfin')" + ); + } +} + +#[cfg(test)] +mod audio_format_roundtrip_tests { + use super::*; + + /// `parse_format` is the inverse of `AudioFormat`'s `Display`, and the + /// scanner writes exactly what `Display` produces. This asserts the pair + /// for EVERY variant rather than for one format, because the defect it + /// guards is structural: `"DSD"` had no arm, so every DSD row read back + /// as `Unknown` — the format printed "UNKNOWN", the quality badge said CD + /// and the detail said "1-bit / 2822.4 kHz". A fold to `Unknown` is a + /// VALID answer, which is why nothing else caught it. + /// + /// Adding a variant to `AudioFormat` without an arm here fails this test + /// instead of shipping the same silent loss again. + #[test] + fn every_variant_survives_the_display_parse_round_trip() { + let all = [ + AudioFormat::Flac, + AudioFormat::Alac, + AudioFormat::Wav, + AudioFormat::Aiff, + AudioFormat::Ape, + AudioFormat::Mp3, + AudioFormat::Dsd, + ]; + for f in all { + let written = f.to_string(); + let read_back = LibraryDatabase::parse_format(&written); + assert_eq!( + read_back, f, + "AudioFormat::{f:?} is stored as {written:?} and read back as \ + {read_back:?} — parse_format is missing its arm" + ); + } + } + + /// The `Unknown` fold stays reachable for genuinely unknown words; it just + /// must not be where a KNOWN format lands. + #[test] + fn an_unknown_word_still_folds_to_unknown() { + assert_eq!(LibraryDatabase::parse_format("opus"), AudioFormat::Unknown); + assert_eq!(LibraryDatabase::parse_format(""), AudioFormat::Unknown); + } + + /// The scanner's own casing must not matter (rows written by older builds). + #[test] + fn parsing_is_case_insensitive() { + assert_eq!(LibraryDatabase::parse_format("dsd"), AudioFormat::Dsd); + assert_eq!(LibraryDatabase::parse_format("Dsd"), AudioFormat::Dsd); + } +} + +#[cfg(test)] +mod metadata_editor_transaction_tests { + use super::*; + + fn update(id: i64, title: &str) -> TrackMetadataUpdateFull { + TrackMetadataUpdateFull { + id, + title: title.to_string(), + artist: "Artist".to_string(), + album: "Album".to_string(), + album_artist: Some("Artist".to_string()), + album_group_title: "Album".to_string(), + track_number: Some(1), + disc_number: Some(1), + year: Some(2026), + genre: Some("Rock".to_string()), + catalog_number: Some("CAT-1".to_string()), + } + } + + #[test] + fn metadata_and_artwork_update_only_exact_rows_and_rollback_on_stale_ids() { + let tmp = tempfile::tempdir().unwrap(); + let mut db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + let first = db + .insert_track(&LocalTrack { + file_path: "/music/album/01.flac".to_string(), + title: "One".to_string(), + artist: "Artist".to_string(), + album: "Album".to_string(), + album_group_key: "/music/album".to_string(), + album_group_title: "Album".to_string(), + artwork_path: Some("/art/disc-one.jpg".to_string()), + ..LocalTrack::default() + }) + .unwrap(); + let second = db + .insert_track(&LocalTrack { + file_path: "/music/album/02.flac".to_string(), + title: "Two".to_string(), + artist: "Artist".to_string(), + album: "Album".to_string(), + album_group_key: "/music/album".to_string(), + album_group_title: "Album".to_string(), + artwork_path: Some("/art/disc-two.jpg".to_string()), + ..LocalTrack::default() + }) + .unwrap(); + + db.update_tracks_metadata_and_artwork_by_id( + &[update(first, "Edited")], + Some("/art/new.jpg"), + ) + .unwrap(); + let first_row: (String, String) = db + .conn + .query_row( + "SELECT title, artwork_path FROM local_tracks WHERE id = ?1", + params![first], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + let second_art: String = db + .conn + .query_row( + "SELECT artwork_path FROM local_tracks WHERE id = ?1", + params![second], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + first_row, + ("Edited".to_string(), "/art/new.jpg".to_string()) + ); + assert_eq!(second_art, "/art/disc-two.jpg"); + + let error = db.update_tracks_metadata_and_artwork_by_id( + &[update(first, "Must Roll Back"), update(i64::MAX, "Missing")], + Some("/art/should-not-land.jpg"), + ); + assert!(error.is_err()); + let after: (String, String) = db + .conn + .query_row( + "SELECT title, artwork_path FROM local_tracks WHERE id = ?1", + params![first], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(after, first_row); + } +} + +#[cfg(test)] +mod artist_image_cache_tests { + use super::*; + + #[test] + fn remote_refresh_preserves_custom_art_and_negative_results_are_fresh() { + let tmp = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + + db.cache_artist_image_with_canonical( + "Beyonce", + None, + "custom", + Some("/pictures/beyonce.jpg"), + Some("Beyoncé"), + ) + .unwrap(); + db.cache_artist_image_with_canonical( + "Beyonce", + Some("https://cdn.example/beyonce.jpg"), + "qobuz", + None, + None, + ) + .unwrap(); + let image = db.get_artist_image("Beyonce").unwrap().unwrap(); + assert_eq!( + image.custom_image_path.as_deref(), + Some("/pictures/beyonce.jpg") + ); + assert_eq!(image.canonical_name.as_deref(), Some("Beyoncé")); + + db.cache_artist_image_with_canonical("No Portrait", None, "miss", None, None) + .unwrap(); + assert!(db + .artist_image_resolution_is_fresh("No Portrait", 60) + .unwrap()); + assert!(db + .get_artist_image("No Portrait") + .unwrap() + .unwrap() + .image_url + .is_none()); + } +} + +#[cfg(all(test, unix))] +mod purchase_registry_reachability_tests { + use super::*; + use std::os::unix::fs::symlink; + + #[test] + fn an_io_error_never_prunes_a_purchase_row() { + let tmp = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + let loop_path = tmp.path().join("loop"); + symlink(&loop_path, &loop_path).unwrap(); + db.mark_purchase_downloaded(42, Some("album"), loop_path.to_str().unwrap(), 6) + .unwrap(); + + assert!(db.get_downloaded_purchase_track_ids().unwrap().is_empty()); + let remaining: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM downloaded_purchases", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(remaining, 1, "I/O errors are unreachable, never missing"); + crate::reachability::reset_cooldowns(); + } +} + +#[cfg(test)] +mod schema_init_concurrency_tests { + use super::*; + use std::sync::{Arc, Barrier}; + + #[test] + fn concurrent_opens_serialize_a_legacy_schema_migration() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("library.db"); + let legacy = Connection::open(&path).unwrap(); + legacy + .execute_batch( + "CREATE TABLE local_sacd_images ( + fingerprint TEXT PRIMARY KEY, + image_path TEXT NOT NULL UNIQUE, + image_size_bytes INTEGER NOT NULL, + image_modified_ns INTEGER NOT NULL, + observed_at INTEGER NOT NULL + );", + ) + .unwrap(); + drop(legacy); + + const THREADS: usize = 8; + let barrier = Arc::new(Barrier::new(THREADS)); + let handles = (0..THREADS) + .map(|_| { + let path = path.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + LibraryDatabase::open(&path).map(drop) + }) + }) + .collect::<Vec<_>>(); + for handle in handles { + handle.join().unwrap().unwrap(); + } + + let db = Connection::open(&path).unwrap(); + let columns: i64 = db + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('local_sacd_images') + WHERE name='parser_revision'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(columns, 1); + } +} diff --git a/crates/qbz-library/src/ephemeral.rs b/crates/qbz-library/src/ephemeral.rs index c6c727831..fae507c06 100644 --- a/crates/qbz-library/src/ephemeral.rs +++ b/crates/qbz-library/src/ephemeral.rs @@ -32,9 +32,35 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Mutex; -use crate::{cue_to_tracks, CueParser, LibraryError, LibraryScanner, LocalTrack, MetadataExtractor}; +use crate::{ + cue_to_tracks, AlbumTagSidecar, CueParser, LibraryError, LibraryScanner, LocalTrack, + MetadataExtractor, +}; use serde::Serialize; +fn should_expand_cue(cue: &crate::CueSheet) -> bool { + cue.is_single_file_image() +} + +fn apply_sidecar_override( + track: &mut LocalTrack, + cache: &mut HashMap<PathBuf, Option<AlbumTagSidecar>>, +) { + let own_directory = Path::new(&track.file_path).parent().map(Path::to_path_buf); + let grouped_directory = (!track.album_group_key.trim().is_empty()) + .then(|| PathBuf::from(&track.album_group_key)) + .filter(|path| path.is_dir()); + for directory in grouped_directory.into_iter().chain(own_directory) { + let sidecar = cache + .entry(directory.clone()) + .or_insert_with(|| crate::read_album_sidecar(&directory).unwrap_or(None)); + if let Some(sidecar) = sidecar.as_ref() { + crate::apply_sidecar_to_track(track, sidecar); + return; + } + } +} + /// Floor for synthetic ephemeral track ids. Any id at or above this /// value is an ephemeral track; below it is a DB row id. Set high /// enough to be impossible to collide with autoincrement DB ids in any @@ -145,6 +171,7 @@ impl EphemeralLibraryState { // share the same parent directory. let mut album_artwork_cache: HashMap<String, Option<String>> = HashMap::new(); let mut folder_artwork_cache: HashMap<PathBuf, Option<String>> = HashMap::new(); + let mut sidecar_cache: HashMap<PathBuf, Option<AlbumTagSidecar>> = HashMap::new(); // Audio files referenced by CUE sheets. We index those audio files // through the CUE path (one logical "album" file gets exploded @@ -156,6 +183,20 @@ impl EphemeralLibraryState { for cue_path in &scan.cue_files { match CueParser::parse(cue_path) { Ok(mut cue) => { + // A multi-file CUE is a sidecar for audio that has + // already been split (usually one FILE per TRACK). The + // regular audio loop below is authoritative in that + // layout. Expanding it as though it were one image would + // duplicate every row and bind the virtual entries to an + // arbitrary backing file. + if !should_expand_cue(&cue) { + log::info!( + "[ephemeral] treating multi-file CUE as sidecar ({} files): {}", + cue.audio_file_count(), + cue_path.display() + ); + continue; + } let audio_path_raw = Path::new(&cue.audio_file).to_path_buf(); let canonical = std::fs::canonicalize(&audio_path_raw) .unwrap_or_else(|_| audio_path_raw.clone()); @@ -214,7 +255,7 @@ impl EphemeralLibraryState { let mut cue_tracks = cue_to_tracks(&cue, properties.duration_secs, format, &properties); if cue_tracks.is_empty() { - log::warn!("[ephemeral] CUE produced no tracks: {}", cue_path.display()); + log::warn!("[ephemeral] CUE produced no tracks"); skipped_files += 1; continue; } @@ -235,9 +276,10 @@ impl EphemeralLibraryState { let mut found = MetadataExtractor::extract_artwork(&canonical, &artwork_cache); if found.is_none() { - if let Some(folder_art) = - MetadataExtractor::find_folder_artwork(&canonical, cue.title.as_deref()) - { + if let Some(folder_art) = MetadataExtractor::find_folder_artwork( + &canonical, + cue.title.as_deref(), + ) { found = MetadataExtractor::cache_artwork_file( Path::new(&folder_art), &artwork_cache, @@ -249,6 +291,7 @@ impl EphemeralLibraryState { }; for mut track in cue_tracks.drain(..) { + apply_sidecar_override(&mut track, &mut sidecar_cache); track.id = inner.next_id; inner.next_id += 1; track.source = Some("ephemeral".to_string()); @@ -259,19 +302,77 @@ impl EphemeralLibraryState { cue_referenced_audio.insert(canonical); } Err(e) => { - log::warn!("[ephemeral] failed to parse CUE {}: {}", cue_path.display(), e); + log::warn!("[ephemeral] failed to parse CUE: {}", e); skipped_files += 1; } } } - for audio_file in &scan.audio_files { + // ── THE FILE READS RUN IN PARALLEL; EVERYTHING ELSE STAYS SERIAL ── + // + // Opening a folder of 247 FLACs on a NAS took ~15 s between "Scanned" + // and "ephemeral opened" (owner's log, 2026-08-22), on what is meant to + // be the QUICK way to play something outside the library. The cost is + // not computation: it is 247 sequential `canonicalize` + tag-read round + // trips over the network, each a few tens of milliseconds, one after + // another. + // + // So only the READS move. The bookkeeping below — the CUE-dedup check, + // the APE skip, id assignment, the artwork caches, the push order — is + // untouched and still runs in scan order, because ids and ordering are + // observable and a parallel loop must not renumber anything. + // + // `std::thread::scope`, not a new dependency: this is I/O-bound, the + // work items are independent, and borrowing `scan.audio_files` across + // the scope needs no Arc. + let t_probe = std::time::Instant::now(); + let probed: Vec<(std::path::PathBuf, Result<LocalTrack, LibraryError>)> = { + let files: &[std::path::PathBuf] = &scan.audio_files; + let workers = std::thread::available_parallelism() + .map(|n| n.get().min(8)) + .unwrap_or(4) + .max(1); + let chunk = files.len().div_ceil(workers).max(1); + let mut out: Vec<Vec<(std::path::PathBuf, Result<LocalTrack, LibraryError>)>> = + Vec::new(); + std::thread::scope(|sc| { + let handles: Vec<_> = files + .chunks(chunk) + .map(|part| { + sc.spawn(move || { + part.iter() + .map(|f| { + let canonical = + std::fs::canonicalize(f).unwrap_or_else(|_| f.clone()); + (canonical, MetadataExtractor::extract(f)) + }) + .collect::<Vec<_>>() + }) + }) + .collect(); + for h in handles { + // A panicking worker must not take the whole open down: the + // chunk is dropped and its files are simply absent, which + // the counters below already report as skipped. + out.push(h.join().unwrap_or_default()); + } + }); + out.into_iter().flatten().collect() + }; + + log::info!( + "[ephemeral][perf] parallel tag reads: {:?} for {} files", + t_probe.elapsed(), + probed.len() + ); + let t_rest = std::time::Instant::now(); + for (audio_file, (canonical_audio, extracted)) in + scan.audio_files.iter().zip(probed.into_iter()) + { // Skip audio files that were already exploded into tracks via // a CUE sheet — listing them again as a single row would // duplicate the album and confuse playback (the CUE-derived // track ids are the canonical ones). - let canonical_audio = - std::fs::canonicalize(audio_file).unwrap_or_else(|_| audio_file.clone()); if cue_referenced_audio.contains(&canonical_audio) { continue; } @@ -293,8 +394,9 @@ impl EphemeralLibraryState { skipped_files += 1; continue; } - match MetadataExtractor::extract(audio_file) { + match extracted { Ok(mut track) => { + apply_sidecar_override(&mut track, &mut sidecar_cache); track.id = inner.next_id; inner.next_id += 1; track.source = Some("ephemeral".to_string()); @@ -354,6 +456,11 @@ impl EphemeralLibraryState { } } + log::info!( + "[ephemeral][perf] serial bookkeeping (artwork + ids): {:?}", + t_rest.elapsed() + ); + // Musical order (album, then disc/track/title — same as the DB-backed // folder view): the extraction order above is readdir order, which is // arbitrary. Ids must FOLLOW the display order because @@ -396,6 +503,96 @@ impl EphemeralLibraryState { }) } + /// Install a track list that did NOT come from scanning a directory — a + /// CD in the drive, and later a disc image. + /// + /// `open_folder` cannot serve these: it takes a `&Path`, rejects anything + /// that is not a real directory (`:118`), and derives every field by + /// reading files off a filesystem. A disc has no filesystem to read. + /// + /// `label` is what the medium is CALLED (the album title, or "Audio CD"), + /// and it is stored where a folder path would be, because everything + /// downstream — the pane header, the tab, the persisted-session check — + /// asks the session what it is, not where it lives. + /// + /// Ids are assigned from the SAME synthetic range as a folder session, so + /// every playback caller keeps routing them to this store without knowing + /// a disc exists. + pub fn open_tracks( + &self, + label: &str, + tracks: Vec<LocalTrack>, + ) -> Result<EphemeralFolderResult, EphemeralError> { + let mut inner = self.inner.lock().map_err(|_| EphemeralError::Lock)?; + inner.reset(); + let mut out = Vec::with_capacity(tracks.len()); + for mut track in tracks { + track.id = inner.next_id; + inner.next_id += 1; + track.source = Some("ephemeral".to_string()); + inner.tracks.insert(track.id, track.clone()); + out.push(track); + } + inner.current_folder_path = Some(label.to_string()); + Ok(EphemeralFolderResult { + folder_path: label.to_string(), + tracks: out, + skipped_files: 0, + }) + } + + /// Swap the stored rows for an updated copy, KEEPING their ids. + /// + /// Used when something about the session changes after it opened — the + /// cover arriving late is the case that motivated it. Re-running + /// `open_tracks` would renumber every row from the floor, and the queue + /// may already hold the old ids: a playing track would lose its source + /// mid-song. + pub fn replace_tracks_preserving_ids( + &self, + tracks: &[LocalTrack], + ) -> Result<(), EphemeralError> { + let mut inner = self.inner.lock().map_err(|_| EphemeralError::Lock)?; + for t in tracks { + // Only rows this session already knows; an unknown id here means + // the caller built its list from something else. + if inner.tracks.contains_key(&t.id) { + inner.tracks.insert(t.id, t.clone()); + } + } + Ok(()) + } + + /// Replace a bounded subset only while the same folder/disc session is + /// still open. The exact id/path check prevents a draft from a dismissed + /// editor from mutating a later session that reused the synthetic ids. + pub fn replace_tracks_for_session( + &self, + expected_folder_path: &str, + tracks: &[LocalTrack], + ) -> Result<Option<Vec<LocalTrack>>, EphemeralError> { + let mut inner = self.inner.lock().map_err(|_| EphemeralError::Lock)?; + if inner.current_folder_path.as_deref() != Some(expected_folder_path) { + return Ok(None); + } + for track in tracks { + let Some(current) = inner.tracks.get(&track.id) else { + return Ok(None); + }; + if current.file_path != track.file_path + || current.cue_start_secs != track.cue_start_secs + { + return Ok(None); + } + } + for track in tracks { + inner.tracks.insert(track.id, track.clone()); + } + let mut snapshot = inner.tracks.values().cloned().collect::<Vec<_>>(); + snapshot.sort_by_key(|track| track.id); + Ok(Some(snapshot)) + } + pub fn clear(&self) { if let Ok(mut inner) = self.inner.lock() { inner.reset(); @@ -433,3 +630,101 @@ impl Default for EphemeralLibraryState { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn track_at(path: &Path) -> LocalTrack { + LocalTrack { + file_path: path.to_string_lossy().into_owned(), + title: "Before".to_string(), + album: "Before album".to_string(), + album_group_title: "Before album".to_string(), + artist: "Artist".to_string(), + ..LocalTrack::default() + } + } + + #[test] + fn ephemeral_loader_only_expands_single_image_cues() { + let temp = tempfile::tempdir().unwrap(); + let single_path = temp.path().join("single.cue"); + fs::write( + &single_path, + "FILE \"album.flac\" WAVE\n\ + TRACK 01 AUDIO\n\ + INDEX 01 00:00:00\n\ + TRACK 02 AUDIO\n\ + INDEX 01 03:00:00\n", + ) + .unwrap(); + let multi_path = temp.path().join("split.cue"); + fs::write( + &multi_path, + "FILE \"01. First.flac\" WAVE\n\ + TRACK 01 AUDIO\n\ + INDEX 01 00:00:00\n\ + FILE \"02. Second.flac\" WAVE\n\ + TRACK 02 AUDIO\n\ + INDEX 01 00:00:00\n", + ) + .unwrap(); + + assert!(should_expand_cue(&CueParser::parse(&single_path).unwrap())); + assert!(!should_expand_cue(&CueParser::parse(&multi_path).unwrap())); + } + + #[test] + fn sidecar_overrides_are_visible_to_ephemeral_scans() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("01.flac"); + let sidecar = crate::AlbumTagSidecar::new( + crate::AlbumMetadataOverride { + album_title: Some("After album".to_string()), + album_artist: Some("After artist".to_string()), + ..crate::AlbumMetadataOverride::default() + }, + vec![crate::TrackMetadataOverride { + file_path: file.to_string_lossy().into_owned(), + cue_start_secs: None, + title: Some("After".to_string()), + disc_number: Some(1), + track_number: Some(1), + }], + ); + crate::write_album_sidecar(temp.path(), &sidecar).unwrap(); + + let mut track = track_at(&file); + let mut cache = HashMap::new(); + apply_sidecar_override(&mut track, &mut cache); + + assert_eq!(track.album, "After album"); + assert_eq!(track.album_artist.as_deref(), Some("After artist")); + assert_eq!(track.title, "After"); + } + + #[test] + fn stale_editor_snapshot_cannot_retarget_a_new_ephemeral_session() { + let state = EphemeralLibraryState::new(); + let first = state + .open_tracks("first", vec![track_at(Path::new("/music/first.flac"))]) + .unwrap(); + let mut edited = first.tracks; + edited[0].title = "Edited".to_string(); + assert!(state + .replace_tracks_for_session("first", &edited) + .unwrap() + .is_some()); + + state + .open_tracks("second", vec![track_at(Path::new("/music/second.flac"))]) + .unwrap(); + assert!(state + .replace_tracks_for_session("first", &edited) + .unwrap() + .is_none()); + assert_eq!(state.tracks_snapshot()[0].title, "Before"); + } +} diff --git a/crates/qbz-library/src/folder_registration.rs b/crates/qbz-library/src/folder_registration.rs new file mode 100644 index 000000000..f983c47cb --- /dev/null +++ b/crates/qbz-library/src/folder_registration.rs @@ -0,0 +1,190 @@ +//! Canonical, typed registration for Local Library roots. + +use crate::{is_network_path, network_fs_label, LibraryDatabase}; +use rusqlite::{params, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "kebab-case")] +pub enum RegisterFolderOutcome { + Added { folder_id: i64 }, + Refreshed { folder_id: i64 }, + Covered { folder_id: i64 }, + RegisteredDisabled { folder_id: i64 }, + Conflict, + Failed, +} + +impl RegisterFolderOutcome { + pub fn scan_folder_id(&self) -> Option<i64> { + match self { + Self::Added { folder_id } + | Self::Refreshed { folder_id } + | Self::Covered { folder_id } => Some(*folder_id), + Self::RegisteredDisabled { .. } | Self::Conflict | Self::Failed => None, + } + } +} + +impl LibraryDatabase { + /// Register a canonical root, or identify the exact existing root that + /// should be refreshed. Path comparisons are component-wise (`Path`), not + /// string-prefix comparisons. + pub fn register_or_refresh_folder(&self, path: &Path) -> RegisterFolderOutcome { + let Ok(candidate) = path.canonicalize() else { + return RegisterFolderOutcome::Failed; + }; + if !candidate.is_dir() { + return RegisterFolderOutcome::Failed; + } + + let rows = match self.get_folders_with_metadata() { + Ok(rows) => rows, + Err(_) => return RegisterFolderOutcome::Failed, + }; + let existing: Vec<_> = rows + .iter() + .map(|row| (row, PathBuf::from(&row.path))) + .collect(); + + if let Some((row, _)) = existing.iter().find(|(_, root)| root == &candidate) { + return if row.enabled { + RegisterFolderOutcome::Refreshed { folder_id: row.id } + } else { + RegisterFolderOutcome::RegisteredDisabled { folder_id: row.id } + }; + } + + if let Some((row, _)) = existing + .iter() + .filter(|(_, root)| candidate.starts_with(root)) + .max_by_key(|(_, root)| root.components().count()) + { + return if row.enabled { + RegisterFolderOutcome::Covered { folder_id: row.id } + } else { + RegisterFolderOutcome::RegisteredDisabled { folder_id: row.id } + }; + } + + if existing + .iter() + .any(|(_, root)| root.starts_with(&candidate)) + { + return RegisterFolderOutcome::Conflict; + } + + let canonical = candidate.to_string_lossy().into_owned(); + let is_network = is_network_path(&candidate); + let fs_label = is_network.then(|| network_fs_label(&candidate)).flatten(); + self.with_connection(|conn| { + let inserted = match conn.execute( + "INSERT OR IGNORE INTO library_folders + (path, is_network, network_fs_type) VALUES (?1, ?2, ?3)", + params![canonical, is_network as i32, fs_label], + ) { + Ok(count) => count, + Err(_) => return RegisterFolderOutcome::Failed, + }; + let row = conn + .query_row( + "SELECT id, enabled FROM library_folders WHERE path = ?1", + [canonical], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)? != 0)), + ) + .optional(); + match row { + Ok(Some((folder_id, _))) if inserted > 0 => { + RegisterFolderOutcome::Added { folder_id } + } + Ok(Some((folder_id, true))) => RegisterFolderOutcome::Refreshed { folder_id }, + Ok(Some((folder_id, false))) => { + RegisterFolderOutcome::RegisteredDisabled { folder_id } + } + _ => RegisterFolderOutcome::Failed, + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_and_descendant_paths_reuse_one_root() { + let tmp = tempfile::tempdir().unwrap(); + let music = tmp.path().join("music"); + let album = music.join("Artist").join("Album"); + std::fs::create_dir_all(&album).unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + + let RegisterFolderOutcome::Added { folder_id } = db.register_or_refresh_folder(&music) + else { + panic!("first registration must add"); + }; + assert_eq!( + db.register_or_refresh_folder(&music), + RegisterFolderOutcome::Refreshed { folder_id } + ); + assert_eq!( + db.register_or_refresh_folder(&album), + RegisterFolderOutcome::Covered { folder_id } + ); + assert_eq!(db.get_folders_with_metadata().unwrap().len(), 1); + } + + #[test] + fn path_prefix_siblings_are_not_ancestors() { + let tmp = tempfile::tempdir().unwrap(); + let music = tmp.path().join("music"); + let music_archive = tmp.path().join("music-archive"); + std::fs::create_dir_all(&music).unwrap(); + std::fs::create_dir_all(&music_archive).unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + assert!(matches!( + db.register_or_refresh_folder(&music), + RegisterFolderOutcome::Added { .. } + )); + assert!(matches!( + db.register_or_refresh_folder(&music_archive), + RegisterFolderOutcome::Added { .. } + )); + } + + #[test] + fn parent_of_an_existing_root_is_a_conflict() { + let tmp = tempfile::tempdir().unwrap(); + let parent = tmp.path().join("music"); + let child = parent.join("collection"); + std::fs::create_dir_all(&child).unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + assert!(matches!( + db.register_or_refresh_folder(&child), + RegisterFolderOutcome::Added { .. } + )); + assert_eq!( + db.register_or_refresh_folder(&parent), + RegisterFolderOutcome::Conflict + ); + } + + #[test] + fn disabled_roots_are_never_reenabled_silently() { + let tmp = tempfile::tempdir().unwrap(); + let music = tmp.path().join("music"); + std::fs::create_dir_all(&music).unwrap(); + let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap(); + let RegisterFolderOutcome::Added { folder_id } = db.register_or_refresh_folder(&music) + else { + panic!("first registration must add"); + }; + db.set_folder_enabled(folder_id, false).unwrap(); + assert_eq!( + db.register_or_refresh_folder(&music), + RegisterFolderOutcome::RegisteredDisabled { folder_id } + ); + assert!(!db.get_folder_by_id(folder_id).unwrap().unwrap().enabled); + } +} diff --git a/crates/qbz-library/src/lib.rs b/crates/qbz-library/src/lib.rs index 894fa9dfc..3351f1a29 100644 --- a/crates/qbz-library/src/lib.rs +++ b/crates/qbz-library/src/lib.rs @@ -32,17 +32,25 @@ pub mod album_grouping; mod cue_parser; mod database; pub mod ephemeral; -pub mod local_playlists; -pub mod qobuz_playlist_snapshot; mod errors; +mod folder_registration; +pub mod local_playlists; mod metadata; mod models; mod mount_info; +mod purchase_copies; +pub mod playlist_membership; +pub mod qobuz_playlist_snapshot; +pub mod reachability; +mod remote_tag_sidecar; +mod sacd; +pub mod sacd_scan; mod scan; mod scanner; -mod tag_writer; mod tag_sidecar; +mod tag_writer; mod thumbnails; +mod watcher; // Re-exports pub use cue_parser::{cue_to_tracks, CueParser, CueSheet, CueTime, CueTrack}; @@ -51,23 +59,46 @@ pub use database::{ PlaylistFolder, PlaylistSettings, PlaylistStats, TrackMetadataUpdateFull, }; pub use errors::LibraryError; +pub use folder_registration::RegisterFolderOutcome; pub use metadata::MetadataExtractor; pub use models::*; pub use mount_info::{is_network_path, network_fs_label}; +pub use purchase_copies::{ + PurchaseAlbumPlaybackPreference, PurchaseCopyHealth, PurchaseCopySnapshot, + PurchaseDownloadCopy, PurchaseDownloadCopyTrack, PurchasePlaybackMode, PurchaseTrackHealth, +}; +pub use reachability::{ + probe, probe_default, probe_file, probe_file_default, FileProbe, Reach, DEFAULT_PROBE, +}; +pub use sacd::{SacdImageImport, SacdImportResult}; +pub use sacd_scan::{ + build_image_rows, SacdImageRows, SacdLabels, SacdScanSummary, SACD_PARSER_REVISION, +}; pub use scan::{scan_with_progress, ScanEvent}; +pub use scanner::{ + LibraryScanner, ScanEntry, ScanFileKind, ScanResult, ScanStream, ScanWalkError, SymlinkPolicy, +}; pub use tag_writer::{ - compute_track_artist_match, write_album_tags_to_files, AlbumTagWrite, TrackTagWrite, + compute_track_artist_match, inspect_album_tag_layers, read_editor_tag_snapshots, + write_album_tags_to_files, write_album_tags_to_files_extended, + write_album_tags_to_files_with_options, write_folder_front_cover, write_purchase_tags, + AlbumTagInspection, AlbumTagWrite, DirectTagWriteOptions, EditorTrackTagSnapshot, + ExtendedAlbumTagWrite, ExtendedTrackTagWrite, FrontCoverWrite, Id3v2WriteVersion, + PurchaseTagWrite, TagLayerInspection, TrackTagWrite, }; -pub use scanner::{LibraryScanner, ScanResult}; pub use thumbnails::{ clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size, + get_or_generate_large_thumbnail, get_or_generate_large_thumbnail_from_bytes, get_or_generate_thumbnail, get_thumbnail_path, get_thumbnails_dir, thumbnail_exists, + LARGE_ART_PX, }; +pub use watcher::{LocalRootWatcher, RootWatchEvent}; // Re-export database module for backwards compatibility pub mod database_exports { pub use crate::database::*; } +pub use remote_tag_sidecar::*; pub use tag_sidecar::*; use std::path::PathBuf; diff --git a/crates/qbz-library/src/local_playlists.rs b/crates/qbz-library/src/local_playlists.rs index ff6d1e025..3dc69ecaf 100644 --- a/crates/qbz-library/src/local_playlists.rs +++ b/crates/qbz-library/src/local_playlists.rs @@ -23,11 +23,18 @@ pub fn is_local_playlist_id(id: &str) -> bool { } /// Track source inside a local playlist. +/// +/// Jellyfin/Subsonic rows (2026-08-30) store the SERVER ITEM ID in the +/// `local_path` column — the same convention their library projection uses +/// for `file_path`: that column is "the source-native locator", not always a +/// filesystem path. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LocalPlaylistTrackSource { Qobuz, Local, Plex, + Jellyfin, + Subsonic, } impl LocalPlaylistTrackSource { @@ -36,12 +43,16 @@ impl LocalPlaylistTrackSource { Self::Qobuz => "qobuz", Self::Local => "local", Self::Plex => "plex", + Self::Jellyfin => "jellyfin", + Self::Subsonic => "subsonic", } } fn parse(s: &str) -> Self { match s { "local" => Self::Local, "plex" => Self::Plex, + "jellyfin" => Self::Jellyfin, + "subsonic" => Self::Subsonic, _ => Self::Qobuz, } } @@ -86,12 +97,15 @@ pub struct LocalPlaylistTrack { pub added_at: i64, } -/// Input for `add_tracks` — exactly one of the three refs per source. +/// Input for `add_tracks` — exactly one source-native ref per variant. +/// Jellyfin/Subsonic carry the server item id (stored in `local_path`). #[derive(Debug, Clone)] pub enum LocalPlaylistTrackInput { Qobuz(u64), Local(String), Plex(String), + Jellyfin(String), + Subsonic(String), } fn now_ms() -> i64 { @@ -154,6 +168,19 @@ pub fn init_schema(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_local_playlist_tracks_playlist ON local_playlist_tracks(playlist_id, position); + + -- Inverse direction for the Add-to-Playlist picker's containment + -- question (playlist_membership.rs). Partial: each row fills exactly + -- one of the three identity columns. + CREATE INDEX IF NOT EXISTS idx_local_playlist_tracks_qobuz + ON local_playlist_tracks(qobuz_track_id, playlist_id) + WHERE qobuz_track_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_local_playlist_tracks_path + ON local_playlist_tracks(local_path, playlist_id) + WHERE local_path IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_local_playlist_tracks_plex + ON local_playlist_tracks(plex_key, playlist_id) + WHERE plex_key IS NOT NULL; "#, )?; // Additive migration (B3): DBs created before the favorite/hidden @@ -331,7 +358,12 @@ fn hydrate_counts(conn: &Connection, p: &mut LocalPlaylist) -> Result<()> { match LocalPlaylistTrackSource::parse(&source) { LocalPlaylistTrackSource::Qobuz => p.qobuz_count = count, LocalPlaylistTrackSource::Local => p.local_count = count, - LocalPlaylistTrackSource::Plex => p.plex_count = count, + // The three server-backed sources share one bucket: every + // consumer of these counts distinguishes "on this machine" from + // "needs a server", not server brands. + LocalPlaylistTrackSource::Plex + | LocalPlaylistTrackSource::Jellyfin + | LocalPlaylistTrackSource::Subsonic => p.plex_count += count, } p.track_count += count; } @@ -431,6 +463,12 @@ pub fn add_tracks( LocalPlaylistTrackInput::Qobuz(id) => ("qobuz", Some(*id as i64), None, None), LocalPlaylistTrackInput::Local(path) => ("local", None, Some(path.as_str()), None), LocalPlaylistTrackInput::Plex(key) => ("plex", None, None, Some(key.as_str())), + LocalPlaylistTrackInput::Jellyfin(item) => { + ("jellyfin", None, Some(item.as_str()), None) + } + LocalPlaylistTrackInput::Subsonic(item) => { + ("subsonic", None, Some(item.as_str()), None) + } }; let exists: bool = conn .prepare( diff --git a/crates/qbz-library/src/metadata.rs b/crates/qbz-library/src/metadata.rs index 74cc3c433..681746694 100644 --- a/crates/qbz-library/src/metadata.rs +++ b/crates/qbz-library/src/metadata.rs @@ -1,5 +1,6 @@ //! Metadata extraction for audio files +use lofty::config::ParseOptions; use lofty::prelude::*; use lofty::probe::Probe; use lofty::tag::ItemKey; @@ -42,13 +43,30 @@ impl MetadataExtractor { /// First non-empty string for `key` across all of the file's tags /// (primary first). When several tags disagree, the primary tag wins — /// deterministic, and matches what other players show. - fn string_across_tags( - tagged_file: &lofty::file::TaggedFile, - key: &ItemKey, - ) -> Option<String> { + fn string_across_tags(tagged_file: &lofty::file::TaggedFile, key: &ItemKey) -> Option<String> { Self::string_from_tags(Self::tags_primary_first(tagged_file), key) } + /// Every non-empty value for `key` across all tags, preserving primary-tag + /// order and deduplicating case-insensitively. This is intentionally + /// separate from the first-value fallback used by scalar metadata. + fn strings_across_tags(tagged_file: &lofty::file::TaggedFile, key: &ItemKey) -> Vec<String> { + let mut values = Vec::<String>::new(); + for tag in Self::tags_primary_first(tagged_file) { + for value in tag.get_strings(key.clone()) { + let value = value.trim(); + if !value.is_empty() + && !values + .iter() + .any(|existing| existing.eq_ignore_ascii_case(value)) + { + values.push(value.to_string()); + } + } + } + values + } + /// Pure core of [`Self::string_across_tags`]: first non-empty, trimmed /// value for `key` yielded by the tag iterator (already in priority /// order). The empty check lives INSIDE the find_map so a blank value in @@ -177,6 +195,92 @@ impl MetadataExtractor { } fn is_disc_folder(name: &str) -> bool { + Self::is_bare_disc_folder(name) || Self::is_titled_disc_folder(name) + } + + /// `Disc 01 - TV Series Soundtrack #01`, `CD2 – Bonus Material`: a disc + /// folder that also names the disc. Box sets are shipped this way, and + /// without this arm every disc becomes its own album, because + /// `album_root_dir` only climbs past folders this predicate accepts. + /// + /// Disjoint from the #147 false positives by construction. Those are album + /// titles that END with a disc token (`Now 75 - CD1`, + /// `100 Popular Classics, Disc 1`, `Relaxation Disc1`); here the + /// designator must be the FIRST thing in the name. The separator must have + /// whitespace on one side, so `CD-Rom Favourites` is not a disc folder + /// either — an accidental merge of two albums is worse than the split this + /// fixes, so the rule stays narrow. + /// `strip_disc_suffix` for callers outside this crate — the disc-suffix + /// rule is the one that decides whether a tag NAMES a disc or merely + /// NUMBERS it, and a second copy of it elsewhere would drift. + pub fn strip_disc_suffix_public(title: &str) -> String { + Self::strip_disc_suffix(title) + } + + /// The TITLE half of a titled disc folder — "Disc 1 - Rheingold" -> "Rheingold". + /// + /// `is_titled_disc_folder` already computes this split and throws the tail + /// away; this returns it. PURELY ADDITIVE: nothing about grouping changes. + /// In particular `strip_disc_suffix` and `album_root_dir` are untouched — + /// they are what make a box set fold into ONE album, there is a regression + /// test pinning that, and a per-disc LABEL must not be bought by breaking + /// it. + pub fn disc_title_from_name(name: &str) -> Option<String> { + let trimmed = name.trim(); + let bytes = trimmed.as_bytes(); + + let mut split = None; + for (idx, ch) in trimmed.char_indices() { + if !matches!(ch, '-' | '\u{2013}' | '\u{2014}' | ':') { + continue; + } + let space_before = idx > 0 && bytes[idx - 1].is_ascii_whitespace(); + let after = idx + ch.len_utf8(); + let space_after = trimmed[after..].starts_with(char::is_whitespace); + if space_before || space_after { + split = Some((idx, after)); + break; + } + } + let (head_end, tail_start) = split?; + let head = trimmed[..head_end].trim(); + let tail = trimmed[tail_start..].trim(); + if tail.is_empty() || !Self::is_bare_disc_folder(head) { + return None; + } + Some(tail.to_string()) + } + + fn is_titled_disc_folder(name: &str) -> bool { + let trimmed = name.trim(); + let bytes = trimmed.as_bytes(); + + let mut split = None; + for (idx, ch) in trimmed.char_indices() { + if !matches!(ch, '-' | '\u{2013}' | '\u{2014}' | ':') { + continue; + } + let space_before = idx > 0 && bytes[idx - 1].is_ascii_whitespace(); + let after = idx + ch.len_utf8(); + let space_after = trimmed[after..].starts_with(char::is_whitespace); + if space_before || space_after { + split = Some((idx, after)); + break; + } + } + + let Some((head_end, tail_start)) = split else { + return false; + }; + + let head = trimmed[..head_end].trim(); + let tail = trimmed[tail_start..].trim(); + + // `Disc 1 -` with nothing after it is already the bare form. + !tail.is_empty() && Self::is_bare_disc_folder(head) + } + + fn is_bare_disc_folder(name: &str) -> bool { let lower = name.to_lowercase(); let tokens: Vec<&str> = lower .split(|c: char| !c.is_ascii_alphanumeric()) @@ -496,7 +600,7 @@ impl MetadataExtractor { file_path: &Path, library_roots: &[PathBuf], ) -> Result<LocalTrack, LibraryError> { - log::debug!("Extracting metadata from: {}", file_path.display()); + log::debug!("Extracting audio metadata"); // DSD containers aren't lofty-readable: qbz-dsd demuxes them (tech // props + embedded ID3v2 for DSF; trailing ID3 for DFF when present). @@ -504,9 +608,26 @@ impl MetadataExtractor { return Self::extract_dsd(file_path, library_roots); } - // Probe the file + // Probe the file — WITHOUT pulling the embedded cover art. + // + // MEASURED 2026-08-22 on a 247-file box set on a NAS: reading tags + // took 9.27 s of a 9.30 s open, while everything else — artwork + // caching, ids, grouping, sorting — took 29 ms. And parallelising the + // reads across 8 threads had bought only 1.6x, which is the signature + // of a bandwidth wall rather than a latency one. + // + // A FLAC's cover art lives INSIDE the tag, so `read()` was pulling + // multiple megabytes of picture data per file across the network to + // recover a title and a track number. The pictures are not wanted + // here: this function fills a row, and the cover is resolved + // separately by `extract_artwork` / `find_folder_artwork` for ONE file + // per album (13 of 247 in that box). + // + // `read_properties` stays on — duration, bit depth and sample rate + // come from the header and are cheap. let tagged_file = Probe::open(file_path) .map_err(|e| LibraryError::Metadata(format!("Failed to open file: {}", e)))? + .options(ParseOptions::new().read_cover_art(false)) .read() .map_err(|e| LibraryError::Metadata(format!("Failed to read file: {}", e)))?; @@ -571,6 +692,7 @@ impl MetadataExtractor { .unwrap_or_else(|| "Unknown Album".to_string()); let (album_group_key, album_group_title) = Self::album_group_info(file_path, Some(album_title.as_str())); + let genres = Self::strings_across_tags(&tagged_file, &ItemKey::Genre); LocalTrack { id: 0, @@ -590,7 +712,8 @@ impl MetadataExtractor { .and_then(|d| if d > 0 { Some(d) } else { None }) .or(inferred_disc), year: Self::year_across_tags(&tagged_file), - genre: Self::string_across_tags(&tagged_file, &ItemKey::Genre), + genre: genres.first().cloned(), + genres, catalog_number: Self::string_across_tags(&tagged_file, &ItemKey::CatalogNumber), duration_secs, format, @@ -602,6 +725,7 @@ impl MetadataExtractor { cue_start_secs: None, cue_end_secs: None, artwork_path: None, + collection_artwork_path: None, last_modified, indexed_at: SystemTime::now() .duration_since(UNIX_EPOCH) @@ -609,6 +733,31 @@ impl MetadataExtractor { .unwrap_or(0), source: None, qobuz_track_id: None, + // Identity tags as Picard writes them (lofty maps + // MUSICBRAINZ_TRACKID -> MusicBrainzRecordingId and + // MUSICBRAINZ_RELEASETRACKID -> MusicBrainzTrackId; ISRC/TSRC + // -> Isrc). Same cross-tag fallback as every other field. + isrc: Self::string_across_tags(&tagged_file, &ItemKey::Isrc), + musicbrainz_recording_id: Self::string_across_tags( + &tagged_file, + &ItemKey::MusicBrainzRecordingId, + ), + musicbrainz_track_id: Self::string_across_tags( + &tagged_file, + &ItemKey::MusicBrainzTrackId, + ), + musicbrainz_release_id: Self::string_across_tags( + &tagged_file, + &ItemKey::MusicBrainzReleaseId, + ), + musicbrainz_release_group_id: Self::string_across_tags( + &tagged_file, + &ItemKey::MusicBrainzReleaseGroupId, + ), + musicbrainz_artist_id: Self::string_across_tags( + &tagged_file, + &ItemKey::MusicBrainzArtistId, + ), is_network_mount: false, } } else { @@ -632,6 +781,7 @@ impl MetadataExtractor { disc_number: inferred_disc, year: None, genre: None, + genres: Vec::new(), catalog_number: None, duration_secs, format, @@ -643,6 +793,7 @@ impl MetadataExtractor { cue_start_secs: None, cue_end_secs: None, artwork_path: None, + collection_artwork_path: None, last_modified, indexed_at: SystemTime::now() .duration_since(UNIX_EPOCH) @@ -650,6 +801,12 @@ impl MetadataExtractor { .unwrap_or(0), source: None, qobuz_track_id: None, + isrc: None, + musicbrainz_recording_id: None, + musicbrainz_track_id: None, + musicbrainz_release_id: None, + musicbrainz_release_group_id: None, + musicbrainz_artist_id: None, is_network_mount: false, } }; @@ -691,6 +848,7 @@ impl MetadataExtractor { .unwrap_or_else(|| "Unknown Album".to_string()); let (album_group_key, album_group_title) = Self::album_group_info(file_path, Some(album_title.as_str())); + let genres = tags.genre.iter().cloned().collect(); Ok(LocalTrack { id: 0, @@ -709,6 +867,7 @@ impl MetadataExtractor { disc_number: tags.disc_number.filter(|d| *d > 0).or(inferred_disc), year: tags.year.and_then(|y| u32::try_from(y).ok()), genre: tags.genre.clone(), + genres, catalog_number: None, duration_secs: info.duration_secs(), format: AudioFormat::Dsd, @@ -722,6 +881,7 @@ impl MetadataExtractor { cue_start_secs: None, cue_end_secs: None, artwork_path: None, + collection_artwork_path: None, last_modified, indexed_at: SystemTime::now() .duration_since(UNIX_EPOCH) @@ -729,6 +889,12 @@ impl MetadataExtractor { .unwrap_or(0), source: None, qobuz_track_id: None, + isrc: None, + musicbrainz_recording_id: None, + musicbrainz_track_id: None, + musicbrainz_release_id: None, + musicbrainz_release_group_id: None, + musicbrainz_artist_id: None, is_network_mount: false, }) } @@ -747,8 +913,11 @@ impl MetadataExtractor { }); } + // Properties only — the embedded cover is megabytes of pure waste on + // this path (see the note in `extract_with_roots`). let tagged_file = Probe::open(file_path) .map_err(|e| LibraryError::Metadata(format!("Failed to open file: {}", e)))? + .options(ParseOptions::new().read_cover_art(false)) .read() .map_err(|e| LibraryError::Metadata(format!("Failed to read file: {}", e)))?; @@ -790,7 +959,7 @@ impl MetadataExtractor { return match generate_thumbnail_from_bytes(&art, &cache_key) { Ok(thumbnail_path) => Some(thumbnail_path.to_string_lossy().to_string()), Err(e) => { - log::warn!("Failed to generate DSD thumbnail for {:?}: {}", file_path, e); + log::warn!("Failed to generate DSD thumbnail: {}", e); None } }; @@ -808,12 +977,33 @@ impl MetadataExtractor { match generate_thumbnail_from_bytes(picture.data(), &cache_key) { Ok(thumbnail_path) => Some(thumbnail_path.to_string_lossy().to_string()), Err(e) => { - log::warn!("Failed to generate thumbnail for {:?}: {}", file_path, e); + log::warn!("Failed to generate embedded-art thumbnail: {}", e); None } } } + /// The RAW embedded picture bytes of an audio file (first picture, the + /// same pick `extract_artwork` makes), WITHOUT the 500px thumbnail + /// encode. This is the re-open-the-source seam for the on-demand large + /// art tier (contract `2026-08-15-immersive-completion` 04 §5): the DB + /// keeps only the 500px thumb path, so a big slot re-derives its bounded + /// 1600px JPEG from these bytes. DSD containers are opened through + /// `qbz_dsd` exactly like `extract_artwork`. + pub fn extract_artwork_bytes(file_path: &Path) -> Option<Vec<u8>> { + if qbz_dsd::is_dsd_path(file_path) { + let demux = qbz_dsd::open_dsd(file_path).ok()?; + return demux.info().tags.artwork.clone(); + } + + let tagged_file = Probe::open(file_path).ok()?.read().ok()?; + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag())?; + + tag.pictures().first().map(|p| p.data().to_vec()) + } + /// Generate thumbnail from an existing artwork file pub fn cache_artwork_file(artwork_path: &Path, _cache_dir: &Path) -> Option<String> { if !artwork_path.is_file() { @@ -823,13 +1013,90 @@ impl MetadataExtractor { match generate_thumbnail(artwork_path) { Ok(thumbnail_path) => Some(thumbnail_path.to_string_lossy().to_string()), Err(e) => { - log::warn!("Failed to generate thumbnail for {:?}: {}", artwork_path, e); + log::warn!("Failed to generate folder-art thumbnail: {}", e); + None + } + } + } + + /// Generate a stable thumbnail for artwork bytes whose source may be a + /// temporary editor staging file. The content hash is part of the cache + /// key, so replacing an album cover cannot reuse the thumbnail generated + /// for the previous image at the same audio or folder path. + pub fn cache_artwork_bytes(bytes: &[u8]) -> Option<String> { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + bytes.hash(&mut hasher); + let cache_key = format!("metadata-editor-artwork-{:016x}", hasher.finish()); + match generate_thumbnail_from_bytes(bytes, &cache_key) { + Ok(thumbnail_path) => Some(thumbnail_path.to_string_lossy().to_string()), + Err(error) => { + log::warn!("Failed to generate edited-art thumbnail: {}", error); None } } } /// Look for folder artwork by file name heuristics + /// A cover living in THIS directory, ignoring the album root. + /// + /// `find_folder_artwork` deliberately PREFERS the album root — it gives + /// index 0 a `dir_bonus` of 5 and compares with a strict `>`, so + /// `Box/cover.jpg` (105) beats `Box/Disc 01 - Name/cover.jpg` (100) on + /// every track of every disc. That is right for the album's own cover and + /// exactly wrong for a per-DISC one: it is why a box set whose discs each + /// ship their own art still resolved to one shared image on all of them. + /// + /// So this is the un-biased sibling, used only where the caller already + /// knows it is asking about one disc's folder. It does NOT touch the scan + /// path and changes no stored value. + pub fn folder_artwork_in_dir(dir: &Path) -> Option<String> { + let mut best: Option<(u32, PathBuf)> = None; + let mut candidates = Vec::new(); + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + if !Self::is_supported_artwork_ext(&ext) { + continue; + } + candidates.push(path.clone()); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + // Same vocabulary the scanner scores with, simplified: an exact + // well-known name wins and a name containing one is a fallback. + const EXACT: &[&str] = &["cover", "folder", "front", "album", "artwork", "art"]; + let score = if EXACT.contains(&stem.as_str()) { + 2 + } else if EXACT.iter().any(|k| stem.contains(k)) { + 1 + } else { + continue; + }; + if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) { + best = Some((score, path)); + } + } + best.map(|(_, p)| p.to_string_lossy().to_string()) + .or_else(|| { + // A single image in a disc directory is unambiguous even when + // it is named after the movement rather than `cover.jpg`. + (candidates.len() == 1) + .then(|| candidates[0].to_string_lossy().to_string()) + }) + } + pub fn find_folder_artwork( audio_file_path: &Path, album_title: Option<&str>, @@ -1197,6 +1464,70 @@ mod tests { assert!(!MetadataExtractor::is_disc_folder("Abbey Road")); } + #[test] + fn test_is_disc_folder_titled() { + // Box sets name the disc after the designator. Every one of these used + // to become its own album, in the DEFAULT (folder) grouping mode. + assert!(MetadataExtractor::is_disc_folder( + "Disc 01 - TV Series Soundtrack #01" + )); + assert!(MetadataExtractor::is_disc_folder("CD1 - Bonus Material")); + assert!(MetadataExtractor::is_disc_folder("Disc 2 \u{2013} Live")); + assert!(MetadataExtractor::is_disc_folder("Disc 3: The Remixes")); + assert!(MetadataExtractor::is_disc_folder( + "Bonus Disc - Rarities 1994-1999" + )); + assert!(MetadataExtractor::is_disc_folder("cd 04 - instrumentals")); + } + + #[test] + fn test_is_disc_folder_titled_stays_narrow() { + // The designator must LEAD. These are the issue #147 shapes with a + // separator in them, and they must stay album titles. + assert!(!MetadataExtractor::is_disc_folder("Now 75 - CD1")); + assert!(!MetadataExtractor::is_disc_folder( + "Match of the Day - The Album CD1" + )); + assert!(!MetadataExtractor::is_disc_folder( + "Greatest Hits - Disc 1 of 2 Collection" + )); + // No whitespace beside the separator: a hyphenated word, not a title. + assert!(!MetadataExtractor::is_disc_folder("CD-Rom Favourites")); + // A leading disc word with no separator at all. + assert!(!MetadataExtractor::is_disc_folder("Disc Jockey Anthems")); + // Nothing after the separator is the bare form, not the titled one. + assert!(!MetadataExtractor::is_titled_disc_folder("Disc 1 -")); + } + + #[test] + fn test_album_root_dir_titled_disc_folders_group_as_one() { + // A 13-disc box set: every disc directory must resolve to the SAME + // album root, or the library shows 13 albums. + let box_root = Path::new("/music/Seiji Yokoyama/Saint Seiya Eternal CD-Box"); + let first = MetadataExtractor::album_root_dir(Path::new( + "/music/Seiji Yokoyama/Saint Seiya Eternal CD-Box/Disc 01 - TV Series Soundtrack #01/01. Pegasus Fantasy.flac", + )) + .unwrap(); + let last = MetadataExtractor::album_root_dir(Path::new( + "/music/Seiji Yokoyama/Saint Seiya Eternal CD-Box/Disc 13 - Complete Song Collection 3/07. Blue Forever.flac", + )) + .unwrap(); + assert_eq!(first, box_root); + assert_eq!(last, box_root); + } + + #[test] + fn test_disc_number_recovered_from_titled_disc_folder() { + assert_eq!( + MetadataExtractor::disc_number_from_name("Disc 01 - TV Series Soundtrack #01"), + Some(1) + ); + assert_eq!( + MetadataExtractor::disc_number_from_name("CD13 - Complete Song Collection 3"), + Some(13) + ); + } + #[test] fn test_album_root_dir_album_with_disc_in_name() { // Issue #147: album names containing "Disc N" should NOT be treated as disc folders diff --git a/crates/qbz-library/src/models.rs b/crates/qbz-library/src/models.rs index ac2d00c52..d9f102e5f 100644 --- a/crates/qbz-library/src/models.rs +++ b/crates/qbz-library/src/models.rs @@ -53,6 +53,10 @@ pub struct LocalTrack { pub disc_number: Option<u32>, pub year: Option<u32>, pub genre: Option<String>, + /// Complete ordered genre set. `genre` remains the compatibility primary + /// value for old databases and single-value tag writers. + #[serde(default)] + pub genres: Vec<String>, pub catalog_number: Option<String>, // Audio properties @@ -70,6 +74,10 @@ pub struct LocalTrack { // Artwork pub artwork_path: Option<String>, + /// Album/collection artwork kept apart from a track or disc cover. + /// Consumers resolve artwork from the most specific layer outward. + #[serde(default)] + pub collection_artwork_path: Option<String>, // Indexing pub last_modified: i64, @@ -87,6 +95,23 @@ pub struct LocalTrack { /// which is common under Flatpak / Snap sandboxes. #[serde(default)] pub is_network_mount: bool, + + // Cross-source identity, straight from the file's tags (Picard writes + // them; lofty reads them). `None` when the file is untagged. The + // recording id + ISRC are the JOIN keys the listen log stamps so a play + // of a local file and a play of the same recording on Qobuz can meet. + #[serde(default)] + pub isrc: Option<String>, + #[serde(default)] + pub musicbrainz_recording_id: Option<String>, + #[serde(default)] + pub musicbrainz_track_id: Option<String>, + #[serde(default)] + pub musicbrainz_release_id: Option<String>, + #[serde(default)] + pub musicbrainz_release_group_id: Option<String>, + #[serde(default)] + pub musicbrainz_artist_id: Option<String>, } impl Default for LocalTrack { @@ -104,6 +129,7 @@ impl Default for LocalTrack { disc_number: None, year: None, genre: None, + genres: Vec::new(), catalog_number: None, duration_secs: 0, format: AudioFormat::Unknown, @@ -115,10 +141,17 @@ impl Default for LocalTrack { cue_start_secs: None, cue_end_secs: None, artwork_path: None, + collection_artwork_path: None, last_modified: 0, indexed_at: 0, source: None, qobuz_track_id: None, + isrc: None, + musicbrainz_recording_id: None, + musicbrainz_track_id: None, + musicbrainz_release_id: None, + musicbrainz_release_group_id: None, + musicbrainz_artist_id: None, is_network_mount: false, } } @@ -154,7 +187,16 @@ pub struct LocalAlbum { pub all_artists: String, pub year: Option<u32>, pub catalog_number: Option<String>, + /// Union of every track/version genre represented by this album. + #[serde(default)] + pub genres: Vec<String>, pub artwork_path: Option<String>, + /// Authoritative source that owns `artwork_path` when an album row is a + /// frontend-side logical group. It may differ from `source`: the + /// best-audio copy can be coverless while another physical copy supplies + /// the group's representative artwork. + #[serde(default)] + pub artwork_source: Option<String>, pub track_count: u32, pub total_duration_secs: u64, pub format: AudioFormat, @@ -171,6 +213,23 @@ pub struct LocalAlbum { /// Source of the album: "user" for local files, "qobuz_download" for offline cached #[serde(default = "default_source")] pub source: String, + /// Distinct authoritative sources contributing physical copies to this + /// logical row. A legacy row has one entry; the Qt aggregation may fold + /// several strongly-associated copies into one card without losing their + /// provenance. + #[serde(default)] + pub sources: Vec<String>, + /// Content evidence used to associate copies conservatively across + /// sources. This is derived from authoritative rows on every read and is + /// never persisted as product identity. + #[serde(default)] + pub identity_tracks: Vec<AlbumTrackEvidence>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AlbumTrackEvidence { + pub title: String, + pub duration_secs: u64, } fn default_source() -> String { diff --git a/crates/qbz-library/src/playlist_membership.rs b/crates/qbz-library/src/playlist_membership.rs new file mode 100644 index 000000000..7955ba55e --- /dev/null +++ b/crates/qbz-library/src/playlist_membership.rs @@ -0,0 +1,671 @@ +//! The inverse membership question: WHICH playlists already hold this track? +//! +//! The playlist tables were all indexed forward (`playlist_id` leading); +//! the Add-to-Playlist picker asks the opposite direction. This module owns +//! the typed track reference at that boundary — replacing the loosely encoded +//! `"plex:<key>"` / `"<i64>"` strings — and the containment query across all +//! four membership stores: +//! +//! - `qobuz_playlist_snapshot_tracks` — Qobuz tracks in Qobuz playlists +//! (hydrated by the membership worker; honesty via `index_state`); +//! - `playlist_local_tracks` / `playlist_plex_tracks` — library/Plex sidecars +//! attached to Qobuz playlists (authoritative locally, complete by +//! construction: QBZ is their only writer); +//! - `local_playlist_tracks` — everything inside `local:` playlists (same: +//! locally authoritative). +//! +//! Identity notes. Plex rows are keyed by rating key (their merge keeps it +//! in `file_path`), Jellyfin/Subsonic rows by their source-qualified server +//! item id (`playlist_remote_tracks`, and `local_playlist_tracks.local_path` +//! under their own `source` value); both key spaces are scoped to the single +//! configured server per protocol, the same assumption the sidecar tables +//! have always made. Everything else that lives in the library is its +//! projected row (`track_id` + path). + +use std::collections::{HashMap, HashSet}; + +use rusqlite::{Connection, Result}; + +/// One selected track at the picker/index boundary. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PlaylistTrackRef { + /// A Qobuz catalog track. + Qobuz(u64), + /// A library-projected row: a local file, or a Jellyfin/Subsonic track + /// (their playlist storage identity is the projection). `path` is the + /// `local_tracks.file_path` value local playlists key on; + /// `qobuz_track_id` is set for offline copies (`qobuz_download` rows), + /// which local playlists store as CATALOG ids while Qobuz-playlist + /// sidecars store the library row — containment must look both ways. + Library { + track_id: i64, + path: Option<String>, + qobuz_track_id: Option<u64>, + }, + /// A Plex track by rating key. + Plex { rating_key: String }, + /// A Jellyfin/Subsonic track by its server item id (their projected rows + /// carry it in `file_path`; playlists store it source-qualified). + Remote { source: String, item_id: String }, +} + +impl PlaylistTrackRef { + /// Build the ref for a library row the way the playlist writers key it: + /// Plex rows carry the rating key in `file_path`, Jellyfin/Subsonic rows + /// their server item id; every other source is the projected library row + /// itself (offline copies also carrying their catalog id). + pub fn from_library_row( + source: &str, + track_id: i64, + file_path: &str, + qobuz_track_id: Option<u64>, + ) -> Self { + match source { + "plex" => Self::Plex { + rating_key: file_path.to_string(), + }, + "jellyfin" | "subsonic" => Self::Remote { + source: source.to_string(), + item_id: file_path.to_string(), + }, + _ => Self::Library { + track_id, + path: Some(file_path.to_string()), + qobuz_track_id: qobuz_track_id.filter(|_| source == "qobuz_download"), + }, + } + } +} + +/// A playlist that contains at least one of the selected refs. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ContainmentTarget { + Qobuz(u64), + Local(String), +} + +#[derive(Debug, Clone)] +pub struct PlaylistContainment { + pub target: ContainmentTarget, + /// How many DISTINCT selected refs this playlist holds (the picker's + /// "N of M"). Duplicate rows inside a playlist count once. + pub contained: u32, +} + +/// SQLite's default variable cap is 999; stay far under it. +const IN_CHUNK: usize = 400; + +fn placeholders(n: usize) -> String { + let mut s = String::with_capacity(n * 2); + for i in 0..n { + if i > 0 { + s.push(','); + } + s.push('?'); + } + s +} + +/// Every writable playlist containing at least one of `refs`, with the +/// distinct-ref count. Qobuz targets come from the membership snapshot +/// restricted to OWNED, ACTIVE playlists (the strip offers add targets, and a +/// retired header must not resurrect there) plus the sidecar tables; `local:` +/// targets from `local_playlist_tracks`. Whether the Qobuz half may be +/// presented as definitive is `qobuz_playlist_snapshot::index_state`'s call, +/// not this function's. +pub fn playlists_containing( + conn: &Connection, + refs: &[PlaylistTrackRef], +) -> Result<Vec<PlaylistContainment>> { + // ordinal = position in `refs`; contained counts distinct ordinals. + let mut qobuz_ids: Vec<(i64, usize)> = Vec::new(); + let mut lib_ids: Vec<(i64, usize)> = Vec::new(); + let mut lib_paths: Vec<(&str, usize)> = Vec::new(); + let mut plex_keys: Vec<(&str, usize)> = Vec::new(); + let mut remote_items: Vec<(&str, &str, usize)> = Vec::new(); + for (ordinal, r) in refs.iter().enumerate() { + match r { + PlaylistTrackRef::Qobuz(id) => qobuz_ids.push((*id as i64, ordinal)), + PlaylistTrackRef::Library { + track_id, + path, + qobuz_track_id, + } => { + lib_ids.push((*track_id, ordinal)); + if let Some(p) = path { + lib_paths.push((p.as_str(), ordinal)); + } + // Offline copies live in local playlists as catalog ids. + if let Some(qid) = qobuz_track_id { + qobuz_ids.push((*qid as i64, ordinal)); + } + } + PlaylistTrackRef::Plex { rating_key } => plex_keys.push((rating_key.as_str(), ordinal)), + PlaylistTrackRef::Remote { source, item_id } => { + remote_items.push((source.as_str(), item_id.as_str(), ordinal)) + } + } + } + + let mut hits: HashMap<ContainmentTarget, HashSet<usize>> = HashMap::new(); + let mut collect_qobuz = |rows: Vec<(i64, usize)>| { + for (pid, ordinal) in rows { + hits.entry(ContainmentTarget::Qobuz(pid as u64)) + .or_default() + .insert(ordinal); + } + }; + + // Qobuz refs → snapshot membership of owned active playlists. + for chunk in qobuz_ids.chunks(IN_CHUNK) { + let sql = format!( + "SELECT t.qobuz_playlist_id, t.track_id + FROM qobuz_playlist_snapshot_tracks t + JOIN qobuz_playlist_snapshot h + ON h.qobuz_playlist_id = t.qobuz_playlist_id + WHERE h.is_owned = 1 AND h.inactive = 0 + AND t.track_id IN ({})", + placeholders(chunk.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(chunk.iter().map(|(id, _)| *id)), + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)), + )?; + let mut found = Vec::new(); + for row in rows { + let (pid, tid) = row?; + for (id, ordinal) in chunk { + if *id == tid { + found.push((pid, *ordinal)); + } + } + } + collect_qobuz(found); + } + + // Library refs → local-track sidecars on Qobuz playlists. + for chunk in lib_ids.chunks(IN_CHUNK) { + let sql = format!( + "SELECT qobuz_playlist_id, local_track_id FROM playlist_local_tracks + WHERE local_track_id IN ({})", + placeholders(chunk.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(chunk.iter().map(|(id, _)| *id)), + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)), + )?; + let mut found = Vec::new(); + for row in rows { + let (pid, tid) = row?; + for (id, ordinal) in chunk { + if *id == tid { + found.push((pid, *ordinal)); + } + } + } + collect_qobuz(found); + } + + // Plex refs → Plex sidecars on Qobuz playlists. + for chunk in plex_keys.chunks(IN_CHUNK) { + let sql = format!( + "SELECT qobuz_playlist_id, plex_rating_key FROM playlist_plex_tracks + WHERE plex_rating_key IN ({})", + placeholders(chunk.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(chunk.iter().map(|(key, _)| *key)), + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)), + )?; + let mut found = Vec::new(); + for row in rows { + let (pid, key) = row?; + for (k, ordinal) in chunk { + if *k == key { + found.push((pid, *ordinal)); + } + } + } + collect_qobuz(found); + } + + // Remote refs → the Jellyfin/Subsonic sidecars on Qobuz playlists, + // source-qualified (the sources come from a fixed vocabulary, so the + // literal in the SQL is safe). + for source in ["jellyfin", "subsonic"] { + let items: Vec<(&str, usize)> = remote_items + .iter() + .filter(|(s, _, _)| *s == source) + .map(|(_, item, ordinal)| (*item, *ordinal)) + .collect(); + for chunk in items.chunks(IN_CHUNK) { + let sql = format!( + "SELECT qobuz_playlist_id, item_id FROM playlist_remote_tracks + WHERE source = '{source}' AND item_id IN ({})", + placeholders(chunk.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(chunk.iter().map(|(item, _)| *item)), + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)), + )?; + let mut found = Vec::new(); + for row in rows { + let (pid, item) = row?; + for (i, ordinal) in &chunk[..] { + if *i == item { + found.push((pid, *ordinal)); + } + } + } + collect_qobuz(found); + } + } + + // All three identity columns of local playlists. + let mut collect_local = |rows: Vec<(String, usize)>| { + for (pid, ordinal) in rows { + hits.entry(ContainmentTarget::Local(pid)) + .or_default() + .insert(ordinal); + } + }; + for chunk in qobuz_ids.chunks(IN_CHUNK) { + let sql = format!( + "SELECT playlist_id, qobuz_track_id FROM local_playlist_tracks + WHERE qobuz_track_id IN ({})", + placeholders(chunk.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(chunk.iter().map(|(id, _)| *id)), + |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)), + )?; + let mut found = Vec::new(); + for row in rows { + let (pid, tid) = row?; + for (id, ordinal) in chunk { + if *id == tid { + found.push((pid.clone(), *ordinal)); + } + } + } + collect_local(found); + } + for chunk in lib_paths.chunks(IN_CHUNK) { + // source-qualified: remote rows store their server item id in the + // same column, and a filesystem path must never match one. + let sql = format!( + "SELECT playlist_id, local_path FROM local_playlist_tracks + WHERE source = 'local' AND local_path IN ({})", + placeholders(chunk.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(chunk.iter().map(|(p, _)| *p)), + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + )?; + let mut found = Vec::new(); + for row in rows { + let (pid, path) = row?; + for (p, ordinal) in chunk { + if *p == path { + found.push((pid.clone(), *ordinal)); + } + } + } + collect_local(found); + } + for chunk in plex_keys.chunks(IN_CHUNK) { + let sql = format!( + "SELECT playlist_id, plex_key FROM local_playlist_tracks + WHERE plex_key IN ({})", + placeholders(chunk.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(chunk.iter().map(|(k, _)| *k)), + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + )?; + let mut found = Vec::new(); + for row in rows { + let (pid, key) = row?; + for (k, ordinal) in chunk { + if *k == key { + found.push((pid.clone(), *ordinal)); + } + } + } + collect_local(found); + } + for source in ["jellyfin", "subsonic"] { + let items: Vec<(&str, usize)> = remote_items + .iter() + .filter(|(s, _, _)| *s == source) + .map(|(_, item, ordinal)| (*item, *ordinal)) + .collect(); + for chunk in items.chunks(IN_CHUNK) { + let sql = format!( + "SELECT playlist_id, local_path FROM local_playlist_tracks + WHERE source = '{source}' AND local_path IN ({})", + placeholders(chunk.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(chunk.iter().map(|(item, _)| *item)), + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + )?; + let mut found = Vec::new(); + for row in rows { + let (pid, item) = row?; + for (i, ordinal) in &chunk[..] { + if *i == item { + found.push((pid.clone(), *ordinal)); + } + } + } + collect_local(found); + } + } + + let mut out: Vec<PlaylistContainment> = hits + .into_iter() + .map(|(target, ordinals)| PlaylistContainment { + target, + contained: ordinals.len() as u32, + }) + .collect(); + // Deterministic order: fullest first, then stable by identity. + out.sort_by(|a, b| { + b.contained.cmp(&a.contained).then_with(|| { + let key = |t: &ContainmentTarget| match t { + ContainmentTarget::Qobuz(id) => (0u8, id.to_string()), + ContainmentTarget::Local(id) => (1u8, id.clone()), + }; + key(&a.target).cmp(&key(&b.target)) + }) + }); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::qobuz_playlist_snapshot as snapshot; + + fn conn() -> Connection { + let c = Connection::open_in_memory().unwrap(); + snapshot::init_schema(&c).unwrap(); + crate::local_playlists::init_schema(&c).unwrap(); + // Self-contained copies of the sidecar tables (the database.rs idiom: + // unit tests build only the schema slice they exercise). + c.execute_batch( + "CREATE TABLE playlist_local_tracks ( + id INTEGER PRIMARY KEY, + qobuz_playlist_id INTEGER NOT NULL, + local_track_id INTEGER NOT NULL, + position INTEGER NOT NULL, + added_at INTEGER NOT NULL, + UNIQUE(qobuz_playlist_id, local_track_id)); + CREATE TABLE playlist_plex_tracks ( + id INTEGER PRIMARY KEY, + qobuz_playlist_id INTEGER NOT NULL, + plex_rating_key TEXT NOT NULL, + position INTEGER NOT NULL, + added_at INTEGER NOT NULL, + UNIQUE(qobuz_playlist_id, plex_rating_key)); + CREATE TABLE playlist_remote_tracks ( + id INTEGER PRIMARY KEY, + qobuz_playlist_id INTEGER NOT NULL, + source TEXT NOT NULL, + item_id TEXT NOT NULL, + position INTEGER NOT NULL, + added_at INTEGER NOT NULL, + UNIQUE(qobuz_playlist_id, source, item_id));", + ) + .unwrap(); + c + } + + /// ONE authoritative list naming every playlist (separate lists would + /// advance the generation and retire the earlier ones — the grace rule + /// working as intended), then a membership snapshot for each. + fn owned_playlists(c: &Connection, playlists: &[(u64, &str, &[u64])]) { + let entries: Vec<snapshot::AuthoritativeEntry> = playlists + .iter() + .map(|(id, name, ids)| snapshot::AuthoritativeEntry { + qobuz_playlist_id: *id, + name: name.to_string(), + owner: None, + track_count: Some(ids.len() as u32), + remote_updated_at: None, + is_owned: true, + }) + .collect(); + snapshot::record_authoritative_list(c, &entries).unwrap(); + for (id, name, ids) in playlists { + snapshot::replace_tracks(c, *id, name, None, ids).unwrap(); + } + } + + fn contained(rows: &[PlaylistContainment], target: ContainmentTarget) -> Option<u32> { + rows.iter() + .find(|r| r.target == target) + .map(|r| r.contained) + } + + #[test] + fn ref_construction_routes_plex_by_stored_key() { + assert_eq!( + PlaylistTrackRef::from_library_row("plex", 99, "12345", None), + PlaylistTrackRef::Plex { + rating_key: "12345".into() + } + ); + assert_eq!( + PlaylistTrackRef::from_library_row("jellyfin", 7, "jelly-item", None), + PlaylistTrackRef::Remote { + source: "jellyfin".into(), + item_id: "jelly-item".into(), + } + ); + // The catalog id sticks only to offline copies; a stray value on any + // other source is dropped rather than trusted. + assert_eq!( + PlaylistTrackRef::from_library_row("qobuz_download", 8, "/dl/a.flac", Some(555)), + PlaylistTrackRef::Library { + track_id: 8, + path: Some("/dl/a.flac".into()), + qobuz_track_id: Some(555), + } + ); + assert_eq!( + PlaylistTrackRef::from_library_row("local", 9, "/m/b.flac", Some(555)), + PlaylistTrackRef::Library { + track_id: 9, + path: Some("/m/b.flac".into()), + qobuz_track_id: None, + } + ); + } + + #[test] + fn offline_copy_matches_catalog_membership_too() { + let c = conn(); + owned_playlists(&c, &[(1, "Qobuz side", &[555])]); + let lp = crate::local_playlists::create(&c, "Local side", None, false).unwrap(); + crate::local_playlists::add_tracks( + &c, + &lp, + &[crate::local_playlists::LocalPlaylistTrackInput::Qobuz(555)], + ) + .unwrap(); + + let refs = [PlaylistTrackRef::Library { + track_id: 8, + path: Some("/dl/a.flac".into()), + qobuz_track_id: Some(555), + }]; + let rows = playlists_containing(&c, &refs).unwrap(); + assert_eq!(contained(&rows, ContainmentTarget::Qobuz(1)), Some(1)); + assert_eq!(contained(&rows, ContainmentTarget::Local(lp)), Some(1)); + } + + #[test] + fn qobuz_containment_counts_distinct_refs() { + let c = conn(); + owned_playlists( + &c, + &[ + (1, "Both", &[10, 20, 30]), + (2, "One", &[20]), + (3, "Neither", &[99]), + ], + ); + + let rows = playlists_containing( + &c, + &[PlaylistTrackRef::Qobuz(10), PlaylistTrackRef::Qobuz(20)], + ) + .unwrap(); + assert_eq!(contained(&rows, ContainmentTarget::Qobuz(1)), Some(2)); + assert_eq!(contained(&rows, ContainmentTarget::Qobuz(2)), Some(1)); + assert_eq!(contained(&rows, ContainmentTarget::Qobuz(3)), None); + // Fullest playlist first. + assert_eq!(rows[0].target, ContainmentTarget::Qobuz(1)); + } + + #[test] + fn duplicate_membership_rows_count_once() { + let c = conn(); + owned_playlists(&c, &[(1, "Dupes", &[10, 10, 10])]); + let rows = playlists_containing(&c, &[PlaylistTrackRef::Qobuz(10)]).unwrap(); + assert_eq!(contained(&rows, ContainmentTarget::Qobuz(1)), Some(1)); + } + + #[test] + fn unowned_and_inactive_playlists_are_not_targets() { + let c = conn(); + snapshot::record_authoritative_list( + &c, + &[snapshot::AuthoritativeEntry { + qobuz_playlist_id: 5, + name: "Followed".into(), + owner: None, + track_count: Some(1), + remote_updated_at: None, + is_owned: false, + }], + ) + .unwrap(); + snapshot::replace_tracks(&c, 5, "Followed", None, &[10]).unwrap(); + let rows = playlists_containing(&c, &[PlaylistTrackRef::Qobuz(10)]).unwrap(); + assert!(rows.is_empty()); + + owned_playlists(&c, &[(6, "Mine", &[10])]); + // Retire 6 by two authoritative lists without it. + for _ in 0..2 { + snapshot::record_authoritative_list(&c, &[]).unwrap(); + } + let rows = playlists_containing(&c, &[PlaylistTrackRef::Qobuz(10)]).unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn sidecars_and_local_playlists_answer_by_typed_identity() { + let c = conn(); + c.execute_batch( + "INSERT INTO playlist_local_tracks VALUES (NULL, 7, 42, 0, 1); + INSERT INTO playlist_plex_tracks VALUES (NULL, 7, 'rk-9', 1, 1);", + ) + .unwrap(); + let lp = crate::local_playlists::create(&c, "Local mix", None, false).unwrap(); + crate::local_playlists::add_tracks( + &c, + &lp, + &[ + crate::local_playlists::LocalPlaylistTrackInput::Qobuz(1000), + crate::local_playlists::LocalPlaylistTrackInput::Local("/music/a.flac".into()), + crate::local_playlists::LocalPlaylistTrackInput::Plex("rk-9".into()), + ], + ) + .unwrap(); + + let refs = [ + PlaylistTrackRef::Qobuz(1000), + PlaylistTrackRef::Library { + track_id: 42, + path: Some("/music/a.flac".into()), + qobuz_track_id: None, + }, + PlaylistTrackRef::Plex { + rating_key: "rk-9".into(), + }, + ]; + let rows = playlists_containing(&c, &refs).unwrap(); + // The Qobuz playlist holds the library row and the Plex key (2 of 3); + // the local playlist holds all three. + assert_eq!(contained(&rows, ContainmentTarget::Qobuz(7)), Some(2)); + assert_eq!( + contained(&rows, ContainmentTarget::Local(lp.clone())), + Some(3) + ); + assert_eq!(rows[0].target, ContainmentTarget::Local(lp)); + } + + #[test] + fn remote_rows_answer_by_source_qualified_item_id() { + let c = conn(); + c.execute_batch( + "INSERT INTO playlist_remote_tracks VALUES (NULL, 7, 'jellyfin', 'item-1', 0, 1);", + ) + .unwrap(); + let lp = crate::local_playlists::create(&c, "Remote mix", None, false).unwrap(); + crate::local_playlists::add_tracks( + &c, + &lp, + &[ + crate::local_playlists::LocalPlaylistTrackInput::Jellyfin("item-1".into()), + crate::local_playlists::LocalPlaylistTrackInput::Subsonic("song-9".into()), + ], + ) + .unwrap(); + + let jelly = PlaylistTrackRef::from_library_row("jellyfin", 12345, "item-1", None); + let sub = PlaylistTrackRef::Remote { + source: "subsonic".into(), + item_id: "song-9".into(), + }; + // The same item id under the WRONG source must not match. + let wrong = PlaylistTrackRef::Remote { + source: "subsonic".into(), + item_id: "item-1".into(), + }; + let rows = playlists_containing(&c, &[jelly, sub]).unwrap(); + assert_eq!(contained(&rows, ContainmentTarget::Qobuz(7)), Some(1)); + assert_eq!( + contained(&rows, ContainmentTarget::Local(lp.clone())), + Some(2) + ); + assert!(playlists_containing(&c, &[wrong]).unwrap().is_empty()); + + // A local FILE path that happens to equal a remote item id never + // cross-matches (the source qualifier on the shared column). + let path_ref = PlaylistTrackRef::Library { + track_id: 1, + path: Some("item-1".into()), + qobuz_track_id: None, + }; + let rows = playlists_containing(&c, &[path_ref]).unwrap(); + assert!(contained(&rows, ContainmentTarget::Local(lp)).is_none()); + } + + #[test] + fn empty_refs_answer_nothing() { + let c = conn(); + assert!(playlists_containing(&c, &[]).unwrap().is_empty()); + } +} diff --git a/crates/qbz-library/src/purchase_copies.rs b/crates/qbz-library/src/purchase_copies.rs new file mode 100644 index 000000000..37e43ebea --- /dev/null +++ b/crates/qbz-library/src/purchase_copies.rs @@ -0,0 +1,1259 @@ +//! Persistent, additive inventory for DRM-free Qobuz purchase copies. +//! +//! `downloaded_purchases` remains a compatibility pointer for old UI and the +//! scanner. This module is the authority for N physical copies: coverage is +//! always evaluated inside one `copy_id`, never by combining folders. + +use crate::{FileProbe, LibraryDatabase, LibraryError}; +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +const LEGACY_MIGRATION_KEY: &str = "purchase_copies_legacy_migration_v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PurchasePlaybackMode { + Qobuz, + Purchase, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PurchaseAlbumPlaybackPreference { + pub album_id: String, + pub mode: PurchasePlaybackMode, + pub format_id: Option<i64>, + pub updated_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PurchaseDownloadCopy { + pub copy_id: String, + pub album_id: String, + pub format_id: i64, + pub resolved_album_folder: String, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PurchaseDownloadCopyTrack { + pub copy_id: String, + pub track_id: i64, + pub file_path: String, + pub mime_type: Option<String>, + pub container: Option<String>, + pub sample_rate_hz: Option<u32>, + pub bit_depth: Option<u32>, + pub channels: Option<u16>, + pub file_size: u64, + pub modified_ns: Option<u128>, + pub last_verified_at: Option<i64>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PurchaseTrackHealth { + Healthy, + Missing, + Changed, + Unreachable, + Unreadable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PurchaseCopyHealth { + CompleteHealthy, + Partial, + Missing, + Changed, + Unreachable, + Unreadable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PurchaseCopySnapshot { + pub copy: PurchaseDownloadCopy, + pub health: PurchaseCopyHealth, + pub downloaded_tracks: u32, + pub healthy_tracks: u32, + pub total_tracks: u32, + pub tracks: Vec<(PurchaseDownloadCopyTrack, PurchaseTrackHealth)>, +} + +pub(crate) fn init_schema(conn: &Connection) -> Result<(), LibraryError> { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS purchase_download_copies ( + copy_id TEXT PRIMARY KEY, + album_id TEXT NOT NULL, + format_id INTEGER NOT NULL, + resolved_album_folder TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_purchase_copies_album + ON purchase_download_copies(album_id, format_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS purchase_download_copy_tracks ( + copy_id TEXT NOT NULL, + track_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + mime_type TEXT, + container TEXT, + sample_rate_hz INTEGER, + bit_depth INTEGER, + channels INTEGER, + file_size INTEGER NOT NULL DEFAULT 0, + modified_ns TEXT, + last_verified_at INTEGER, + PRIMARY KEY(copy_id, track_id), + FOREIGN KEY(copy_id) REFERENCES purchase_download_copies(copy_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_purchase_copy_tracks_track + ON purchase_download_copy_tracks(track_id, copy_id); + + CREATE TABLE IF NOT EXISTS purchase_download_copy_expected_tracks ( + copy_id TEXT NOT NULL, + track_id INTEGER NOT NULL, + PRIMARY KEY(copy_id, track_id), + FOREIGN KEY(copy_id) REFERENCES purchase_download_copies(copy_id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS purchase_album_playback_preferences ( + album_id TEXT PRIMARY KEY, + mode TEXT NOT NULL CHECK(mode IN ('qobuz','purchase')), + format_id INTEGER NULL, + updated_at INTEGER NOT NULL, + CHECK((mode = 'qobuz' AND format_id IS NULL) + OR (mode = 'purchase' AND format_id IS NOT NULL)) + ); + "#, + ) + .map_err(|e| LibraryError::Database(format!("purchase copies schema: {e}")))?; + + // A short-lived race in the first additive-registry migration could import + // the same physical album folder twice. Heal those rows before enforcing + // the identity invariant so old profiles do not keep rendering duplicate + // copies forever. + let repaired = deduplicate_copy_identities(conn)?; + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_copies_identity + ON purchase_download_copies(album_id, format_id, resolved_album_folder)", + [], + ) + .map_err(|e| LibraryError::Database(format!("purchase copy identity index: {e}")))?; + if repaired > 0 { + log::info!("Repaired {repaired} duplicate purchase download copy record(s)"); + } + Ok(()) +} + +/// Merge duplicate physical identities without losing coverage. The newest +/// row wins conflicts for one track; disjoint track and expected-track rows +/// from older duplicates are folded into it before those rows are removed. +fn deduplicate_copy_identities(conn: &Connection) -> Result<usize, LibraryError> { + let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate).map_err(db_error)?; + let identities = { + let mut stmt = tx + .prepare( + "SELECT album_id, format_id, resolved_album_folder + FROM purchase_download_copies + GROUP BY album_id, format_id, resolved_album_folder + HAVING COUNT(*) > 1", + ) + .map_err(db_error)?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(db_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(db_error)?; + rows + }; + + let mut removed = 0; + for (album_id, format_id, folder) in identities { + let copy_ids = { + let mut stmt = tx + .prepare( + "SELECT copy_id FROM purchase_download_copies + WHERE album_id = ?1 AND format_id = ?2 AND resolved_album_folder = ?3 + ORDER BY updated_at DESC, created_at DESC, copy_id", + ) + .map_err(db_error)?; + let rows = stmt + .query_map(params![album_id, format_id, folder], |row| row.get(0)) + .map_err(db_error)? + .collect::<Result<Vec<String>, _>>() + .map_err(db_error)?; + rows + }; + let Some(keeper) = copy_ids.first() else { + continue; + }; + tx.execute( + "UPDATE purchase_download_copies + SET created_at = (SELECT MIN(created_at) FROM purchase_download_copies + WHERE album_id = ?2 AND format_id = ?3 + AND resolved_album_folder = ?4), + updated_at = (SELECT MAX(updated_at) FROM purchase_download_copies + WHERE album_id = ?2 AND format_id = ?3 + AND resolved_album_folder = ?4) + WHERE copy_id = ?1", + params![keeper, album_id, format_id, folder], + ) + .map_err(db_error)?; + + for duplicate in copy_ids.iter().skip(1) { + tx.execute( + "INSERT OR IGNORE INTO purchase_download_copy_expected_tracks(copy_id, track_id) + SELECT ?1, track_id FROM purchase_download_copy_expected_tracks + WHERE copy_id = ?2", + params![keeper, duplicate], + ) + .map_err(db_error)?; + tx.execute( + "INSERT OR IGNORE INTO purchase_download_copy_tracks + (copy_id, track_id, file_path, mime_type, container, sample_rate_hz, + bit_depth, channels, file_size, modified_ns, last_verified_at) + SELECT ?1, track_id, file_path, mime_type, container, sample_rate_hz, + bit_depth, channels, file_size, modified_ns, last_verified_at + FROM purchase_download_copy_tracks WHERE copy_id = ?2", + params![keeper, duplicate], + ) + .map_err(db_error)?; + // Foreign keys are intentionally not enabled on the app's SQLite + // connections, so remove children explicitly before the parent. + tx.execute( + "DELETE FROM purchase_download_copy_expected_tracks WHERE copy_id = ?1", + [duplicate], + ) + .map_err(db_error)?; + tx.execute( + "DELETE FROM purchase_download_copy_tracks WHERE copy_id = ?1", + [duplicate], + ) + .map_err(db_error)?; + removed += tx + .execute( + "DELETE FROM purchase_download_copies WHERE copy_id = ?1", + [duplicate], + ) + .map_err(db_error)?; + } + } + tx.commit().map_err(db_error)?; + Ok(removed) +} + +/// One-time, filesystem-free import of the compatibility registry. Grouping by +/// album + format + parent folder is what prevents an old split registry from +/// becoming one fabricated complete copy. +pub(crate) fn migrate_legacy_registry(conn: &Connection) -> Result<(), LibraryError> { + // Serialize the marker check with the import itself. A process-local schema + // lock cannot protect two QBZ processes opening the same profile at once. + let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate).map_err(db_error)?; + let done = tx + .query_row( + "SELECT value FROM library_kv WHERE key = ?1", + [LEGACY_MIGRATION_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| LibraryError::Database(format!("read purchase migration marker: {e}")))? + .is_some(); + if done { + return tx.commit().map_err(db_error); + } + + let mut stmt = tx + .prepare( + "SELECT track_id, format_id, album_id, file_path + FROM downloaded_purchases WHERE album_id IS NOT NULL + ORDER BY album_id, format_id, file_path", + ) + .map_err(|e| LibraryError::Database(format!("read legacy purchase registry: {e}")))?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(|e| LibraryError::Database(format!("query legacy purchase registry: {e}")))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| LibraryError::Database(format!("collect legacy purchase registry: {e}")))?; + drop(stmt); + + let mut groups: BTreeMap<(String, i64, String), Vec<(i64, String)>> = BTreeMap::new(); + for (track_id, format_id, album_id, file_path) in rows { + let Some(folder) = Path::new(&file_path).parent() else { + continue; + }; + groups + .entry((album_id, format_id, folder.to_string_lossy().into_owned())) + .or_default() + .push((track_id, file_path)); + } + + let now = unix_now(); + for ((album_id, format_id, folder), tracks) in groups { + let copy_id = Uuid::new_v4().to_string(); + tx.execute( + "INSERT OR IGNORE INTO purchase_download_copies + (copy_id, album_id, format_id, resolved_album_folder, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + params![copy_id, album_id, format_id, folder, now], + ) + .map_err(|e| LibraryError::Database(format!("migrate purchase copy: {e}")))?; + let effective_copy_id: String = tx + .query_row( + "SELECT copy_id FROM purchase_download_copies + WHERE album_id = ?1 AND format_id = ?2 AND resolved_album_folder = ?3", + params![album_id, format_id, folder], + |row| row.get(0), + ) + .map_err(|e| LibraryError::Database(format!("resolve migrated purchase copy: {e}")))?; + for (track_id, file_path) in tracks { + let container = Path::new(&file_path) + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + tx.execute( + "INSERT OR IGNORE INTO purchase_download_copy_tracks + (copy_id, track_id, file_path, container, file_size) + VALUES (?1, ?2, ?3, ?4, 0)", + params![effective_copy_id, track_id, file_path, container], + ) + .map_err(|e| LibraryError::Database(format!("migrate purchase track: {e}")))?; + } + } + tx.execute( + "INSERT OR REPLACE INTO library_kv(key, value) VALUES (?1, '1')", + [LEGACY_MIGRATION_KEY], + ) + .map_err(|e| LibraryError::Database(format!("write purchase migration marker: {e}")))?; + tx.commit() + .map_err(|e| LibraryError::Database(format!("commit purchase migration: {e}"))) +} + +impl LibraryDatabase { + pub fn create_purchase_copy( + &self, + album_id: &str, + format_id: i64, + resolved_album_folder: &Path, + ) -> Result<PurchaseDownloadCopy, LibraryError> { + let copy = PurchaseDownloadCopy { + copy_id: Uuid::new_v4().to_string(), + album_id: album_id.to_string(), + format_id, + resolved_album_folder: resolved_album_folder.to_string_lossy().into_owned(), + created_at: unix_now(), + updated_at: unix_now(), + }; + self.with_connection(|conn| insert_copy(conn, ©))?; + Ok(copy) + } + + /// Create a copy and its immutable album coverage manifest in one + /// transaction. The manifest is the denominator used by playback after a + /// restart; without it, a directory containing one healthy track could be + /// mistaken for a complete album. + pub fn create_purchase_copy_with_expected_tracks( + &self, + album_id: &str, + format_id: i64, + resolved_album_folder: &Path, + expected_track_ids: &[i64], + ) -> Result<PurchaseDownloadCopy, LibraryError> { + let expected = normalize_expected_track_ids(expected_track_ids)?; + let copy = PurchaseDownloadCopy { + copy_id: Uuid::new_v4().to_string(), + album_id: album_id.to_string(), + format_id, + resolved_album_folder: resolved_album_folder.to_string_lossy().into_owned(), + created_at: unix_now(), + updated_at: unix_now(), + }; + self.with_connection(|conn| { + let tx = conn.unchecked_transaction().map_err(db_error)?; + insert_copy(&tx, ©)?; + insert_expected_tracks(&tx, ©.copy_id, &expected)?; + tx.commit().map_err(db_error) + })?; + Ok(copy) + } + + /// Attach the exact album coverage used to validate a migrated copy. + /// Existing rows are replaced atomically so a crash cannot leave a + /// half-written denominator. + pub fn set_purchase_copy_expected_tracks( + &self, + copy_id: &str, + expected_track_ids: &[i64], + ) -> Result<(), LibraryError> { + let expected = normalize_expected_track_ids(expected_track_ids)?; + self.with_connection(|conn| { + if select_copy(conn, copy_id)?.is_none() { + return Err(LibraryError::Database(format!( + "purchase copy {copy_id} does not exist" + ))); + } + let tx = conn.unchecked_transaction().map_err(db_error)?; + tx.execute( + "DELETE FROM purchase_download_copy_expected_tracks WHERE copy_id = ?1", + [copy_id], + ) + .map_err(db_error)?; + insert_expected_tracks(&tx, copy_id, &expected)?; + tx.commit().map_err(db_error) + }) + } + + pub fn get_purchase_copy_expected_track_ids( + &self, + copy_id: &str, + ) -> Result<Vec<i64>, LibraryError> { + self.with_connection(|conn| select_expected_tracks(conn, copy_id)) + } + + pub fn upsert_purchase_copy_track( + &self, + track: &PurchaseDownloadCopyTrack, + ) -> Result<(), LibraryError> { + self.with_connection(|conn| { + conn.execute( + "INSERT INTO purchase_download_copy_tracks + (copy_id, track_id, file_path, mime_type, container, sample_rate_hz, + bit_depth, channels, file_size, modified_ns, last_verified_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(copy_id, track_id) DO UPDATE SET + file_path=excluded.file_path, mime_type=excluded.mime_type, + container=excluded.container, sample_rate_hz=excluded.sample_rate_hz, + bit_depth=excluded.bit_depth, channels=excluded.channels, + file_size=excluded.file_size, modified_ns=excluded.modified_ns, + last_verified_at=excluded.last_verified_at", + params![ + track.copy_id, + track.track_id, + track.file_path, + track.mime_type, + track.container, + track.sample_rate_hz, + track.bit_depth, + track.channels, + u64_to_i64(track.file_size), + track.modified_ns.map(|value| value.to_string()), + track.last_verified_at, + ], + ) + .and_then(|_| { + conn.execute( + "UPDATE purchase_download_copies SET updated_at = ?2 WHERE copy_id = ?1", + params![track.copy_id, unix_now()], + ) + }) + .map(|_| ()) + .map_err(|e| LibraryError::Database(format!("upsert purchase copy track: {e}"))) + }) + } + + pub fn get_purchase_copy( + &self, + copy_id: &str, + ) -> Result<Option<PurchaseDownloadCopy>, LibraryError> { + self.with_connection(|conn| select_copy(conn, copy_id)) + } + + pub fn get_purchase_copies_for_album( + &self, + album_id: &str, + ) -> Result<Vec<PurchaseDownloadCopy>, LibraryError> { + self.with_connection(|conn| { + let mut stmt = conn + .prepare( + "SELECT copy_id, album_id, format_id, resolved_album_folder, + created_at, updated_at + FROM purchase_download_copies WHERE album_id = ?1 + ORDER BY updated_at DESC, copy_id", + ) + .map_err(db_error)?; + let rows = stmt + .query_map([album_id], copy_from_row) + .map_err(db_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(db_error)?; + Ok(rows) + }) + } + + pub fn get_purchase_copy_tracks( + &self, + copy_id: &str, + ) -> Result<Vec<PurchaseDownloadCopyTrack>, LibraryError> { + self.with_connection(|conn| select_copy_tracks(conn, copy_id)) + } + + /// Forget one inventory row while leaving the user's files untouched. + /// Any compatibility pointers that still name paths from this exact copy + /// are removed with it; pointers promoted from another copy survive. + pub fn delete_purchase_copy_record(&self, copy_id: &str) -> Result<bool, LibraryError> { + self.with_connection(|conn| { + let Some(copy) = select_copy(conn, copy_id)? else { + return Ok(false); + }; + let tracks = select_copy_tracks(conn, copy_id)?; + let tx = conn.unchecked_transaction().map_err(db_error)?; + for track in &tracks { + tx.execute( + "DELETE FROM downloaded_purchases + WHERE track_id = ?1 AND format_id = ?2 AND album_id = ?3 AND file_path = ?4", + params![ + track.track_id, + copy.format_id, + copy.album_id, + track.file_path + ], + ) + .map_err(db_error)?; + } + tx.execute( + "DELETE FROM purchase_download_copy_expected_tracks WHERE copy_id = ?1", + [copy_id], + ) + .map_err(db_error)?; + tx.execute( + "DELETE FROM purchase_download_copy_tracks WHERE copy_id = ?1", + [copy_id], + ) + .map_err(db_error)?; + let removed = tx + .execute( + "DELETE FROM purchase_download_copies WHERE copy_id = ?1", + [copy_id], + ) + .map_err(db_error)? + > 0; + tx.execute( + "DELETE FROM purchase_album_playback_preferences + WHERE album_id = ?1 AND mode = 'purchase' AND format_id = ?2 + AND NOT EXISTS ( + SELECT 1 FROM purchase_download_copies + WHERE album_id = ?1 AND format_id = ?2 + )", + params![copy.album_id, copy.format_id], + ) + .map_err(db_error)?; + tx.commit().map_err(db_error)?; + Ok(removed) + }) + } + + /// Atomically point the compatibility registry at `copy_id`, but only if + /// that one copy covers every expected album track. + pub fn promote_complete_purchase_copy( + &self, + copy_id: &str, + expected_track_ids: &[i64], + ) -> Result<bool, LibraryError> { + self.with_connection(|conn| { + let Some(copy) = select_copy(conn, copy_id)? else { + return Ok(false); + }; + let tracks = select_copy_tracks(conn, copy_id)?; + let present: HashSet<i64> = tracks.iter().map(|track| track.track_id).collect(); + if expected_track_ids.is_empty() + || !expected_track_ids + .iter() + .all(|track_id| present.contains(track_id)) + { + return Ok(false); + } + + let tx = conn.unchecked_transaction().map_err(db_error)?; + for track in tracks + .iter() + .filter(|track| expected_track_ids.contains(&track.track_id)) + { + tx.execute( + "INSERT OR REPLACE INTO downloaded_purchases + (track_id, format_id, album_id, file_path, downloaded_at) + VALUES (?1, ?2, ?3, ?4, datetime('now'))", + params![ + track.track_id, + copy.format_id, + copy.album_id, + track.file_path + ], + ) + .map_err(db_error)?; + } + tx.commit().map_err(db_error)?; + Ok(true) + }) + } + + pub fn purchase_playback_preference( + &self, + album_id: &str, + ) -> Result<PurchaseAlbumPlaybackPreference, LibraryError> { + self.with_connection(|conn| { + conn.query_row( + "SELECT album_id, mode, format_id, updated_at + FROM purchase_album_playback_preferences WHERE album_id = ?1", + [album_id], + |row| { + let mode = match row.get::<_, String>(1)?.as_str() { + "purchase" => PurchasePlaybackMode::Purchase, + _ => PurchasePlaybackMode::Qobuz, + }; + Ok(PurchaseAlbumPlaybackPreference { + album_id: row.get(0)?, + mode, + format_id: row.get(2)?, + updated_at: row.get(3)?, + }) + }, + ) + .optional() + .map_err(db_error) + .map(|value| { + value.unwrap_or(PurchaseAlbumPlaybackPreference { + album_id: album_id.to_string(), + mode: PurchasePlaybackMode::Qobuz, + format_id: None, + updated_at: 0, + }) + }) + }) + } + + pub fn set_purchase_playback_preference( + &self, + album_id: &str, + mode: PurchasePlaybackMode, + format_id: Option<i64>, + ) -> Result<PurchaseAlbumPlaybackPreference, LibraryError> { + let normalized_format = match mode { + PurchasePlaybackMode::Qobuz => None, + PurchasePlaybackMode::Purchase => format_id, + }; + if mode == PurchasePlaybackMode::Purchase && normalized_format.is_none() { + return Err(LibraryError::Database( + "purchase playback preference requires a format_id".to_string(), + )); + } + let value = PurchaseAlbumPlaybackPreference { + album_id: album_id.to_string(), + mode, + format_id: normalized_format, + updated_at: unix_now(), + }; + self.with_connection(|conn| { + conn.execute( + "INSERT INTO purchase_album_playback_preferences + (album_id, mode, format_id, updated_at) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(album_id) DO UPDATE SET mode=excluded.mode, + format_id=excluded.format_id, updated_at=excluded.updated_at", + params![ + value.album_id, + mode_word(value.mode), + value.format_id, + value.updated_at + ], + ) + .map_err(db_error) + })?; + Ok(value) + } + + /// Snapshot all copies of an album. This performs bounded probes and must + /// therefore be called away from a UI thread. + pub fn inspect_purchase_copies( + &self, + album_id: &str, + expected_track_ids: &[i64], + ) -> Result<Vec<PurchaseCopySnapshot>, LibraryError> { + let expected: HashSet<i64> = expected_track_ids.iter().copied().collect(); + let mut result = Vec::new(); + for copy in self.get_purchase_copies_for_album(album_id)? { + let rows = self.get_purchase_copy_tracks(©.copy_id)?; + let mut tracks = Vec::with_capacity(rows.len()); + let mut healthy_ids = HashSet::new(); + let mut saw_missing = false; + let mut saw_changed = false; + let mut saw_unreachable = false; + let mut saw_unreadable = false; + for row in rows { + let health = track_health(&row); + match health { + PurchaseTrackHealth::Healthy => { + healthy_ids.insert(row.track_id); + } + PurchaseTrackHealth::Missing => saw_missing = true, + PurchaseTrackHealth::Changed => saw_changed = true, + PurchaseTrackHealth::Unreachable => saw_unreachable = true, + PurchaseTrackHealth::Unreadable => saw_unreadable = true, + } + tracks.push((row, health)); + } + let complete = !expected.is_empty() + && expected + .iter() + .all(|track_id| healthy_ids.contains(track_id)); + let health = if complete { + PurchaseCopyHealth::CompleteHealthy + } else if saw_unreachable { + PurchaseCopyHealth::Unreachable + } else if saw_changed { + PurchaseCopyHealth::Changed + } else if saw_unreadable { + PurchaseCopyHealth::Unreadable + } else if saw_missing { + PurchaseCopyHealth::Missing + } else { + PurchaseCopyHealth::Partial + }; + result.push(PurchaseCopySnapshot { + copy, + health, + downloaded_tracks: tracks + .iter() + .filter(|(row, _)| expected.contains(&row.track_id)) + .count() as u32, + healthy_tracks: healthy_ids.intersection(&expected).count() as u32, + total_tracks: expected.len() as u32, + tracks, + }); + } + Ok(result) + } + + /// Exact-format candidates that are complete and healthy against their + /// own persisted album manifest. Results retain copy recency order and + /// never combine coverage between copy IDs. This performs bounded file + /// probes and must run away from the UI thread. + pub fn complete_healthy_purchase_track_candidates( + &self, + album_id: &str, + format_id: i64, + track_id: i64, + ) -> Result<Vec<(PurchaseDownloadCopy, PurchaseDownloadCopyTrack)>, LibraryError> { + let mut candidates = Vec::new(); + for copy in self + .get_purchase_copies_for_album(album_id)? + .into_iter() + .filter(|copy| copy.format_id == format_id) + { + let expected: HashSet<i64> = self + .get_purchase_copy_expected_track_ids(©.copy_id)? + .into_iter() + .collect(); + if expected.is_empty() || !expected.contains(&track_id) { + continue; + } + let rows = self.get_purchase_copy_tracks(©.copy_id)?; + let mut target = None; + let mut healthy = HashSet::new(); + for row in rows + .into_iter() + .filter(|row| expected.contains(&row.track_id)) + { + if track_health(&row) == PurchaseTrackHealth::Healthy { + healthy.insert(row.track_id); + if row.track_id == track_id { + target = Some(row); + } + } + } + if expected.iter().all(|id| healthy.contains(id)) { + if let Some(target) = target { + candidates.push((copy, target)); + } + } + } + Ok(candidates) + } +} + +fn insert_copy(conn: &Connection, copy: &PurchaseDownloadCopy) -> Result<(), LibraryError> { + conn.execute( + "INSERT INTO purchase_download_copies + (copy_id, album_id, format_id, resolved_album_folder, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + copy.copy_id, + copy.album_id, + copy.format_id, + copy.resolved_album_folder, + copy.created_at, + copy.updated_at + ], + ) + .map(|_| ()) + .map_err(db_error) +} + +fn select_copy( + conn: &Connection, + copy_id: &str, +) -> Result<Option<PurchaseDownloadCopy>, LibraryError> { + conn.query_row( + "SELECT copy_id, album_id, format_id, resolved_album_folder, created_at, updated_at + FROM purchase_download_copies WHERE copy_id = ?1", + [copy_id], + copy_from_row, + ) + .optional() + .map_err(db_error) +} + +fn copy_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PurchaseDownloadCopy> { + Ok(PurchaseDownloadCopy { + copy_id: row.get(0)?, + album_id: row.get(1)?, + format_id: row.get(2)?, + resolved_album_folder: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) +} + +fn select_copy_tracks( + conn: &Connection, + copy_id: &str, +) -> Result<Vec<PurchaseDownloadCopyTrack>, LibraryError> { + let mut stmt = conn + .prepare( + "SELECT copy_id, track_id, file_path, mime_type, container, sample_rate_hz, + bit_depth, channels, file_size, modified_ns, last_verified_at + FROM purchase_download_copy_tracks WHERE copy_id = ?1 ORDER BY track_id", + ) + .map_err(db_error)?; + let rows = stmt + .query_map([copy_id], |row| { + let modified_ns = row + .get::<_, Option<String>>(9)? + .and_then(|value| value.parse::<u128>().ok()); + Ok(PurchaseDownloadCopyTrack { + copy_id: row.get(0)?, + track_id: row.get(1)?, + file_path: row.get(2)?, + mime_type: row.get(3)?, + container: row.get(4)?, + sample_rate_hz: row.get(5)?, + bit_depth: row.get(6)?, + channels: row.get(7)?, + file_size: row.get::<_, i64>(8)?.max(0) as u64, + modified_ns, + last_verified_at: row.get(10)?, + }) + }) + .map_err(db_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(db_error)?; + Ok(rows) +} + +fn normalize_expected_track_ids(expected_track_ids: &[i64]) -> Result<Vec<i64>, LibraryError> { + let expected: HashSet<i64> = expected_track_ids + .iter() + .copied() + .filter(|track_id| *track_id > 0) + .collect(); + if expected.is_empty() { + return Err(LibraryError::Database( + "purchase copy requires at least one expected track".to_string(), + )); + } + let mut expected: Vec<i64> = expected.into_iter().collect(); + expected.sort_unstable(); + Ok(expected) +} + +fn insert_expected_tracks( + conn: &Connection, + copy_id: &str, + expected_track_ids: &[i64], +) -> Result<(), LibraryError> { + for track_id in expected_track_ids { + conn.execute( + "INSERT INTO purchase_download_copy_expected_tracks (copy_id, track_id) + VALUES (?1, ?2)", + params![copy_id, track_id], + ) + .map_err(db_error)?; + } + Ok(()) +} + +fn select_expected_tracks(conn: &Connection, copy_id: &str) -> Result<Vec<i64>, LibraryError> { + let mut stmt = conn + .prepare( + "SELECT track_id FROM purchase_download_copy_expected_tracks + WHERE copy_id = ?1 ORDER BY track_id", + ) + .map_err(db_error)?; + let rows = stmt + .query_map([copy_id], |row| row.get(0)) + .map_err(db_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(db_error)?; + Ok(rows) +} + +fn track_health(track: &PurchaseDownloadCopyTrack) -> PurchaseTrackHealth { + match crate::probe_file_default(Path::new(&track.file_path)) { + FileProbe::Missing => PurchaseTrackHealth::Missing, + FileProbe::Unreachable => PurchaseTrackHealth::Unreachable, + FileProbe::Present(metadata) if !metadata.is_file() => PurchaseTrackHealth::Unreadable, + FileProbe::Present(metadata) => { + if track.file_size > 0 && metadata.len() != track.file_size { + return PurchaseTrackHealth::Changed; + } + if let Some(expected) = track.modified_ns { + let observed = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_nanos()); + if observed != Some(expected) { + return PurchaseTrackHealth::Changed; + } + } + PurchaseTrackHealth::Healthy + } + } +} + +fn mode_word(mode: PurchasePlaybackMode) -> &'static str { + match mode { + PurchasePlaybackMode::Qobuz => "qobuz", + PurchasePlaybackMode::Purchase => "purchase", + } +} + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .min(i64::MAX as u64) as i64 +} + +fn u64_to_i64(value: u64) -> i64 { + value.min(i64::MAX as u64) as i64 +} + +fn db_error(error: rusqlite::Error) -> LibraryError { + LibraryError::Database(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(copy_id: &str, id: i64, path: &Path) -> PurchaseDownloadCopyTrack { + let metadata = std::fs::metadata(path).unwrap(); + PurchaseDownloadCopyTrack { + copy_id: copy_id.to_string(), + track_id: id, + file_path: path.to_string_lossy().into_owned(), + mime_type: Some("audio/flac".to_string()), + container: Some("flac".to_string()), + sample_rate_hz: Some(96_000), + bit_depth: Some(24), + channels: Some(2), + file_size: metadata.len(), + modified_ns: None, + last_verified_at: None, + } + } + + #[test] + fn copies_never_combine_coverage_across_folders() { + let root = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&root.path().join("library.db")).unwrap(); + let first_dir = root.path().join("first"); + let second_dir = root.path().join("second"); + std::fs::create_dir_all(&first_dir).unwrap(); + std::fs::create_dir_all(&second_dir).unwrap(); + let first = db.create_purchase_copy("album", 7, &first_dir).unwrap(); + let second = db.create_purchase_copy("album", 7, &second_dir).unwrap(); + let one = first_dir.join("01.flac"); + let two = second_dir.join("02.flac"); + std::fs::write(&one, b"one").unwrap(); + std::fs::write(&two, b"two").unwrap(); + db.upsert_purchase_copy_track(&track(&first.copy_id, 1, &one)) + .unwrap(); + db.upsert_purchase_copy_track(&track(&second.copy_id, 2, &two)) + .unwrap(); + + let snapshots = db.inspect_purchase_copies("album", &[1, 2]).unwrap(); + assert_eq!(snapshots.len(), 2); + assert!(snapshots + .iter() + .all(|copy| copy.health == PurchaseCopyHealth::Partial)); + assert!(!db + .promote_complete_purchase_copy(&first.copy_id, &[1, 2]) + .unwrap()); + } + + #[test] + fn a_complete_copy_promotes_legacy_atomically() { + let root = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&root.path().join("library.db")).unwrap(); + let copy = db.create_purchase_copy("album", 55, root.path()).unwrap(); + for id in [1, 2] { + let path = root.path().join(format!("{id}.dsf")); + std::fs::write(&path, b"dsd").unwrap(); + db.upsert_purchase_copy_track(&track(©.copy_id, id, &path)) + .unwrap(); + } + + assert!(db + .promote_complete_purchase_copy(©.copy_id, &[1, 2]) + .unwrap()); + assert_eq!( + db.get_downloaded_purchase_formats().unwrap(), + vec![(1, 55), (2, 55)] + ); + } + + #[test] + fn preference_defaults_to_qobuz_and_round_trips_exact_format() { + let root = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&root.path().join("library.db")).unwrap(); + let default = db.purchase_playback_preference("album").unwrap(); + assert_eq!(default.mode, PurchasePlaybackMode::Qobuz); + assert_eq!(default.format_id, None); + + db.set_purchase_playback_preference("album", PurchasePlaybackMode::Purchase, Some(55)) + .unwrap(); + let stored = db.purchase_playback_preference("album").unwrap(); + assert_eq!(stored.mode, PurchasePlaybackMode::Purchase); + assert_eq!(stored.format_id, Some(55)); + } + + #[test] + fn playback_candidates_require_the_persisted_complete_manifest() { + let root = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&root.path().join("library.db")).unwrap(); + + let complete_dir = root.path().join("complete"); + let partial_dir = root.path().join("partial"); + let legacy_dir = root.path().join("legacy"); + std::fs::create_dir_all(&complete_dir).unwrap(); + std::fs::create_dir_all(&partial_dir).unwrap(); + std::fs::create_dir_all(&legacy_dir).unwrap(); + + let complete = db + .create_purchase_copy_with_expected_tracks("album", 55, &complete_dir, &[1, 2]) + .unwrap(); + for id in [1, 2] { + let path = complete_dir.join(format!("complete-{id}.dsf")); + std::fs::write(&path, b"dsd").unwrap(); + db.upsert_purchase_copy_track(&track(&complete.copy_id, id, &path)) + .unwrap(); + } + + let partial = db + .create_purchase_copy_with_expected_tracks("album", 55, &partial_dir, &[1, 2]) + .unwrap(); + let partial_path = partial_dir.join("partial-1.dsf"); + std::fs::write(&partial_path, b"dsd").unwrap(); + db.upsert_purchase_copy_track(&track(&partial.copy_id, 1, &partial_path)) + .unwrap(); + + let legacy = db.create_purchase_copy("album", 55, &legacy_dir).unwrap(); + for id in [1, 2] { + let path = legacy_dir.join(format!("legacy-{id}.dsf")); + std::fs::write(&path, b"dsd").unwrap(); + db.upsert_purchase_copy_track(&track(&legacy.copy_id, id, &path)) + .unwrap(); + } + + let candidates = db + .complete_healthy_purchase_track_candidates("album", 55, 1) + .unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].0.copy_id, complete.copy_id); + assert!(db + .complete_healthy_purchase_track_candidates("album", 56, 1) + .unwrap() + .is_empty()); + + std::fs::remove_file(complete_dir.join("complete-2.dsf")).unwrap(); + assert!(db + .complete_healthy_purchase_track_candidates("album", 55, 1) + .unwrap() + .is_empty()); + } + + #[test] + fn legacy_migration_groups_by_folder() { + let root = tempfile::tempdir().unwrap(); + let db_path = root.path().join("library.db"); + { + let db = LibraryDatabase::open(&db_path).unwrap(); + db.with_connection(|conn| { + conn.execute( + "DELETE FROM library_kv WHERE key = ?1", + [LEGACY_MIGRATION_KEY], + ) + .unwrap(); + }); + db.mark_purchase_downloaded(1, Some("album"), "/a/01.flac", 7) + .unwrap(); + db.mark_purchase_downloaded(2, Some("album"), "/b/02.flac", 7) + .unwrap(); + } + let db = LibraryDatabase::open(&db_path).unwrap(); + let copies = db.get_purchase_copies_for_album("album").unwrap(); + assert_eq!(copies.len(), 2); + assert_ne!( + copies[0].resolved_album_folder, + copies[1].resolved_album_folder + ); + } + + #[test] + fn concurrent_legacy_migrations_import_one_physical_copy() { + let root = tempfile::tempdir().unwrap(); + let db_path = root.path().join("library.db"); + { + let db = LibraryDatabase::open(&db_path).unwrap(); + db.mark_purchase_downloaded(1, Some("album"), "/a/01.flac", 7) + .unwrap(); + db.with_connection(|conn| { + conn.execute("DELETE FROM purchase_download_copy_tracks", []) + .unwrap(); + conn.execute("DELETE FROM purchase_download_copies", []) + .unwrap(); + conn.execute( + "DELETE FROM library_kv WHERE key = ?1", + [LEGACY_MIGRATION_KEY], + ) + .unwrap(); + }); + } + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let mut workers = Vec::new(); + for _ in 0..2 { + let path = db_path.clone(); + let barrier = barrier.clone(); + workers.push(std::thread::spawn(move || { + let conn = Connection::open(path).unwrap(); + conn.busy_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + barrier.wait(); + migrate_legacy_registry(&conn).unwrap(); + })); + } + barrier.wait(); + for worker in workers { + worker.join().unwrap(); + } + + let db = LibraryDatabase::open(&db_path).unwrap(); + assert_eq!(db.get_purchase_copies_for_album("album").unwrap().len(), 1); + } + + #[test] + fn reopening_repairs_duplicate_physical_identities_without_losing_coverage() { + let root = tempfile::tempdir().unwrap(); + let db_path = root.path().join("library.db"); + let folder = root.path().join("same-copy"); + std::fs::create_dir_all(&folder).unwrap(); + let first_path = folder.join("01.flac"); + let second_path = folder.join("02.flac"); + std::fs::write(&first_path, b"one").unwrap(); + std::fs::write(&second_path, b"two").unwrap(); + { + let db = LibraryDatabase::open(&db_path).unwrap(); + db.with_connection(|conn| { + conn.execute("DROP INDEX idx_purchase_copies_identity", []) + .unwrap(); + for (copy_id, updated_at) in [("old", 10_i64), ("new", 20_i64)] { + conn.execute( + "INSERT INTO purchase_download_copies + (copy_id, album_id, format_id, resolved_album_folder, created_at, updated_at) + VALUES (?1, 'album', 7, ?2, 1, ?3)", + params![copy_id, folder.to_string_lossy(), updated_at], + ) + .unwrap(); + } + for (copy_id, track_id, path) in [ + ("old", 1_i64, &first_path), + ("new", 2_i64, &second_path), + ] { + conn.execute( + "INSERT INTO purchase_download_copy_tracks + (copy_id, track_id, file_path, file_size) + VALUES (?1, ?2, ?3, 3)", + params![copy_id, track_id, path.to_string_lossy()], + ) + .unwrap(); + conn.execute( + "INSERT INTO purchase_download_copy_expected_tracks(copy_id, track_id) + VALUES (?1, ?2)", + params![copy_id, track_id], + ) + .unwrap(); + } + }); + } + + let db = LibraryDatabase::open(&db_path).unwrap(); + let copies = db.get_purchase_copies_for_album("album").unwrap(); + assert_eq!(copies.len(), 1); + assert_eq!(copies[0].copy_id, "new", "the newest row is the keeper"); + assert_eq!(db.get_purchase_copy_tracks("new").unwrap().len(), 2); + assert_eq!( + db.get_purchase_copy_expected_track_ids("new").unwrap(), + vec![1, 2] + ); + assert!(db.create_purchase_copy("album", 7, &folder).is_err()); + } + + #[test] + fn forgetting_a_copy_removes_only_its_registry_and_leaves_files() { + let root = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&root.path().join("library.db")).unwrap(); + let folder = root.path().join("copy"); + std::fs::create_dir_all(&folder).unwrap(); + let path = folder.join("01.flac"); + std::fs::write(&path, b"one").unwrap(); + let copy = db + .create_purchase_copy_with_expected_tracks("album", 7, &folder, &[1]) + .unwrap(); + db.upsert_purchase_copy_track(&track(©.copy_id, 1, &path)) + .unwrap(); + assert!(db + .promote_complete_purchase_copy(©.copy_id, &[1]) + .unwrap()); + + assert!(db.delete_purchase_copy_record(©.copy_id).unwrap()); + assert!(path.exists(), "forgetting never deletes the user's file"); + assert!(db.get_purchase_copy(©.copy_id).unwrap().is_none()); + assert!(db.get_downloaded_purchase_formats().unwrap().is_empty()); + } +} diff --git a/crates/qbz-library/src/qobuz_playlist_snapshot.rs b/crates/qbz-library/src/qobuz_playlist_snapshot.rs index 3934f2285..f21e334f2 100644 --- a/crates/qbz-library/src/qobuz_playlist_snapshot.rs +++ b/crates/qbz-library/src/qobuz_playlist_snapshot.rs @@ -1,25 +1,35 @@ -//! Local snapshot of the user's QOBUZ playlists (offline-mode port, B7/B8). +//! Local snapshot of the user's QOBUZ playlists (offline-mode port, B7/B8; +//! membership index substrate, 2.1.1 Add-to-Playlist redesign). //! //! Spec D11 left an HONEST LIMIT: playlist names and membership live only in //! the Qobuz API, so offline a mixed playlist falls back to a synthesized //! "Playlist (N local)" name and shows zero Qobuz rows. This module stores a -//! point-in-time snapshot captured opportunistically from data the app -//! ALREADY fetches while online (no new API traffic): +//! point-in-time snapshot captured from data the app fetches while online: //! -//! - NAMES: every user-playlist list load (sidebar / playlist manager) -//! upserts id + name (+ owner, track_count) for ALL listed playlists — -//! cheap names-only rows, no track membership. +//! - HEADERS: every authoritative user-playlist list load (sidebar / playlist +//! manager) upserts id + name (+ owner, track_count, ownership) for ALL +//! listed playlists and advances the AUTHORITY GENERATION. A header absent +//! from two consecutive authoritative lists is marked `inactive` — never +//! deleted on a single miss, tolerating Qobuz post-write lag. //! - MEMBERSHIP: opening a playlist DETAIL online full-replaces its snapshot -//! track ids (the detail fetch already returns the full track list). -//! Membership is recorded ONLY for playlists already captured by the -//! names producer (the user's own list) — a merely-viewed public playlist -//! never lands in the snapshot, so the offline surfaces stay the user's. +//! track ids, and the background hydrator does the same from the +//! ids-only `playlist/get?extra=track_ids` fetch. Membership is recorded +//! ONLY for playlists already captured by a header producer (the user's own +//! list) — a merely-viewed public playlist never lands in the snapshot. +//! - MUTATIONS: successful add/remove/create through QBZ update the snapshot +//! incrementally and synchronously, so the picker's containment answer +//! moves without waiting for a re-sync. //! -//! Rows are point-in-time: offline consumers show them as-is (no staleness -//! UI in v1); `snapped_at` is stamped for the future. +//! Freshness is deliberately NOT one timestamp (that overload is exactly what +//! made an empty membership answer unreadable): `header_updated_at` says when +//! the list last named this playlist, `membership_synced_at` says when the +//! track ids were last authoritative, `membership_count` + +//! `membership_remote_updated_at` are the evidence a later list compares +//! against to decide staleness. `snapped_at` remains for the older offline +//! consumers and is stamped wherever it always was. //! -//! All functions take `&Connection` (the local_playlists idiom): no Tauri -//! state, no async runtime — testable with in-memory SQLite. +//! All functions take `&Connection` (the local_playlists idiom): no async +//! runtime — testable with in-memory SQLite. use std::collections::HashMap; @@ -31,20 +41,69 @@ pub struct SnapshotHeader { pub qobuz_playlist_id: u64, pub name: String, pub owner: Option<String>, - /// The playlist's TOTAL Qobuz track count at snapshot time (not the + /// The playlist's TOTAL Qobuz track count at header time (not the /// offline-playable subset). pub track_count: Option<u32>, - /// Unix ms when this header was last written. + /// Unix ms when this header was last written (legacy stamp; kept for the + /// offline consumers that predate the split freshness columns). pub snapped_at: i64, + /// Unix ms when an authoritative list last named this playlist. + pub header_updated_at: Option<i64>, + /// Unix ms when the membership rows were last authoritative. NULL means + /// membership was never captured — which is NOT "empty playlist". + pub membership_synced_at: Option<i64>, + /// Number of membership rows written at the last sync. + pub membership_count: Option<u32>, + /// The playlist's API `updated_at` as reported by the authoritative list. + pub remote_updated_at: Option<i64>, + /// The API `updated_at` in force when membership was last synced. + pub membership_remote_updated_at: Option<i64>, + /// Last authority generation that listed this playlist. + pub seen_generation: i64, + /// The session user owns (can write to) this playlist. Persisted + /// explicitly instead of being re-inferred from whichever response + /// happened to be loaded. + pub is_owned: bool, + /// Absent from two consecutive authoritative lists. Kept, not deleted, so + /// a Qobuz-side hiccup cannot erase local knowledge. + pub inactive: bool, } -/// Names-producer input (one listed playlist). +/// Authoritative-list producer input (one listed playlist). #[derive(Debug, Clone)] -pub struct SnapshotNameEntry { +pub struct AuthoritativeEntry { pub qobuz_playlist_id: u64, pub name: String, pub owner: Option<String>, pub track_count: Option<u32>, + pub remote_updated_at: Option<i64>, + pub is_owned: bool, +} + +/// Why the hydrator wants to refresh a playlist's membership. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StaleReason { + NeverSynced, + CountMismatch, + RemoteRevision, +} + +#[derive(Debug, Clone)] +pub struct HydrationCandidate { + pub qobuz_playlist_id: u64, + pub reason: StaleReason, +} + +/// The picker's honest tri-state for the containment section. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MembershipIndexState { + /// Every owned active playlist has authoritative membership. + Complete, + /// Some owned playlists still await a membership sync. + Updating { pending: u32 }, + /// No authoritative list was ever recorded (fresh profile, or the app has + /// never been online) — an empty containment answer means nothing. + Unavailable, } fn now_ms() -> i64 { @@ -55,8 +114,9 @@ fn now_ms() -> i64 { .as_millis() as i64 } -/// Create the snapshot tables. Idempotent (`IF NOT EXISTS`), run by -/// `LibraryDatabase::open` next to the rest of the schema. +/// Create the snapshot tables. Idempotent (`IF NOT EXISTS` + pragma-guarded +/// additive ALTERs, the database.rs idiom), run by `LibraryDatabase::open` +/// next to the rest of the schema. pub fn init_schema(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -76,44 +136,158 @@ pub fn init_schema(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_qobuz_playlist_snapshot_tracks ON qobuz_playlist_snapshot_tracks(qobuz_playlist_id, position); - "#, - ) -} -/// NAMES producer: upsert a header row for every listed playlist. Never -/// touches the membership table. Stamps `snapped_at` = now on each row. -pub fn upsert_names(conn: &Connection, entries: &[SnapshotNameEntry]) -> Result<()> { - if entries.is_empty() { - return Ok(()); - } - let ts = now_ms(); - let mut stmt = conn.prepare( - "INSERT INTO qobuz_playlist_snapshot - (qobuz_playlist_id, name, owner, track_count, snapped_at) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(qobuz_playlist_id) DO UPDATE SET - name = excluded.name, - owner = excluded.owner, - track_count = excluded.track_count, - snapped_at = excluded.snapped_at", + -- The picker's containment question runs in the opposite direction: + -- "which playlists hold THIS track". + CREATE INDEX IF NOT EXISTS idx_qobuz_playlist_snapshot_tracks_track + ON qobuz_playlist_snapshot_tracks(track_id, qobuz_playlist_id); + + CREATE TABLE IF NOT EXISTS qobuz_playlist_index_meta ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + ); + "#, )?; - for e in entries { - stmt.execute(params![ - e.qobuz_playlist_id as i64, - e.name, - e.owner, - e.track_count, - ts - ])?; + // Additive migration (2.1.1 membership index): the split freshness + // columns. One probe guards the whole batch — they ship together. + let has_freshness: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('qobuz_playlist_snapshot') + WHERE name = 'membership_synced_at'", + [], + |r| r.get::<_, i32>(0), + ) + .map(|count| count > 0) + .unwrap_or(false); + if !has_freshness { + conn.execute_batch( + "ALTER TABLE qobuz_playlist_snapshot ADD COLUMN header_updated_at INTEGER; + ALTER TABLE qobuz_playlist_snapshot ADD COLUMN membership_synced_at INTEGER; + ALTER TABLE qobuz_playlist_snapshot ADD COLUMN membership_count INTEGER; + ALTER TABLE qobuz_playlist_snapshot ADD COLUMN remote_updated_at INTEGER; + ALTER TABLE qobuz_playlist_snapshot ADD COLUMN membership_remote_updated_at INTEGER; + ALTER TABLE qobuz_playlist_snapshot ADD COLUMN seen_generation INTEGER NOT NULL DEFAULT 0; + ALTER TABLE qobuz_playlist_snapshot ADD COLUMN is_owned INTEGER NOT NULL DEFAULT 0; + ALTER TABLE qobuz_playlist_snapshot ADD COLUMN inactive INTEGER NOT NULL DEFAULT 0; + -- Backfill: the old single stamp was both header and membership + -- evidence; keep it as the header stamp everywhere, and as the + -- membership stamp only where membership rows actually exist. + UPDATE qobuz_playlist_snapshot + SET header_updated_at = snapped_at + WHERE header_updated_at IS NULL; + UPDATE qobuz_playlist_snapshot + SET membership_synced_at = snapped_at, + membership_count = ( + SELECT COUNT(*) FROM qobuz_playlist_snapshot_tracks t + WHERE t.qobuz_playlist_id + = qobuz_playlist_snapshot.qobuz_playlist_id) + WHERE membership_synced_at IS NULL + AND EXISTS ( + SELECT 1 FROM qobuz_playlist_snapshot_tracks t + WHERE t.qobuz_playlist_id + = qobuz_playlist_snapshot.qobuz_playlist_id);", + )?; } Ok(()) } +// ───────────────────────── Authority generations ───────────────────────── + +const GENERATION_KEY: &str = "authority_generation"; + +/// Last recorded authority generation; 0 when no authoritative list was ever +/// recorded. +pub fn current_generation(conn: &Connection) -> Result<i64> { + conn.query_row( + "SELECT value FROM qobuz_playlist_index_meta WHERE key = ?1", + params![GENERATION_KEY], + |r| r.get(0), + ) + .optional() + .map(|v| v.unwrap_or(0)) +} + +/// HEADERS producer: record one successful authoritative user-playlist list. +/// Upserts every entry, advances the authority generation, and marks headers +/// unseen for TWO consecutive generations `inactive` (the grace that +/// tolerates Qobuz post-write lag). Returns the new generation. +/// +/// An EMPTY list is still recorded (a user can genuinely delete every +/// playlist), but the caller must only invoke this for a fetch that +/// succeeded — a network failure must never masquerade as an empty library. +pub fn record_authoritative_list(conn: &Connection, entries: &[AuthoritativeEntry]) -> Result<i64> { + let ts = now_ms(); + conn.execute_batch("BEGIN IMMEDIATE")?; + let result = (|| -> Result<i64> { + let generation = current_generation(conn)? + 1; + { + let mut stmt = conn.prepare( + "INSERT INTO qobuz_playlist_snapshot + (qobuz_playlist_id, name, owner, track_count, snapped_at, + header_updated_at, remote_updated_at, seen_generation, + is_owned, inactive) + VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, ?7, ?8, 0) + ON CONFLICT(qobuz_playlist_id) DO UPDATE SET + name = excluded.name, + owner = excluded.owner, + track_count = excluded.track_count, + snapped_at = excluded.snapped_at, + header_updated_at = excluded.header_updated_at, + remote_updated_at = excluded.remote_updated_at, + seen_generation = excluded.seen_generation, + is_owned = excluded.is_owned, + inactive = 0", + )?; + for e in entries { + stmt.execute(params![ + e.qobuz_playlist_id as i64, + e.name, + e.owner, + e.track_count, + ts, + e.remote_updated_at, + generation, + e.is_owned, + ])?; + } + } + conn.execute( + "UPDATE qobuz_playlist_snapshot + SET inactive = 1 + WHERE inactive = 0 AND seen_generation <= ?1 - 2", + params![generation], + )?; + conn.execute( + "INSERT INTO qobuz_playlist_index_meta (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![GENERATION_KEY, generation], + )?; + Ok(generation) + })(); + match result { + Ok(generation) => { + conn.execute_batch("COMMIT")?; + Ok(generation) + } + Err(e) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(e) + } + } +} + +// ───────────────────────── Membership producers ───────────────────────── + /// MEMBERSHIP producer: full-replace the snapshot track ids of ONE playlist -/// (detail load) and refresh its header (name / owner / track_count / -/// snapped_at). Returns `false` (writing NOTHING) when the playlist has no -/// header row — i.e. it was never captured by the names producer, so it is -/// not one of the user's listed playlists. +/// (detail load or hydrator fetch) and refresh its header. Stamps +/// `membership_synced_at` / `membership_count`, and copies the HEADER's +/// current `remote_updated_at` into the membership evidence — never a stamp +/// from the fetch itself: `playlist/get` and `getUserPlaylists` do not report +/// comparable `updated_at` values, and trusting the fetch's made the queue +/// re-fetch the same playlist forever (smoke 2026-08-30). Returns `false` +/// (writing NOTHING) when the playlist has no header row — i.e. it was never +/// captured by the headers producer, so it is not one of the user's listed +/// playlists. pub fn replace_tracks( conn: &Connection, qobuz_playlist_id: u64, @@ -121,35 +295,299 @@ pub fn replace_tracks( owner: Option<&str>, track_ids: &[u64], ) -> Result<bool> { - let updated = conn.execute( - "UPDATE qobuz_playlist_snapshot - SET name = ?2, owner = ?3, track_count = ?4, snapped_at = ?5 - WHERE qobuz_playlist_id = ?1", - params![ - qobuz_playlist_id as i64, - name, - owner, - track_ids.len() as u32, - now_ms() - ], - )?; - if updated == 0 { + let ts = now_ms(); + conn.execute_batch("BEGIN IMMEDIATE")?; + let result = (|| -> Result<bool> { + let updated = conn.execute( + "UPDATE qobuz_playlist_snapshot + SET name = ?2, owner = ?3, track_count = ?4, snapped_at = ?5, + membership_synced_at = ?5, membership_count = ?4, + membership_remote_updated_at = remote_updated_at + WHERE qobuz_playlist_id = ?1", + params![ + qobuz_playlist_id as i64, + name, + owner, + track_ids.len() as u32, + ts, + ], + )?; + if updated == 0 { + return Ok(false); + } + conn.execute( + "DELETE FROM qobuz_playlist_snapshot_tracks WHERE qobuz_playlist_id = ?1", + params![qobuz_playlist_id as i64], + )?; + let mut stmt = conn.prepare( + "INSERT INTO qobuz_playlist_snapshot_tracks (qobuz_playlist_id, position, track_id) + VALUES (?1, ?2, ?3)", + )?; + for (pos, tid) in track_ids.iter().enumerate() { + stmt.execute(params![qobuz_playlist_id as i64, pos as i64, *tid as i64])?; + } + Ok(true) + })(); + match result { + Ok(wrote) => { + conn.execute_batch("COMMIT")?; + Ok(wrote) + } + Err(e) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(e) + } + } +} + +/// Incremental MUTATION producer: a successful `playlist/addTracks` appends +/// its ids to the snapshot so the containment answer moves immediately. +/// Applies only when membership was already captured (`membership_synced_at` +/// set) — an uncaptured playlist stays queued for the hydrator instead of +/// pretending an append equals a snapshot. Returns whether it applied. +pub fn apply_added_tracks( + conn: &Connection, + qobuz_playlist_id: u64, + track_ids: &[u64], +) -> Result<bool> { + if track_ids.is_empty() { return Ok(false); } + let ts = now_ms(); + conn.execute_batch("BEGIN IMMEDIATE")?; + let result = (|| -> Result<bool> { + let captured: Option<i64> = conn + .query_row( + "SELECT membership_synced_at FROM qobuz_playlist_snapshot + WHERE qobuz_playlist_id = ?1", + params![qobuz_playlist_id as i64], + |r| r.get(0), + ) + .optional()? + .flatten(); + if captured.is_none() { + return Ok(false); + } + let next: i64 = conn.query_row( + "SELECT COALESCE(MAX(position) + 1, 0) + FROM qobuz_playlist_snapshot_tracks + WHERE qobuz_playlist_id = ?1", + params![qobuz_playlist_id as i64], + |r| r.get(0), + )?; + let mut stmt = conn.prepare( + "INSERT INTO qobuz_playlist_snapshot_tracks (qobuz_playlist_id, position, track_id) + VALUES (?1, ?2, ?3)", + )?; + for (offset, tid) in track_ids.iter().enumerate() { + stmt.execute(params![ + qobuz_playlist_id as i64, + next + offset as i64, + *tid as i64 + ])?; + } + conn.execute( + "UPDATE qobuz_playlist_snapshot + SET membership_count = ( + SELECT COUNT(*) FROM qobuz_playlist_snapshot_tracks t + WHERE t.qobuz_playlist_id = ?1), + track_count = ( + SELECT COUNT(*) FROM qobuz_playlist_snapshot_tracks t + WHERE t.qobuz_playlist_id = ?1), + membership_synced_at = ?2, snapped_at = ?2 + WHERE qobuz_playlist_id = ?1", + params![qobuz_playlist_id as i64, ts], + )?; + Ok(true) + })(); + match result { + Ok(applied) => { + conn.execute_batch("COMMIT")?; + Ok(applied) + } + Err(e) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(e) + } + } +} + +/// Incremental MUTATION producer: a successful track removal drops every +/// membership row bearing the removed TRACK ids. Same capture precondition +/// as `apply_added_tracks`. +pub fn apply_removed_tracks( + conn: &Connection, + qobuz_playlist_id: u64, + track_ids: &[u64], +) -> Result<bool> { + if track_ids.is_empty() { + return Ok(false); + } + let ts = now_ms(); + conn.execute_batch("BEGIN IMMEDIATE")?; + let result = (|| -> Result<bool> { + let captured: Option<i64> = conn + .query_row( + "SELECT membership_synced_at FROM qobuz_playlist_snapshot + WHERE qobuz_playlist_id = ?1", + params![qobuz_playlist_id as i64], + |r| r.get(0), + ) + .optional()? + .flatten(); + if captured.is_none() { + return Ok(false); + } + { + let mut stmt = conn.prepare( + "DELETE FROM qobuz_playlist_snapshot_tracks + WHERE qobuz_playlist_id = ?1 AND track_id = ?2", + )?; + for tid in track_ids { + stmt.execute(params![qobuz_playlist_id as i64, *tid as i64])?; + } + } + conn.execute( + "UPDATE qobuz_playlist_snapshot + SET membership_count = ( + SELECT COUNT(*) FROM qobuz_playlist_snapshot_tracks t + WHERE t.qobuz_playlist_id = ?1), + track_count = ( + SELECT COUNT(*) FROM qobuz_playlist_snapshot_tracks t + WHERE t.qobuz_playlist_id = ?1), + membership_synced_at = ?2, snapped_at = ?2 + WHERE qobuz_playlist_id = ?1", + params![qobuz_playlist_id as i64, ts], + )?; + Ok(true) + })(); + match result { + Ok(applied) => { + conn.execute_batch("COMMIT")?; + Ok(applied) + } + Err(e) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(e) + } + } +} + +/// MUTATION producer: a playlist just created through QBZ. Inserts its header +/// at the CURRENT generation (it cannot have been in the last list yet — the +/// grace rule, not a fresh fetch, is what keeps it alive) with an empty, +/// captured membership, so an immediate follow-up add applies incrementally. +pub fn record_created_playlist( + conn: &Connection, + qobuz_playlist_id: u64, + name: &str, + owner: Option<&str>, +) -> Result<()> { + let ts = now_ms(); + let generation = current_generation(conn)?; conn.execute( - "DELETE FROM qobuz_playlist_snapshot_tracks WHERE qobuz_playlist_id = ?1", + "INSERT INTO qobuz_playlist_snapshot + (qobuz_playlist_id, name, owner, track_count, snapped_at, + header_updated_at, membership_synced_at, membership_count, + seen_generation, is_owned, inactive) + VALUES (?1, ?2, ?3, 0, ?4, ?4, ?4, 0, ?5, 1, 0) + ON CONFLICT(qobuz_playlist_id) DO UPDATE SET + name = excluded.name, + owner = excluded.owner, + seen_generation = excluded.seen_generation, + is_owned = 1, + inactive = 0", + params![qobuz_playlist_id as i64, name, owner, ts, generation], + )?; + Ok(()) +} + +/// MUTATION producer: a playlist deleted through QBZ leaves the target set +/// immediately instead of waiting out the two-generation grace. +pub fn mark_inactive(conn: &Connection, qobuz_playlist_id: u64) -> Result<()> { + conn.execute( + "UPDATE qobuz_playlist_snapshot SET inactive = 1 WHERE qobuz_playlist_id = ?1", params![qobuz_playlist_id as i64], )?; - let mut stmt = conn.prepare( - "INSERT INTO qobuz_playlist_snapshot_tracks (qobuz_playlist_id, position, track_id) - VALUES (?1, ?2, ?3)", - )?; - for (pos, tid) in track_ids.iter().enumerate() { - stmt.execute(params![qobuz_playlist_id as i64, pos as i64, *tid as i64])?; - } - Ok(true) + Ok(()) } +// ───────────────────────── Hydration planning ───────────────────────── + +/// The membership refreshes the hydrator still owes, oldest debt first. +/// Only owned, active playlists — followed/read-only playlists are +/// informational and must not spend the rate budget. +/// +/// Staleness is deliberately NOT "any evidence disagrees" (the 2026-08-30 +/// smoke found that fetching forever): a playlist is stale when +/// +/// - membership was never captured; or +/// - BOTH sides carry a remote revision and they differ — the authoritative +/// list said the playlist changed since the membership sync; or +/// - a revision comparison is impossible on either side, and the counts +/// disagree (the coarse fallback). +/// +/// A successful `replace_tracks` copies the header's current revision into +/// the membership evidence and equalises the counts, so every sync strictly +/// converges: only a NEW authoritative list can make a playlist stale again, +/// and then only the playlists it actually reports changed. +pub fn hydration_queue(conn: &Connection) -> Result<Vec<HydrationCandidate>> { + let mut stmt = conn.prepare( + "SELECT qobuz_playlist_id, + membership_synced_at IS NULL AS never_synced, + (membership_remote_updated_at IS NOT NULL + AND remote_updated_at IS NOT NULL + AND membership_remote_updated_at != remote_updated_at) + AS revision_mismatch + FROM qobuz_playlist_snapshot + WHERE is_owned = 1 AND inactive = 0 + AND (membership_synced_at IS NULL + OR (membership_remote_updated_at IS NOT NULL + AND remote_updated_at IS NOT NULL + AND membership_remote_updated_at != remote_updated_at) + OR ((membership_remote_updated_at IS NULL + OR remote_updated_at IS NULL) + AND membership_count IS NOT track_count)) + ORDER BY never_synced DESC, revision_mismatch DESC, qobuz_playlist_id", + )?; + let mut out = Vec::new(); + for r in stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)? as u64, + r.get::<_, bool>(1)?, + r.get::<_, bool>(2)?, + )) + })? { + let (pid, never, revision) = r?; + out.push(HydrationCandidate { + qobuz_playlist_id: pid, + reason: if never { + StaleReason::NeverSynced + } else if revision { + StaleReason::RemoteRevision + } else { + StaleReason::CountMismatch + }, + }); + } + Ok(out) +} + +/// The containment section's honest tri-state. +pub fn index_state(conn: &Connection) -> Result<MembershipIndexState> { + if current_generation(conn)? == 0 { + return Ok(MembershipIndexState::Unavailable); + } + let pending = hydration_queue(conn)?.len() as u32; + Ok(if pending == 0 { + MembershipIndexState::Complete + } else { + MembershipIndexState::Updating { pending } + }) +} + +// ───────────────────────────── Readers ───────────────────────────── + fn row_to_header(r: &rusqlite::Row) -> Result<SnapshotHeader> { Ok(SnapshotHeader { qobuz_playlist_id: r.get::<_, i64>("qobuz_playlist_id")? as u64, @@ -157,26 +595,40 @@ fn row_to_header(r: &rusqlite::Row) -> Result<SnapshotHeader> { owner: r.get("owner")?, track_count: r.get("track_count")?, snapped_at: r.get("snapped_at")?, + header_updated_at: r.get("header_updated_at")?, + membership_synced_at: r.get("membership_synced_at")?, + membership_count: r.get("membership_count")?, + remote_updated_at: r.get("remote_updated_at")?, + membership_remote_updated_at: r.get("membership_remote_updated_at")?, + seen_generation: r.get("seen_generation")?, + is_owned: r.get("is_owned")?, + inactive: r.get("inactive")?, }) } +const HEADER_COLUMNS: &str = "qobuz_playlist_id, name, owner, track_count, snapped_at, \ + header_updated_at, membership_synced_at, membership_count, \ + remote_updated_at, membership_remote_updated_at, seen_generation, \ + is_owned, inactive"; + /// One snapshot header, or None. pub fn get_header(conn: &Connection, qobuz_playlist_id: u64) -> Result<Option<SnapshotHeader>> { conn.query_row( - "SELECT qobuz_playlist_id, name, owner, track_count, snapped_at - FROM qobuz_playlist_snapshot WHERE qobuz_playlist_id = ?1", + &format!( + "SELECT {HEADER_COLUMNS} FROM qobuz_playlist_snapshot + WHERE qobuz_playlist_id = ?1" + ), params![qobuz_playlist_id as i64], row_to_header, ) .optional() } -/// All snapshot headers. +/// All snapshot headers, inactive ones included (offline consumers decide). pub fn all_headers(conn: &Connection) -> Result<Vec<SnapshotHeader>> { - let mut stmt = conn.prepare( - "SELECT qobuz_playlist_id, name, owner, track_count, snapped_at - FROM qobuz_playlist_snapshot", - )?; + let mut stmt = conn.prepare(&format!( + "SELECT {HEADER_COLUMNS} FROM qobuz_playlist_snapshot" + ))?; let mut out = Vec::new(); for r in stmt.query_map([], row_to_header)? { out.push(r?); @@ -191,9 +643,7 @@ pub fn track_ids(conn: &Connection, qobuz_playlist_id: u64) -> Result<Vec<u64>> WHERE qobuz_playlist_id = ?1 ORDER BY position", )?; let mut out = Vec::new(); - for r in stmt.query_map(params![qobuz_playlist_id as i64], |r| { - r.get::<_, i64>(0) - })? { + for r in stmt.query_map(params![qobuz_playlist_id as i64], |r| r.get::<_, i64>(0))? { out.push(r? as u64); } Ok(out) @@ -226,19 +676,25 @@ mod tests { c } - fn name_entry(id: u64, name: &str, count: u32) -> SnapshotNameEntry { - SnapshotNameEntry { + fn entry(id: u64, name: &str, count: u32) -> AuthoritativeEntry { + AuthoritativeEntry { qobuz_playlist_id: id, name: name.to_string(), owner: Some("me".to_string()), track_count: Some(count), + remote_updated_at: None, + is_owned: true, } } + fn record(c: &Connection, entries: &[AuthoritativeEntry]) -> i64 { + record_authoritative_list(c, entries).unwrap() + } + #[test] fn roundtrip_header_and_tracks() { let c = conn(); - upsert_names(&c, &[name_entry(42, "Road Trip", 3)]).unwrap(); + record(&c, &[entry(42, "Road Trip", 3)]); let wrote = replace_tracks(&c, 42, "Road Trip", Some("me"), &[30, 10, 20]).unwrap(); assert!(wrote); @@ -247,6 +703,10 @@ mod tests { assert_eq!(h.owner.as_deref(), Some("me")); assert_eq!(h.track_count, Some(3)); assert!(h.snapped_at > 0); + assert!(h.is_owned); + assert!(!h.inactive); + assert_eq!(h.membership_count, Some(3)); + assert!(h.membership_synced_at.is_some()); // Snapshot order preserved, not sorted by id. assert_eq!(track_ids(&c, 42).unwrap(), vec![30, 10, 20]); @@ -257,7 +717,7 @@ mod tests { #[test] fn replace_is_full_replace() { let c = conn(); - upsert_names(&c, &[name_entry(7, "Mix", 3)]).unwrap(); + record(&c, &[entry(7, "Mix", 3)]); replace_tracks(&c, 7, "Mix", None, &[1, 2, 3]).unwrap(); replace_tracks(&c, 7, "Mix renamed", None, &[9]).unwrap(); @@ -279,28 +739,244 @@ mod tests { #[test] fn names_only_rows_without_tracks() { let c = conn(); - upsert_names(&c, &[name_entry(1, "A", 10), name_entry(2, "B", 0)]).unwrap(); + record(&c, &[entry(1, "A", 10), entry(2, "B", 0)]); assert_eq!(all_headers(&c).unwrap().len(), 2); assert!(track_ids(&c, 1).unwrap().is_empty()); assert!(all_track_ids(&c).unwrap().is_empty()); - // Re-upserting updates the name in place without creating track rows. - upsert_names(&c, &[name_entry(1, "A renamed", 11)]).unwrap(); + // Re-recording updates the name in place without creating track rows, + // and never touches the membership stamp. + record(&c, &[entry(1, "A renamed", 11), entry(2, "B", 0)]); let h = get_header(&c, 1).unwrap().unwrap(); assert_eq!(h.name, "A renamed"); assert_eq!(h.track_count, Some(11)); + assert!(h.membership_synced_at.is_none()); assert!(track_ids(&c, 1).unwrap().is_empty()); } #[test] fn replace_refuses_unknown_playlist() { let c = conn(); - // No names row -> the detail producer writes NOTHING (a merely + // No header row -> the detail producer writes NOTHING (a merely // viewed public playlist must not land in the snapshot). let wrote = replace_tracks(&c, 99, "Someone's list", None, &[1, 2]).unwrap(); assert!(!wrote); assert!(get_header(&c, 99).unwrap().is_none()); assert!(track_ids(&c, 99).unwrap().is_empty()); } + + #[test] + fn header_upsert_is_not_membership_evidence() { + // The 2.1.0 research finding: header refreshes used to renew the one + // timestamp that also vouched for membership. Now they must not. + let c = conn(); + record(&c, &[entry(5, "Mix", 2)]); + replace_tracks(&c, 5, "Mix", None, &[1, 2]).unwrap(); + let synced = get_header(&c, 5).unwrap().unwrap().membership_synced_at; + + record(&c, &[entry(5, "Mix", 2)]); + let h = get_header(&c, 5).unwrap().unwrap(); + assert_eq!(h.membership_synced_at, synced); + assert_eq!(h.membership_count, Some(2)); + } + + #[test] + fn generations_and_two_generation_grace() { + let c = conn(); + assert_eq!(current_generation(&c).unwrap(), 0); + let g1 = record(&c, &[entry(1, "A", 0), entry(2, "B", 0)]); + assert_eq!(g1, 1); + + // B missing once: still active (post-write lag tolerance). + record(&c, &[entry(1, "A", 0)]); + assert!(!get_header(&c, 2).unwrap().unwrap().inactive); + + // B missing twice: inactive, but never deleted. + record(&c, &[entry(1, "A", 0)]); + let h = get_header(&c, 2).unwrap().unwrap(); + assert!(h.inactive); + + // B reappears: active again. + record(&c, &[entry(1, "A", 0), entry(2, "B", 0)]); + assert!(!get_header(&c, 2).unwrap().unwrap().inactive); + } + + #[test] + fn hydration_queue_reasons_and_order() { + let c = conn(); + let mut never = entry(1, "Never", 4); + never.remote_updated_at = Some(100); + // Revision unavailable on this one: the coarse count fallback governs. + let mut counted = entry(2, "Counted", 5); + counted.remote_updated_at = None; + let mut revised = entry(3, "Revised", 1); + revised.remote_updated_at = Some(100); + let mut fresh = entry(4, "Fresh", 1); + fresh.remote_updated_at = Some(100); + let followed = AuthoritativeEntry { + is_owned: false, + ..entry(5, "Followed", 9) + }; + record( + &c, + &[ + never.clone(), + counted.clone(), + revised.clone(), + fresh.clone(), + followed, + ], + ); + replace_tracks(&c, 2, "Counted", None, &[7]).unwrap(); + replace_tracks(&c, 3, "Revised", None, &[7]).unwrap(); + replace_tracks(&c, 4, "Fresh", None, &[8]).unwrap(); + // Next authoritative list: 2's declared count (5) disagrees with its + // membership (1) and it has no revision signal; 3 changed remotely. + revised.remote_updated_at = Some(200); + record(&c, &[never, counted, revised, fresh]); + + let queue = hydration_queue(&c).unwrap(); + let ids: Vec<u64> = queue.iter().map(|q| q.qobuz_playlist_id).collect(); + assert_eq!(ids, vec![1, 3, 2]); + assert_eq!(queue[0].reason, StaleReason::NeverSynced); + assert_eq!(queue[1].reason, StaleReason::RemoteRevision); + assert_eq!(queue[2].reason, StaleReason::CountMismatch); + + match index_state(&c).unwrap() { + MembershipIndexState::Updating { pending } => assert_eq!(pending, 3), + other => panic!("expected Updating, got {other:?}"), + } + } + + #[test] + fn successful_sync_settles_the_queue_even_when_the_list_count_lags() { + // The 2026-08-30 smoke: one playlist re-fetched forever. A sync must + // CONVERGE — with revision evidence on both sides equal, a lagging + // list count is the list's problem, not a reason to fetch again. + let c = conn(); + let mut e = entry(1, "Lagging", 10); + e.remote_updated_at = Some(100); + record(&c, &[e.clone()]); + replace_tracks(&c, 1, "Lagging", None, &[5, 6, 7]).unwrap(); + // The list still reports the stale count 10 and the same revision. + record(&c, &[e]); + assert!(hydration_queue(&c).unwrap().is_empty()); + assert_eq!(index_state(&c).unwrap(), MembershipIndexState::Complete); + } + + #[test] + fn only_the_playlists_the_list_reports_changed_requeue() { + let c = conn(); + let mut a = entry(1, "A", 2); + a.remote_updated_at = Some(100); + let mut b = entry(2, "B", 2); + b.remote_updated_at = Some(100); + record(&c, &[a.clone(), b.clone()]); + replace_tracks(&c, 1, "A", None, &[1, 2]).unwrap(); + replace_tracks(&c, 2, "B", None, &[3, 4]).unwrap(); + assert!(hydration_queue(&c).unwrap().is_empty()); + + // Only B changed remotely. + b.remote_updated_at = Some(200); + record(&c, &[a, b]); + let queue = hydration_queue(&c).unwrap(); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].qobuz_playlist_id, 2); + assert_eq!(queue[0].reason, StaleReason::RemoteRevision); + } + + #[test] + fn index_state_lifecycle() { + let c = conn(); + assert_eq!(index_state(&c).unwrap(), MembershipIndexState::Unavailable); + record(&c, &[entry(1, "A", 1)]); + assert!(matches!( + index_state(&c).unwrap(), + MembershipIndexState::Updating { pending: 1 } + )); + replace_tracks(&c, 1, "A", None, &[10]).unwrap(); + assert_eq!(index_state(&c).unwrap(), MembershipIndexState::Complete); + } + + #[test] + fn incremental_add_and_remove() { + let c = conn(); + record(&c, &[entry(1, "A", 2), entry(2, "Uncaptured", 3)]); + replace_tracks(&c, 1, "A", None, &[10, 20]).unwrap(); + + assert!(apply_added_tracks(&c, 1, &[30, 40]).unwrap()); + assert_eq!(track_ids(&c, 1).unwrap(), vec![10, 20, 30, 40]); + let h = get_header(&c, 1).unwrap().unwrap(); + assert_eq!(h.membership_count, Some(4)); + assert_eq!(h.track_count, Some(4)); + + assert!(apply_removed_tracks(&c, 1, &[20, 30]).unwrap()); + assert_eq!(track_ids(&c, 1).unwrap(), vec![10, 40]); + assert_eq!( + get_header(&c, 1).unwrap().unwrap().membership_count, + Some(2) + ); + + // A playlist whose membership was never captured refuses the + // increment — the hydrator owns it (its queue entry stays). + assert!(!apply_added_tracks(&c, 2, &[1]).unwrap()); + assert!(track_ids(&c, 2).unwrap().is_empty()); + assert!(hydration_queue(&c) + .unwrap() + .iter() + .any(|q| q.qobuz_playlist_id == 2)); + } + + #[test] + fn created_playlist_is_captured_and_survives_one_list_miss() { + let c = conn(); + record(&c, &[entry(1, "A", 0)]); + record_created_playlist(&c, 9, "Brand new", Some("me")).unwrap(); + + let h = get_header(&c, 9).unwrap().unwrap(); + assert!(h.is_owned); + assert_eq!(h.membership_count, Some(0)); + // Immediate add applies incrementally — no fetch needed. + assert!(apply_added_tracks(&c, 9, &[5]).unwrap()); + assert_eq!(track_ids(&c, 9).unwrap(), vec![5]); + + // One authoritative list that does not include it yet (Qobuz + // post-write lag): still active. + record(&c, &[entry(1, "A", 0)]); + assert!(!get_header(&c, 9).unwrap().unwrap().inactive); + } + + #[test] + fn legacy_schema_backfill() { + // A DB created by the pre-2.1.1 module: single-stamp schema. + let c = Connection::open_in_memory().unwrap(); + c.execute_batch( + "CREATE TABLE qobuz_playlist_snapshot ( + qobuz_playlist_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, owner TEXT, track_count INTEGER, + snapped_at INTEGER NOT NULL); + CREATE TABLE qobuz_playlist_snapshot_tracks ( + qobuz_playlist_id INTEGER NOT NULL, + position INTEGER NOT NULL, + track_id INTEGER NOT NULL); + INSERT INTO qobuz_playlist_snapshot VALUES (1, 'With tracks', NULL, 2, 111); + INSERT INTO qobuz_playlist_snapshot VALUES (2, 'Names only', NULL, 7, 222); + INSERT INTO qobuz_playlist_snapshot_tracks VALUES (1, 0, 10), (1, 1, 20);", + ) + .unwrap(); + init_schema(&c).unwrap(); + + let with_tracks = get_header(&c, 1).unwrap().unwrap(); + assert_eq!(with_tracks.header_updated_at, Some(111)); + assert_eq!(with_tracks.membership_synced_at, Some(111)); + assert_eq!(with_tracks.membership_count, Some(2)); + + let names_only = get_header(&c, 2).unwrap().unwrap(); + assert_eq!(names_only.header_updated_at, Some(222)); + assert!(names_only.membership_synced_at.is_none()); + + // Idempotent re-run. + init_schema(&c).unwrap(); + } } diff --git a/crates/qbz-library/src/reachability.rs b/crates/qbz-library/src/reachability.rs new file mode 100644 index 000000000..658f9a124 --- /dev/null +++ b/crates/qbz-library/src/reachability.rs @@ -0,0 +1,302 @@ +//! Reachability probes that cannot hang the caller. +//! +//! # Why this exists +//! +//! A network share that is MOUNTED BUT UNREACHABLE — the user is on a +//! different network today, the NAS is off, the VPN dropped — does not make +//! filesystem calls fail. It makes them **block**, for however long the mount +//! was configured to wait (an NFS hard mount waits forever). `Path::exists()`, +//! `read_dir()` and even `canonicalize()` are all syscalls, and all of them sit +//! there. +//! +//! That matters because the codebase already tries to defend against exactly +//! this case and cannot, since every guard probes with a bare blocking call: +//! +//! * `scan.rs` decides a folder is unreachable with `read_dir(..).is_err()` — +//! on a hung mount that never returns an error, it never returns at all; +//! * `mount_info::is_network_path()` canonicalizes the path it is classifying, +//! so the network detector hangs on the very thing it exists to detect; +//! * the local playback path awaits an `exists()` with no deadline, so +//! playing one dead file never resolves. +//! +//! # The two rules this module encodes +//! +//! **1. Deadline the PROBE, never the transfer.** Reading a hi-res FLAC over a +//! working-but-slow share legitimately takes many seconds; a blanket timeout +//! around the whole operation turns "your network is slow" into "this track +//! does not exist", which is worse than the bug. Only the question *does this +//! answer at all?* gets a deadline. +//! +//! **2. Probe a MOUNT once, not a file each time.** A blocking syscall cannot +//! be cancelled: when the deadline expires the helper thread is still stuck in +//! the kernel, and it stays stuck until the mount gives up. One leaked thread +//! per dead mount per cool-down is the price of asking at all. One per TRACK +//! would be a thread bomb on a 2000-row queue, which is how a defence turns +//! into the outage it was meant to prevent. + +use std::collections::HashMap; +use std::fs::Metadata; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// How long a mount stays marked unreachable before anyone probes it again. +/// Long enough that a dead NAS is asked about rarely; short enough that +/// reconnecting to the right network recovers without a restart. +const COOLDOWN: Duration = Duration::from_secs(30); + +/// The default probe deadline. The owner's spec: a couple of seconds, then +/// stop waiting and treat it as gone. +pub const DEFAULT_PROBE: Duration = Duration::from_secs(3); + +/// What a probe learned about a path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reach { + /// The filesystem answered and the file is there. + Present, + /// The filesystem answered and the file is not there. This is a REAL + /// answer — the caller may delete/clean on it. + Missing, + /// The filesystem did not answer inside the deadline, or answered with an + /// I/O error. NOT the same as missing: the file may be perfectly fine on a + /// share we cannot see from this network, so the caller should skip it, + /// never clean it. + Unreachable, +} + +/// A bounded metadata probe for callers that need to validate a regular file +/// without doing a second, potentially blocking filesystem syscall. +#[derive(Debug)] +pub enum FileProbe { + Present(Metadata), + Missing, + Unreachable, +} + +impl Reach { + /// Can playback proceed? + pub fn is_playable(self) -> bool { + self == Reach::Present + } +} + +fn unreachable_until() -> &'static Mutex<HashMap<PathBuf, Instant>> { + static MAP: OnceLock<Mutex<HashMap<PathBuf, Instant>>> = OnceLock::new(); + MAP.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// The key a path is remembered under: its first two components, which is a +/// good enough stand-in for "the mount" without calling into the filesystem to +/// find the real mount point (that lookup is itself a syscall on the path we +/// are trying not to touch). +fn mount_key(path: &Path) -> PathBuf { + path.components().take(3).collect() +} + +/// True when this path's mount answered too slowly recently and its cool-down +/// has not elapsed. Costs no syscall — that is the whole point. +pub fn is_cooling_down(path: &Path) -> bool { + let key = mount_key(path); + let mut map = match unreachable_until().lock() { + Ok(m) => m, + Err(_) => return false, + }; + match map.get(&key) { + Some(until) if Instant::now() < *until => true, + Some(_) => { + map.remove(&key); + false + } + None => false, + } +} + +fn mark_unreachable(path: &Path) { + if let Ok(mut map) = unreachable_until().lock() { + map.insert(mount_key(path), Instant::now() + COOLDOWN); + } +} + +/// Forget every cool-down. Call when connectivity changes — the user joined +/// the right network and should not wait out a timer for it. +pub fn reset_cooldowns() { + if let Ok(mut map) = unreachable_until().lock() { + map.clear(); + } +} + +/// Does `path` exist, answered within `deadline`? +/// +/// Blocking, but bounded: safe to call from `spawn_blocking` or a plain +/// thread. The probe itself runs on its own thread precisely because it may +/// never return; this function stops WAITING for it, which is all anyone can +/// do about a syscall stuck in the kernel. +pub fn probe(path: &Path, deadline: Duration) -> Reach { + if is_cooling_down(path) { + return Reach::Unreachable; + } + let (tx, rx) = std::sync::mpsc::channel(); + let probe_path = path.to_path_buf(); + // Detached on purpose: if it is wedged, joining it would re-introduce the + // hang this function exists to remove. + std::thread::Builder::new() + .name("qbz-reach-probe".to_string()) + .spawn(move || { + // `try_exists` rather than `exists`: it distinguishes "answered, + // not there" from "could not answer", which is exactly the + // distinction the caller needs to decide clean-vs-skip. + let _ = tx.send(probe_path.try_exists()); + }) + .ok(); + + match rx.recv_timeout(deadline) { + Ok(Ok(true)) => Reach::Present, + Ok(Ok(false)) => Reach::Missing, + Ok(Err(error)) => { + log::warn!( + "[reach] mount probe returned an I/O error — treating it as unreachable: {}", + error + ); + mark_unreachable(path); + Reach::Unreachable + } + Err(_) => { + log::warn!( + "[reach] mount probe did not answer in {:?} — treating it as unreachable for {:?}", + deadline, + COOLDOWN + ); + mark_unreachable(path); + Reach::Unreachable + } + } +} + +/// [`probe`] with the default deadline. +pub fn probe_default(path: &Path) -> Reach { + probe(path, DEFAULT_PROBE) +} + +/// Read metadata for `path`, bounded by `deadline`. +/// +/// Only `NotFound` is classified as [`FileProbe::Missing`]. Permission errors, +/// I/O failures, symlink loops, and timeouts preserve durable registry state by +/// returning [`FileProbe::Unreachable`]. +pub fn probe_file(path: &Path, deadline: Duration) -> FileProbe { + if is_cooling_down(path) { + return FileProbe::Unreachable; + } + let (tx, rx) = std::sync::mpsc::channel(); + let probe_path = path.to_path_buf(); + std::thread::Builder::new() + .name("qbz-file-probe".to_string()) + .spawn(move || { + let _ = tx.send(std::fs::metadata(probe_path)); + }) + .ok(); + + match rx.recv_timeout(deadline) { + Ok(Ok(metadata)) => FileProbe::Present(metadata), + Ok(Err(error)) if error.kind() == ErrorKind::NotFound => FileProbe::Missing, + Ok(Err(error)) => { + log::warn!( + "[reach] file metadata returned an I/O error — treating it as unreachable: {}", + error + ); + mark_unreachable(path); + FileProbe::Unreachable + } + Err(_) => { + log::warn!( + "[reach] file metadata did not answer in {:?} — treating it as unreachable for {:?}", + deadline, + COOLDOWN + ); + mark_unreachable(path); + FileProbe::Unreachable + } + } +} + +pub fn probe_file_default(path: &Path) -> FileProbe { + probe_file(path, DEFAULT_PROBE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_real_file_is_present() { + let f = std::env::temp_dir().join("qbz-reach-present"); + std::fs::write(&f, b"x").unwrap(); + assert_eq!(probe_default(&f), Reach::Present); + let _ = std::fs::remove_file(&f); + } + + #[test] + fn an_absent_file_is_missing_not_unreachable() { + // The distinction the caller acts on: Missing may be cleaned, + // Unreachable may only be skipped. + let f = std::env::temp_dir().join("qbz-reach-no-such-file-9e1f"); + assert_eq!(probe_default(&f), Reach::Missing); + } + + #[test] + fn a_cooling_mount_answers_without_touching_the_filesystem() { + let p = PathBuf::from("/mnt/qbz-test-dead/album/track.flac"); + mark_unreachable(&p); + assert!(is_cooling_down(&p)); + // A sibling under the SAME mount is covered by the one probe. + assert!(is_cooling_down(Path::new( + "/mnt/qbz-test-dead/other/x.flac" + ))); + assert_eq!(probe_default(&p), Reach::Unreachable); + reset_cooldowns(); + assert!(!is_cooling_down(&p)); + } + + #[test] + fn mount_key_groups_siblings_and_separates_mounts() { + assert_eq!( + mount_key(Path::new("/mnt/nas/a/b.flac")), + mount_key(Path::new("/mnt/nas/c/d.flac")) + ); + assert_ne!( + mount_key(Path::new("/mnt/nas/a.flac")), + mount_key(Path::new("/mnt/other/a.flac")) + ); + } + + #[cfg(unix)] + #[test] + fn an_io_error_is_unreachable_not_missing() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let loop_path = root.path().join("loop"); + symlink(&loop_path, &loop_path).unwrap(); + + assert_eq!(probe_default(&loop_path), Reach::Unreachable); + reset_cooldowns(); + } + + #[test] + fn file_probe_returns_metadata_and_only_not_found_is_missing() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("track.flac"); + std::fs::write(&file, b"audio").unwrap(); + match probe_file_default(&file) { + FileProbe::Present(metadata) => { + assert!(metadata.is_file()); + assert_eq!(metadata.len(), 5); + } + other => panic!("unexpected probe: {other:?}"), + } + assert!(matches!( + probe_file_default(&root.path().join("absent")), + FileProbe::Missing + )); + } +} diff --git a/crates/qbz-library/src/remote_tag_sidecar.rs b/crates/qbz-library/src/remote_tag_sidecar.rs new file mode 100644 index 000000000..04d99e278 --- /dev/null +++ b/crates/qbz-library/src/remote_tag_sidecar.rs @@ -0,0 +1,258 @@ +//! Profile-scoped metadata sidecars for media-server albums. +//! +//! Physical Local Library albums keep their `.qbz.json` beside the audio +//! files. Plex/Jellyfin/Subsonic albums have no writable local directory, so +//! their equivalent document lives in one small SQLite sidecar database in +//! the active user's data directory. The payload is deliberately the SAME +//! [`AlbumTagSidecar`] used by physical albums: the editor, validation and +//! future schema migrations therefore have one document rather than a remote +//! approximation of it. + +use std::path::Path; +use std::time::Duration; + +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::{AlbumTagSidecar, LibraryError}; + +/// Stable identity of one remote album. `source_instance` is the server's own +/// id (or `default` for old cache rows that predate it); `album_id` and every +/// track `file_path` inside the payload are source-native opaque ids. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RemoteTagTarget { + pub source: String, + pub source_instance: String, + pub album_id: String, +} + +impl RemoteTagTarget { + pub fn new( + source: impl Into<String>, + source_instance: impl Into<String>, + album_id: impl Into<String>, + ) -> Self { + let source_instance = source_instance.into(); + Self { + source: source.into(), + source_instance: if source_instance.trim().is_empty() { + "default".to_string() + } else { + source_instance + }, + album_id: album_id.into(), + } + } +} + +/// One decoded row used by the derived catalog's bounded bootstrap overlay. +#[derive(Debug, Clone)] +pub struct StoredRemoteTagSidecar { + pub target: RemoteTagTarget, + pub sidecar: AlbumTagSidecar, +} + +pub struct RemoteTagSidecarStore { + conn: Connection, +} + +impl RemoteTagSidecarStore { + pub fn open(path: &Path) -> Result<Self, LibraryError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(LibraryError::Io)?; + } + let conn = + Connection::open(path).map_err(|error| LibraryError::Database(error.to_string()))?; + conn.busy_timeout(Duration::from_secs(5)) + .map_err(|error| LibraryError::Database(error.to_string()))?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + CREATE TABLE IF NOT EXISTS remote_album_tag_sidecars ( + source TEXT NOT NULL, + source_instance TEXT NOT NULL, + album_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(source, source_instance, album_id) + ); + CREATE INDEX IF NOT EXISTS idx_remote_tag_sidecars_updated + ON remote_album_tag_sidecars(source, source_instance, updated_at);", + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + Ok(Self { conn }) + } + + pub fn get(&self, target: &RemoteTagTarget) -> Result<Option<AlbumTagSidecar>, LibraryError> { + let json = self + .conn + .query_row( + "SELECT payload_json FROM remote_album_tag_sidecars + WHERE source=?1 AND source_instance=?2 AND album_id=?3", + params![target.source, target.source_instance, target.album_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| LibraryError::Database(error.to_string()))?; + json.map(|json| { + serde_json::from_str(&json).map_err(|error| LibraryError::Metadata(error.to_string())) + }) + .transpose() + } + + /// Replace one complete album document atomically. A full payload is + /// intentional: track navigation edits one row in a validated snapshot, + /// then persists that snapshot just like the album editor does. + pub fn put( + &mut self, + target: &RemoteTagTarget, + sidecar: &AlbumTagSidecar, + ) -> Result<(), LibraryError> { + let json = serde_json::to_string(sidecar) + .map_err(|error| LibraryError::Metadata(error.to_string()))?; + let tx = self + .conn + .transaction() + .map_err(|error| LibraryError::Database(error.to_string()))?; + tx.execute( + "INSERT INTO remote_album_tag_sidecars( + source,source_instance,album_id,payload_json,updated_at + ) VALUES (?1,?2,?3,?4,?5) + ON CONFLICT(source,source_instance,album_id) DO UPDATE SET + payload_json=excluded.payload_json, + updated_at=excluded.updated_at", + params![ + target.source, + target.source_instance, + target.album_id, + json, + sidecar.updated_at + ], + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + tx.commit() + .map_err(|error| LibraryError::Database(error.to_string())) + } + + pub fn all(&self) -> Result<Vec<StoredRemoteTagSidecar>, LibraryError> { + let mut stmt = self + .conn + .prepare( + "SELECT source,source_instance,album_id,payload_json + FROM remote_album_tag_sidecars", + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(|error| LibraryError::Database(error.to_string()))?; + let mut out = Vec::new(); + for row in rows { + let (source, source_instance, album_id, json) = + row.map_err(|error| LibraryError::Database(error.to_string()))?; + let sidecar = match serde_json::from_str(&json) { + Ok(sidecar) => sidecar, + Err(error) => { + // One damaged optional overlay must not disable metadata + // edits for every other server album in the profile. The + // authoritative media caches remain readable; skip only + // the bad row and leave enough identity in the log to + // repair/remove it later. + log::warn!( + "[remote-tag-sidecar] ignoring corrupt row source={} instance={} album={}: {}", + source, + source_instance, + album_id, + error + ); + continue; + } + }; + out.push(StoredRemoteTagSidecar { + target: RemoteTagTarget::new(source, source_instance, album_id), + sidecar, + }); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AlbumMetadataOverride, TrackMetadataOverride}; + + #[test] + fn profile_sidecar_roundtrip_keeps_native_track_identity_and_clears() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata_sidecars.db"); + let target = RemoteTagTarget::new("subsonic", "navidrome-a", "album-9"); + let sidecar = AlbumTagSidecar::new( + AlbumMetadataOverride { + album_title: Some("Edited album".into()), + album_artist: Some(String::new()), + year: Some(0), + genre: Some("Progressive rock".into()), + catalog_number: Some("CAT-1".into()), + }, + vec![TrackMetadataOverride { + file_path: "native-track-4".into(), + cue_start_secs: None, + title: Some("Edited track".into()), + disc_number: Some(0), + track_number: Some(7), + }], + ); + + let mut store = RemoteTagSidecarStore::open(&path).unwrap(); + store.put(&target, &sidecar).unwrap(); + let read = store.get(&target).unwrap().unwrap(); + assert_eq!(read.album.album_title.as_deref(), Some("Edited album")); + assert_eq!(read.album.year, Some(0)); + assert_eq!(read.tracks[0].file_path, "native-track-4"); + assert_eq!(read.tracks[0].disc_number, Some(0)); + + let all = store.all().unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].target, target); + } + + #[test] + fn old_cache_rows_use_a_stable_default_server_identity() { + assert_eq!( + RemoteTagTarget::new("plex", "", "album").source_instance, + "default" + ); + } + + #[test] + fn one_corrupt_payload_does_not_hide_other_sidecars() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata_sidecars.db"); + let mut store = RemoteTagSidecarStore::open(&path).unwrap(); + let target = RemoteTagTarget::new("plex", "server", "good"); + store + .put( + &target, + &AlbumTagSidecar::new(AlbumMetadataOverride::default(), Vec::new()), + ) + .unwrap(); + store + .conn + .execute( + "INSERT INTO remote_album_tag_sidecars VALUES + ('plex','server','broken','{',1)", + [], + ) + .unwrap(); + + let rows = store.all().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].target, target); + } +} diff --git a/crates/qbz-library/src/sacd.rs b/crates/qbz-library/src/sacd.rs new file mode 100644 index 000000000..d1db6d426 --- /dev/null +++ b/crates/qbz-library/src/sacd.rs @@ -0,0 +1,1179 @@ +//! Persistent catalogue identity for tracks inside a SACD image. +//! +//! A SACD is not a directory and an `.iso` extension is not proof that an +//! image is a SACD. Discovery therefore stays explicit: the frontend first +//! parses the Scarlet Book TOC, then hands the complete, validated generation +//! to this module. Failed or partial reads never call this API and can never +//! prune the last known-good rows. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior}; + +use crate::{AudioFormat, LibraryDatabase, LibraryError, LocalTrack}; + +/// One completely parsed SACD image generation. +#[derive(Debug, Clone)] +pub struct SacdImageImport { + /// Stable geometry fingerprint supplied by the SACD parser. + pub fingerprint: String, + /// Native image path, without the `sacd:` virtual-track scheme. + pub image_path: String, + pub image_size_bytes: u64, + pub image_modified_ns: i64, + pub observed_at: i64, + /// Scan-cache schema/parser generation. Not part of the fingerprint. + pub parser_revision: i64, + /// Complete stereo-area track set. Each path must be + /// `sacd:<image_path>#<track_number>`. + pub tracks: Vec<LocalTrack>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SacdImportResult { + /// Authoritative `local_tracks.id`s in disc order. + pub track_ids: Vec<i64>, + pub inserted: usize, + pub updated: usize, + pub removed: usize, + /// A newer complete generation was already committed for this disc. + pub stale: bool, +} + +impl LibraryDatabase { + /// Mark a known image as seen by one registered library root, and report + /// whether its file facts and parser revision still match. Unknown paths + /// remain unowned until a complete parse is imported. + pub fn observe_sacd_image( + &self, + root_id: i64, + observed_generation: i64, + image_path: &str, + size_bytes: u64, + modified_ns: i64, + parser_revision: i64, + ) -> Result<bool, LibraryError> { + self.with_connection(|connection| { + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + ensure_registered_root_owns_path(&transaction, root_id, image_path)?; + let known = transaction + .query_row( + "SELECT fingerprint,image_size_bytes,image_modified_ns,parser_revision + FROM local_sacd_images WHERE image_path=?1", + params![image_path], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }, + ) + .optional() + .map_err(database_error)?; + let unchanged = + if let Some((fingerprint, known_size, known_mtime, known_revision)) = known { + observe_root(&transaction, root_id, &fingerprint, observed_generation)?; + known_size == to_i64(size_bytes) + && known_mtime == modified_ns + && known_revision == parser_revision + } else { + false + }; + transaction.commit().map_err(database_error)?; + Ok(unchanged) + }) + } + + /// Atomically adopt a fully parsed SACD image found by a registered Local + /// Library root. Requiring the root id and generation keeps this API out of + /// the manual/ephemeral open path by construction. + /// + /// The geometry fingerprint, not the file path, owns the row ids. Moving + /// an image and opening it again therefore preserves playlists/history + /// references. A successful shorter generation prunes obsolete virtual + /// tracks; validation or SQL failure rolls the whole operation back. + pub fn import_sacd_image( + &self, + root_id: i64, + observed_generation: i64, + import: &SacdImageImport, + ) -> Result<SacdImportResult, LibraryError> { + let ordered = validate_import(import)?; + self.with_connection(|connection| { + // Reserve the writer before reading the row-id high-water mark; + // two simultaneous explicit imports must serialize rather than + // allocate the same ids from a shared snapshot. + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + ensure_registered_root_owns_path(&transaction, root_id, &import.image_path)?; + let latest_observation = transaction + .query_row( + "SELECT MAX(observed_at) FROM local_sacd_images + WHERE fingerprint=?1 OR image_path=?2", + params![import.fingerprint, import.image_path], + |row| row.get::<_, Option<i64>>(0), + ) + .map_err(database_error)?; + if latest_observation.is_some_and(|latest| latest > import.observed_at) { + let track_ids = { + let mut statement = transaction + .prepare( + "SELECT local_track_id FROM local_sacd_tracks + WHERE fingerprint=?1 ORDER BY track_number", + ) + .map_err(database_error)?; + let rows = statement + .query_map(params![import.fingerprint], |row| row.get(0)) + .map_err(database_error)?; + rows.collect::<Result<Vec<_>, _>>() + .map_err(database_error)? + }; + let current_fingerprint = transaction + .query_row( + "SELECT fingerprint FROM local_sacd_images WHERE image_path=?1", + params![import.image_path], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)?; + if current_fingerprint.as_deref() == Some(import.fingerprint.as_str()) { + observe_root( + &transaction, + root_id, + &import.fingerprint, + observed_generation, + )?; + transaction.commit().map_err(database_error)?; + } else { + transaction.rollback().map_err(database_error)?; + } + return Ok(SacdImportResult { + track_ids, + inserted: 0, + updated: 0, + removed: 0, + stale: true, + }); + } + // Allocate above the pre-transaction high-water mark. SQLite may + // otherwise reuse the just-deleted maximum rowid when an image is + // replaced, silently making a playlist entry for the old disc + // point at a track on the new one. + let mut next_id: i64 = transaction + .query_row( + "SELECT COALESCE(MAX(id),0)+1 FROM local_tracks", + [], + |row| row.get(0), + ) + .map_err(database_error)?; + + // A successfully parsed new disc at an already-known path means + // the file was replaced. An absent/NAS-down image never reaches + // this method, so it cannot enter this prune arm. + let replaced: Vec<String> = { + let mut statement = transaction + .prepare( + "SELECT fingerprint FROM local_sacd_images + WHERE image_path=?1 AND fingerprint<>?2", + ) + .map_err(database_error)?; + let rows = statement + .query_map(params![import.image_path, import.fingerprint], |row| { + row.get(0) + }) + .map_err(database_error)?; + rows.collect::<Result<Vec<_>, _>>() + .map_err(database_error)? + }; + + let mut removed = 0usize; + for fingerprint in replaced { + removed += remove_generation(&transaction, &fingerprint)?; + } + + let mapped: HashMap<u32, i64> = { + let mut statement = transaction + .prepare( + "SELECT track_number,local_track_id FROM local_sacd_tracks + WHERE fingerprint=?1", + ) + .map_err(database_error)?; + let rows = statement + .query_map(params![import.fingerprint], |row| { + Ok((row.get::<_, u32>(0)?, row.get::<_, i64>(1)?)) + }) + .map_err(database_error)?; + rows.collect::<Result<HashMap<_, _>, _>>() + .map_err(database_error)? + }; + + let incoming_numbers = ordered + .iter() + .map(|(number, _)| *number) + .collect::<HashSet<_>>(); + for (number, id) in &mapped { + if !incoming_numbers.contains(number) { + transaction + .execute( + "DELETE FROM local_sacd_tracks + WHERE fingerprint=?1 AND track_number=?2", + params![import.fingerprint, number], + ) + .map_err(database_error)?; + removed += transaction + .execute("DELETE FROM local_tracks WHERE id=?1", params![id]) + .map_err(database_error)?; + } + } + + let mut result = SacdImportResult { + track_ids: Vec::with_capacity(ordered.len()), + inserted: 0, + updated: 0, + removed, + stale: false, + }; + + for (number, track) in ordered { + let existing = match mapped.get(&number).copied() { + Some(id) => Some(id), + None => transaction + .query_row( + "SELECT id FROM local_tracks + WHERE file_path=?1 AND cue_file_path IS NULL", + params![track.file_path], + |row| row.get(0), + ) + .optional() + .map_err(database_error)?, + }; + + let id = match existing { + Some(id) => { + update_track(&transaction, id, track)?; + result.updated += 1; + id + } + None => { + let id = insert_track(&transaction, next_id, track)?; + next_id = next_id.checked_add(1).ok_or_else(|| { + LibraryError::Database("local track id space exhausted".to_string()) + })?; + result.inserted += 1; + id + } + }; + transaction + .execute( + "INSERT INTO local_sacd_tracks + (fingerprint,track_number,local_track_id) + VALUES (?1,?2,?3) + ON CONFLICT(fingerprint,track_number) DO UPDATE SET + local_track_id=excluded.local_track_id", + params![import.fingerprint, number, id], + ) + .map_err(database_error)?; + result.track_ids.push(id); + } + + transaction + .execute( + "INSERT INTO local_sacd_images + (fingerprint,image_path,image_size_bytes,image_modified_ns,observed_at,parser_revision) + VALUES (?1,?2,?3,?4,?5,?6) + ON CONFLICT(fingerprint) DO UPDATE SET + image_path=excluded.image_path, + image_size_bytes=excluded.image_size_bytes, + image_modified_ns=excluded.image_modified_ns, + observed_at=excluded.observed_at, + parser_revision=excluded.parser_revision", + params![ + import.fingerprint, + import.image_path, + to_i64(import.image_size_bytes), + import.image_modified_ns, + import.observed_at, + import.parser_revision, + ], + ) + .map_err(database_error)?; + + observe_root( + &transaction, + root_id, + &import.fingerprint, + observed_generation, + )?; + + transaction.commit().map_err(database_error)?; + Ok(result) + }) + } + + /// Backfill root ownership for pre-fix indexed rows and remove generations + /// that are outside every configured Local Library folder. This is + /// intentionally idempotent: `LibraryDatabase::open` may run many times in + /// one Qt startup. + pub(crate) fn reconcile_sacd_catalog_scope(&self) -> Result<(), LibraryError> { + let (linked, unlinked, removed_images, removed_tracks) = self.with_connection( + |connection| -> Result<(usize, usize, usize, usize), LibraryError> { + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + let roots: Vec<(i64, String)> = { + let mut statement = transaction + .prepare("SELECT id,path FROM library_folders") + .map_err(database_error)?; + let rows = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(database_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(database_error)?; + rows + }; + let images: Vec<(String, String)> = { + let mut statement = transaction + .prepare("SELECT fingerprint,image_path FROM local_sacd_images") + .map_err(database_error)?; + let rows = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(database_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(database_error)?; + rows + }; + + let mut linked = 0usize; + for (fingerprint, image_path) in &images { + for (root_id, root_path) in &roots { + if path_is_within(image_path, root_path) { + linked += transaction + .execute( + "INSERT OR IGNORE INTO local_sacd_roots + (root_id,fingerprint,observed_generation) + VALUES (?1,?2,0)", + params![root_id, fingerprint], + ) + .map_err(database_error)?; + } + } + } + + let mappings: Vec<(i64, String, Option<String>, Option<String>)> = { + let mut statement = transaction + .prepare( + "SELECT owned.root_id,owned.fingerprint,folders.path,images.image_path + FROM local_sacd_roots owned + LEFT JOIN library_folders folders ON folders.id=owned.root_id + LEFT JOIN local_sacd_images images + ON images.fingerprint=owned.fingerprint", + ) + .map_err(database_error)?; + let rows = statement + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .map_err(database_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(database_error)?; + rows + }; + let mut unlinked = 0usize; + for (root_id, fingerprint, root_path, image_path) in mappings { + let valid = root_path + .as_deref() + .zip(image_path.as_deref()) + .is_some_and(|(root, image)| path_is_within(image, root)); + if !valid { + unlinked += transaction + .execute( + "DELETE FROM local_sacd_roots + WHERE root_id=?1 AND fingerprint=?2", + params![root_id, fingerprint], + ) + .map_err(database_error)?; + } + } + + let orphaned: Vec<String> = { + let mut statement = transaction + .prepare( + "SELECT images.fingerprint FROM local_sacd_images images + WHERE NOT EXISTS ( + SELECT 1 FROM local_sacd_roots owned + WHERE owned.fingerprint=images.fingerprint + )", + ) + .map_err(database_error)?; + let rows = statement + .query_map([], |row| row.get(0)) + .map_err(database_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(database_error)?; + rows + }; + let mut removed_tracks = 0usize; + for fingerprint in &orphaned { + removed_tracks += remove_generation(&transaction, fingerprint)?; + } + let removed_images = orphaned.len(); + transaction.commit().map_err(database_error)?; + Ok((linked, unlinked, removed_images, removed_tracks)) + }, + )?; + if linked > 0 || unlinked > 0 || removed_images > 0 { + log::info!( + "[sacd] catalogue scope reconciled: linked={linked} unlinked={unlinked} removed_images={removed_images} removed_tracks={removed_tracks}" + ); + } + Ok(()) + } + + /// Drop one root's ownership and remove only generations that no other + /// registered root owns. + pub(crate) fn remove_sacd_root(&self, root_id: i64) -> Result<usize, LibraryError> { + self.with_connection(|connection| { + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + let removed = remove_root_ownership(&transaction, root_id, None)?; + transaction.commit().map_err(database_error)?; + Ok(removed) + }) + } + + /// Complete one successful SACD walk: stale observations under this root + /// disappear, while overlapping roots retain their shared generations. + pub(crate) fn finish_sacd_root_scan( + &self, + root_id: i64, + observed_generation: i64, + ) -> Result<usize, LibraryError> { + self.with_connection(|connection| { + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + let removed = remove_root_ownership(&transaction, root_id, Some(observed_generation))?; + transaction.commit().map_err(database_error)?; + Ok(removed) + }) + } + + /// A known SACD path that now has a valid non-SACD signature was replaced; + /// remove its complete generation and every ownership edge. + pub(crate) fn remove_sacd_image_at_path( + &self, + image_path: &str, + ) -> Result<usize, LibraryError> { + self.with_connection(|connection| { + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + let fingerprint = transaction + .query_row( + "SELECT fingerprint FROM local_sacd_images WHERE image_path=?1", + params![image_path], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)?; + let removed = match fingerprint { + Some(fingerprint) => remove_generation(&transaction, &fingerprint)?, + None => 0, + }; + transaction.commit().map_err(database_error)?; + Ok(removed) + }) + } +} + +fn ensure_registered_root_owns_path( + transaction: &Transaction<'_>, + root_id: i64, + image_path: &str, +) -> Result<(), LibraryError> { + let root_path = transaction + .query_row( + "SELECT path FROM library_folders WHERE id=?1", + params![root_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)? + .ok_or_else(|| LibraryError::InvalidPath("SACD scan root is not registered".to_string()))?; + if !path_is_within(image_path, &root_path) { + return Err(LibraryError::InvalidPath(format!( + "SACD image is outside its registered scan root: {image_path}" + ))); + } + Ok(()) +} + +fn observe_root( + transaction: &Transaction<'_>, + root_id: i64, + fingerprint: &str, + observed_generation: i64, +) -> Result<(), LibraryError> { + transaction + .execute( + "INSERT INTO local_sacd_roots(root_id,fingerprint,observed_generation) + VALUES (?1,?2,?3) + ON CONFLICT(root_id,fingerprint) DO UPDATE SET + observed_generation=excluded.observed_generation", + params![root_id, fingerprint, observed_generation], + ) + .map_err(database_error)?; + Ok(()) +} + +fn remove_root_ownership( + transaction: &Transaction<'_>, + root_id: i64, + keep_generation: Option<i64>, +) -> Result<usize, LibraryError> { + let fingerprints: Vec<String> = if let Some(generation) = keep_generation { + let mut statement = transaction + .prepare( + "SELECT fingerprint FROM local_sacd_roots + WHERE root_id=?1 AND observed_generation<>?2", + ) + .map_err(database_error)?; + let rows = statement + .query_map(params![root_id, generation], |row| row.get(0)) + .map_err(database_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(database_error)?; + rows + } else { + let mut statement = transaction + .prepare("SELECT fingerprint FROM local_sacd_roots WHERE root_id=?1") + .map_err(database_error)?; + let rows = statement + .query_map(params![root_id], |row| row.get(0)) + .map_err(database_error)? + .collect::<Result<Vec<_>, _>>() + .map_err(database_error)?; + rows + }; + match keep_generation { + Some(generation) => transaction + .execute( + "DELETE FROM local_sacd_roots + WHERE root_id=?1 AND observed_generation<>?2", + params![root_id, generation], + ) + .map_err(database_error)?, + None => transaction + .execute( + "DELETE FROM local_sacd_roots WHERE root_id=?1", + params![root_id], + ) + .map_err(database_error)?, + }; + + let mut removed = 0usize; + for fingerprint in fingerprints { + let still_owned = transaction + .query_row( + "SELECT 1 FROM local_sacd_roots WHERE fingerprint=?1 LIMIT 1", + params![fingerprint], + |_| Ok(()), + ) + .optional() + .map_err(database_error)? + .is_some(); + if !still_owned { + removed += remove_generation(transaction, &fingerprint)?; + } + } + Ok(removed) +} + +fn path_is_within(path: &str, root: &str) -> bool { + !root.is_empty() && Path::new(path).starts_with(Path::new(root)) +} + +fn validate_import(import: &SacdImageImport) -> Result<Vec<(u32, &LocalTrack)>, LibraryError> { + if import.fingerprint.trim().is_empty() { + return Err(LibraryError::Other("SACD fingerprint is empty".to_string())); + } + if import.image_path.is_empty() { + return Err(LibraryError::InvalidPath( + "SACD image path is empty".to_string(), + )); + } + if import.tracks.is_empty() { + return Err(LibraryError::Other( + "SACD generation has no tracks".to_string(), + )); + } + + let mut seen = HashSet::with_capacity(import.tracks.len()); + let mut ordered = Vec::with_capacity(import.tracks.len()); + for track in &import.tracks { + let number = track + .track_number + .filter(|number| (1..=255).contains(number)) + .ok_or_else(|| LibraryError::Other("SACD track number is invalid".to_string()))?; + if !seen.insert(number) { + return Err(LibraryError::Other(format!( + "SACD generation repeats track {number}" + ))); + } + if track.format != AudioFormat::Dsd { + return Err(LibraryError::Other(format!( + "SACD track {number} is not DSD" + ))); + } + let (path, path_number) = parse_virtual_path(&track.file_path).ok_or_else(|| { + LibraryError::InvalidPath(format!("invalid SACD virtual track {number}")) + })?; + if path != import.image_path || path_number != number { + return Err(LibraryError::InvalidPath(format!( + "SACD virtual track {number} does not match its image" + ))); + } + ordered.push((number, track)); + } + ordered.sort_by_key(|(number, _)| *number); + Ok(ordered) +} + +fn parse_virtual_path(value: &str) -> Option<(&str, u32)> { + let rest = value.strip_prefix("sacd:")?; + let (path, number) = rest.rsplit_once('#')?; + (!path.is_empty()).then_some((path, number.parse().ok()?)) +} + +fn remove_generation( + transaction: &Transaction<'_>, + fingerprint: &str, +) -> Result<usize, LibraryError> { + let removed = transaction + .execute( + "DELETE FROM local_tracks WHERE id IN ( + SELECT local_track_id FROM local_sacd_tracks WHERE fingerprint=?1 + )", + params![fingerprint], + ) + .map_err(database_error)?; + transaction + .execute( + "DELETE FROM local_sacd_tracks WHERE fingerprint=?1", + params![fingerprint], + ) + .map_err(database_error)?; + transaction + .execute( + "DELETE FROM local_sacd_roots WHERE fingerprint=?1", + params![fingerprint], + ) + .map_err(database_error)?; + transaction + .execute( + "DELETE FROM local_sacd_images WHERE fingerprint=?1", + params![fingerprint], + ) + .map_err(database_error)?; + Ok(removed) +} + +fn update_track( + transaction: &Transaction<'_>, + id: i64, + track: &LocalTrack, +) -> Result<(), LibraryError> { + let format = track.format.to_string(); + let network = i64::from(track.is_network_mount); + let changed = transaction + .execute( + r#"UPDATE local_tracks SET + file_path=?1,title=?2,artist=?3,album=?4,album_artist=?5, + track_number=?6,disc_number=?7,year=?8,genre=?9,catalog_number=?10, + duration_secs=?11,format=?12,bit_depth=?13,sample_rate=?14, + channels=?15,file_size_bytes=?16,cue_file_path=NULL, + cue_start_secs=NULL,cue_end_secs=NULL,artwork_path=?17, + last_modified=?18,indexed_at=?19,album_group_key=?20, + album_group_title=?21,source='user',qobuz_track_id=NULL, + is_network_mount=?22 + WHERE id=?23"#, + params![ + track.file_path, + track.title, + track.artist, + track.album, + track.album_artist, + track.track_number, + track.disc_number, + track.year, + track.genre, + track.catalog_number, + track.duration_secs as i64, + format, + track.bit_depth, + track.sample_rate, + track.channels, + track.file_size_bytes as i64, + track.artwork_path, + track.last_modified, + track.indexed_at, + track.album_group_key, + track.album_group_title, + network, + id, + ], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(LibraryError::Database(format!( + "SACD mapped track {id} is missing" + ))); + } + Ok(()) +} + +fn insert_track( + transaction: &Transaction<'_>, + id: i64, + track: &LocalTrack, +) -> Result<i64, LibraryError> { + let format = track.format.to_string(); + let network = i64::from(track.is_network_mount); + transaction + .execute( + r#"INSERT INTO local_tracks + (id,file_path,title,artist,album,album_artist,track_number,disc_number, + year,genre,catalog_number,duration_secs,format,bit_depth,sample_rate, + channels,file_size_bytes,cue_file_path,cue_start_secs,cue_end_secs, + artwork_path,last_modified,indexed_at,album_group_key,album_group_title, + source,qobuz_track_id,is_network_mount) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15, + ?16,?17,NULL,NULL,NULL,?18,?19,?20,?21,?22,'user',NULL,?23)"#, + params![ + id, + track.file_path, + track.title, + track.artist, + track.album, + track.album_artist, + track.track_number, + track.disc_number, + track.year, + track.genre, + track.catalog_number, + track.duration_secs as i64, + format, + track.bit_depth, + track.sample_rate, + track.channels, + track.file_size_bytes as i64, + track.artwork_path, + track.last_modified, + track.indexed_at, + track.album_group_key, + track.album_group_title, + network, + ], + ) + .map_err(database_error)?; + Ok(id) +} + +fn to_i64(value: u64) -> i64 { + value.min(i64::MAX as u64) as i64 +} + +fn database_error(error: rusqlite::Error) -> LibraryError { + LibraryError::Database(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn fresh_db() -> (TempDir, LibraryDatabase, i64) { + let temp = TempDir::new().unwrap(); + let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); + let root_id = db + .add_folder_with_network_info("/music", false, None) + .unwrap(); + (temp, db, root_id) + } + + fn image(fingerprint: &str, path: &str, count: u32) -> SacdImageImport { + SacdImageImport { + fingerprint: fingerprint.to_string(), + image_path: path.to_string(), + image_size_bytes: 4_700_000_000, + image_modified_ns: 123, + observed_at: 456, + parser_revision: 2, + tracks: (1..=count) + .map(|number| LocalTrack { + file_path: format!("sacd:{path}#{number}"), + title: format!("Track {number}"), + artist: "Artist".to_string(), + album: "Album".to_string(), + album_artist: Some("Artist".to_string()), + album_group_key: "sacd|||Album".to_string(), + album_group_title: "Album".to_string(), + track_number: Some(number), + disc_number: Some(1), + duration_secs: 180, + format: AudioFormat::Dsd, + bit_depth: Some(1), + sample_rate: 2_822_400.0, + channels: 2, + indexed_at: 456, + ..Default::default() + }) + .collect(), + } + } + + fn seed_unowned_generation(db: &LibraryDatabase, import: &SacdImageImport) -> Vec<i64> { + let ids = import + .tracks + .iter() + .map(|track| db.insert_track(track).unwrap()) + .collect::<Vec<_>>(); + db.with_connection(|connection| { + connection + .execute( + "INSERT INTO local_sacd_images + (fingerprint,image_path,image_size_bytes,image_modified_ns, + observed_at,parser_revision) + VALUES (?1,?2,?3,?4,?5,?6)", + params![ + import.fingerprint, + import.image_path, + to_i64(import.image_size_bytes), + import.image_modified_ns, + import.observed_at, + import.parser_revision, + ], + ) + .unwrap(); + for (track, id) in import.tracks.iter().zip(&ids) { + connection + .execute( + "INSERT INTO local_sacd_tracks + (fingerprint,track_number,local_track_id) + VALUES (?1,?2,?3)", + params![import.fingerprint, track.track_number, id], + ) + .unwrap(); + } + }); + ids + } + + #[test] + fn catalogue_import_requires_a_registered_owning_root() { + let (_temp, db, root_id) = fresh_db(); + assert!(db + .import_sacd_image(9999, 1, &image("unregistered", "/music/disc.iso", 1)) + .is_err()); + assert!(db + .import_sacd_image(root_id, 1, &image("sibling", "/music-2/disc.iso", 1)) + .is_err()); + let rows: i64 = db + .with_connection(|connection| { + connection.query_row("SELECT COUNT(*) FROM local_sacd_images", [], |row| { + row.get(0) + }) + }) + .unwrap(); + assert_eq!(rows, 0); + } + + #[test] + fn scope_reconciliation_adopts_scanned_legacy_rows_and_purges_manual_ones() { + let (_temp, db, root_id) = fresh_db(); + let inside = image("inside", "/music/album/disc.iso", 2); + let outside = image("outside", "/downloads/opened.iso", 3); + let inside_ids = seed_unowned_generation(&db, &inside); + let outside_ids = seed_unowned_generation(&db, &outside); + + db.reconcile_sacd_catalog_scope().unwrap(); + + assert!(inside_ids + .iter() + .all(|id| db.get_track(*id).unwrap().is_some())); + assert!(outside_ids + .iter() + .all(|id| db.get_track(*id).unwrap().is_none())); + let state: (i64, i64, i64) = db + .with_connection(|connection| { + connection.query_row( + "SELECT + (SELECT COUNT(*) FROM local_sacd_images), + (SELECT COUNT(*) FROM local_sacd_roots WHERE root_id=?1), + (SELECT COUNT(*) FROM local_sacd_tracks)", + params![root_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + }) + .unwrap(); + assert_eq!(state, (1, 1, 2)); + } + + #[test] + fn removing_roots_respects_overlap_and_last_owner_removes_the_disc() { + let (_temp, db, outer_root) = fresh_db(); + let inner_root = db + .add_folder_with_network_info("/music/album", false, None) + .unwrap(); + let import = image("shared", "/music/album/disc.iso", 2); + let ids = db + .import_sacd_image(outer_root, 1, &import) + .unwrap() + .track_ids; + + db.remove_folder("/music").unwrap(); + assert!(ids.iter().all(|id| db.get_track(*id).unwrap().is_some())); + + db.remove_folder("/music/album").unwrap(); + assert!(ids.iter().all(|id| db.get_track(*id).unwrap().is_none())); + assert_ne!(inner_root, outer_root); + } + + #[test] + fn a_complete_root_generation_prunes_a_disappeared_image() { + let (_temp, db, root_id) = fresh_db(); + let ids = db + .import_sacd_image(root_id, 1, &image("gone", "/music/gone.iso", 2)) + .unwrap() + .track_ids; + + assert_eq!(db.finish_sacd_root_scan(root_id, 2).unwrap(), 2); + assert!(ids.iter().all(|id| db.get_track(*id).unwrap().is_none())); + } + + #[test] + fn import_is_persistent_and_reimporting_a_moved_image_keeps_row_ids() { + let (_temp, db, root_id) = fresh_db(); + let first = db + .import_sacd_image(root_id, 1, &image("sacd-a", "/music/old.iso", 3)) + .unwrap(); + assert_eq!((first.inserted, first.updated, first.removed), (3, 0, 0)); + db.add_local_track_to_playlist(7, first.track_ids[1], 0) + .unwrap(); + + let mut moved = image("sacd-a", "/nas/new.iso", 3); + moved.tracks[1].title = "Corrected".to_string(); + let moved_root = db + .add_folder_with_network_info("/nas", true, Some("nfs")) + .unwrap(); + let second = db.import_sacd_image(moved_root, 2, &moved).unwrap(); + assert_eq!((second.inserted, second.updated, second.removed), (0, 3, 0)); + assert_eq!(second.track_ids, first.track_ids); + assert_eq!( + db.get_track(first.track_ids[1]).unwrap().unwrap().title, + "Corrected" + ); + assert_eq!( + db.get_track(first.track_ids[1]).unwrap().unwrap().file_path, + "sacd:/nas/new.iso#2" + ); + let playlist_title: String = db + .with_connection(|connection| { + connection.query_row( + "SELECT t.title FROM playlist_local_tracks p + JOIN local_tracks t ON t.id=p.local_track_id + WHERE p.qobuz_playlist_id=7", + [], + |row| row.get(0), + ) + }) + .unwrap(); + assert_eq!(playlist_title, "Corrected"); + let source: String = db + .with_connection(|connection| { + connection.query_row( + "SELECT source FROM local_tracks WHERE id=?1", + params![first.track_ids[0]], + |row| row.get(0), + ) + }) + .unwrap(); + assert_eq!(source, "user", "the existing local router must own the row"); + } + + #[test] + fn a_parser_revision_invalidates_only_the_scan_cache() { + let (_temp, db, root_id) = fresh_db(); + let import = image("sacd-a", "/music/disc.iso", 2); + let ids = db.import_sacd_image(root_id, 1, &import).unwrap().track_ids; + + assert!(db + .observe_sacd_image( + root_id, + 2, + &import.image_path, + import.image_size_bytes, + import.image_modified_ns, + import.parser_revision, + ) + .unwrap()); + assert!(!db + .observe_sacd_image( + root_id, + 3, + &import.image_path, + import.image_size_bytes, + import.image_modified_ns, + import.parser_revision + 1, + ) + .unwrap()); + + let mut reparsed = import; + reparsed.parser_revision += 1; + reparsed.observed_at += 1; + assert_eq!( + db.import_sacd_image(root_id, 4, &reparsed) + .unwrap() + .track_ids, + ids + ); + } + + #[test] + fn only_a_complete_successful_generation_prunes_obsolete_tracks() { + let (_temp, db, root_id) = fresh_db(); + let first = db + .import_sacd_image(root_id, 1, &image("sacd-a", "/music/disc.iso", 4)) + .unwrap(); + + let mut invalid = image("sacd-a", "/music/disc.iso", 2); + invalid.tracks[1].file_path = "sacd:/different.iso#2".to_string(); + assert!(db.import_sacd_image(root_id, 2, &invalid).is_err()); + assert!(db.get_track(first.track_ids[3]).unwrap().is_some()); + + let shorter = db + .import_sacd_image(root_id, 3, &image("sacd-a", "/music/disc.iso", 2)) + .unwrap(); + assert_eq!(shorter.removed, 2); + assert_eq!(shorter.track_ids, first.track_ids[..2]); + assert!(db.get_track(first.track_ids[2]).unwrap().is_none()); + assert!(db.get_track(first.track_ids[3]).unwrap().is_none()); + } + + #[test] + fn an_older_completed_snapshot_cannot_overwrite_a_newer_correction() { + let (_temp, db, root_id) = fresh_db(); + let mut newer = image("sacd-a", "/music/disc.iso", 2); + newer.observed_at = 200; + newer.tracks[0].title = "New naming".to_string(); + db.import_sacd_image(root_id, 2, &newer).unwrap(); + + let mut older = image("sacd-a", "/music/disc.iso", 2); + older.observed_at = 100; + older.tracks[0].title = "Late old naming".to_string(); + let outcome = db.import_sacd_image(root_id, 1, &older).unwrap(); + + assert!(outcome.stale); + assert_eq!( + (outcome.inserted, outcome.updated, outcome.removed), + (0, 0, 0) + ); + assert_eq!( + db.get_track(outcome.track_ids[0]).unwrap().unwrap().title, + "New naming" + ); + } + + #[test] + fn a_successfully_parsed_replacement_at_the_same_path_replaces_the_old_disc() { + let (_temp, db, root_id) = fresh_db(); + let old = db + .import_sacd_image(root_id, 1, &image("sacd-old", "/music/disc.iso", 2)) + .unwrap(); + let new = db + .import_sacd_image(root_id, 2, &image("sacd-new", "/music/disc.iso", 3)) + .unwrap(); + + assert_eq!(new.removed, 2); + assert!( + new.track_ids.iter().all(|id| *id > old.track_ids[1]), + "a new disc must never inherit a removed disc's row ids" + ); + for id in old.track_ids { + assert!(db.get_track(id).unwrap().is_none()); + } + let old_images: i64 = db + .with_connection(|connection| { + connection.query_row( + "SELECT COUNT(*) FROM local_sacd_images WHERE fingerprint='sacd-old'", + [], + |row| row.get(0), + ) + }) + .unwrap(); + assert_eq!(old_images, 0); + } + + #[test] + fn clear_library_removes_tracks_and_sacd_identity_together() { + let (_temp, db, root_id) = fresh_db(); + db.import_sacd_image(root_id, 1, &image("sacd-a", "/music/disc.iso", 2)) + .unwrap(); + db.clear_all_tracks().unwrap(); + + let counts: (i64, i64, i64, i64) = db + .with_connection(|connection| { + connection.query_row( + "SELECT + (SELECT COUNT(*) FROM local_tracks), + (SELECT COUNT(*) FROM local_sacd_images), + (SELECT COUNT(*) FROM local_sacd_tracks), + (SELECT COUNT(*) FROM local_sacd_roots)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + }) + .unwrap(); + assert_eq!(counts, (0, 0, 0, 0)); + } + + #[test] + fn the_full_sacd_track_number_domain_imports_without_a_hidden_cap() { + let (_temp, db, root_id) = fresh_db(); + let started = std::time::Instant::now(); + let result = db + .import_sacd_image(root_id, 1, &image("sacd-max", "/music/max.iso", 255)) + .unwrap(); + let sqlite_bytes: i64 = db + .with_connection(|connection| { + connection.query_row( + "SELECT + (SELECT page_size FROM pragma_page_size) * + (SELECT page_count FROM pragma_page_count)", + [], + |row| row.get(0), + ) + }) + .unwrap(); + eprintln!( + "sacd_import_metric tracks=255 sqlite_bytes={sqlite_bytes} elapsed_us={}", + started.elapsed().as_micros() + ); + assert_eq!(result.inserted, 255); + assert_eq!(result.track_ids.len(), 255); + assert_eq!( + db.get_track(result.track_ids[254]) + .unwrap() + .unwrap() + .track_number, + Some(255) + ); + } +} diff --git a/crates/qbz-library/src/sacd_scan.rs b/crates/qbz-library/src/sacd_scan.rs new file mode 100644 index 000000000..64e0be45c --- /dev/null +++ b/crates/qbz-library/src/sacd_scan.rs @@ -0,0 +1,580 @@ +//! SACD images in the library: one builder turns a parsed stereo area into +//! the virtual `sacd:<image>#<n>` rows, and one pass finds the images a +//! scanned root holds. +//! +//! The folder scan never treats an `.iso` as audio. It keys the walk on the +//! extension, asks [`qbz_disc::sacd::is_sacd_image`] for the `SACDMTOC` +//! signature, and only then parses the Scarlet Book TOC — so a DVD or a data +//! ISO sitting next to the music costs two sector reads and leaves no trace. +//! A disc already imported with the same size and mtime is skipped before +//! any file is opened. Everything that reaches the database goes through the +//! generation-based [`LibraryDatabase::import_sacd_image`], which is what +//! keeps a half-read or NAS-down image from pruning known-good rows. +//! +//! The manual `Open › disc image` path (qbz-qt `sacd_qt`) uses the same +//! builder, with translated fallback labels; the scan uses the English ones, +//! which only ever show for an image whose Master TOC carries no text. + +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; + +use qbz_disc::sacd::{read_area, SacdError, SacdSniff}; + +use crate::{AudioFormat, LibraryDatabase, LocalTrack, MetadataExtractor, SacdImageImport}; + +/// Bump when a successful TOC parse can expose a different complete snapshot. +/// This invalidates only the scan cache; disc identity remains geometry-only. +pub const SACD_PARSER_REVISION: i64 = 2; + +/// Fallback naming for an image whose Master TOC carries no text. +#[derive(Debug, Clone)] +pub struct SacdLabels { + /// Album title when neither the disc nor the file name gives one. + pub album: String, + /// Track title pattern; `{}` is replaced by the track number. + pub track: String, +} + +impl Default for SacdLabels { + fn default() -> Self { + Self { + album: "SACD".to_string(), + track: "Track {}".to_string(), + } + } +} + +/// What the filesystem says about the image file itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImageFacts { + pub size_bytes: u64, + pub last_modified: i64, + pub modified_ns: i64, + pub is_network_mount: bool, +} + +pub fn image_facts(path: &Path) -> ImageFacts { + let metadata = std::fs::metadata(path).ok(); + let modified = metadata + .as_ref() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()); + ImageFacts { + size_bytes: metadata + .as_ref() + .map(|metadata| metadata.len()) + .unwrap_or(0), + last_modified: modified + .as_ref() + .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64) + .unwrap_or(0), + modified_ns: modified + .map(|duration| duration.as_nanos().min(i64::MAX as u128) as i64) + .unwrap_or(0), + is_network_mount: crate::is_network_path(path), + } +} + +/// Wall-clock nanoseconds made strictly monotonic inside this process. The DB +/// compares this token while holding an IMMEDIATE transaction, so two +/// imports finishing out of order cannot publish old rows over newer ones. A +/// fresh process naturally starts above its prior token. +pub fn observation_token() -> i64 { + static LAST: AtomicI64 = AtomicI64::new(0); + let wall = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos().min(i64::MAX as u128) as i64) + .unwrap_or(1); + let mut seen = LAST.load(Ordering::Acquire); + loop { + let next = wall.max(seen.saturating_add(1)); + match LAST.compare_exchange_weak(seen, next, Ordering::AcqRel, Ordering::Acquire) { + Ok(_) => return next, + Err(actual) => seen = actual, + } + } +} + +fn unix_now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64) + .unwrap_or(0) +} + +/// A parsed image, ready for the catalogue and for an ephemeral session. +#[derive(Debug, Clone)] +pub struct SacdImageRows { + pub fingerprint: String, + pub album: String, + pub artist: Option<String>, + pub total_playtime_secs: f64, + pub tracks: Vec<LocalTrack>, + pub import: SacdImageImport, +} + +/// Parse an image and build its rows. A SACD names itself, so nothing here +/// touches the network; the remembered disc memory (a human correction) wins +/// over the disc's own text, which wins over the file name, which wins over +/// the labels. Cover art is whatever sits beside the image, through the same +/// folder walk a scanned album uses. +pub fn build_image_rows(path: &Path, labels: &SacdLabels) -> Result<SacdImageRows, SacdError> { + let area = read_area(path)?; + let fingerprint = area.fingerprint(); + + let remembered = qbz_disc::store::get(&fingerprint).filter(|memory| memory.edited); + if remembered.is_some() { + log::info!("[sacd] corrected by hand — using the remembered naming"); + } + + let album = match remembered + .as_ref() + .filter(|memory| !memory.album.is_empty()) + { + Some(memory) => memory.album.clone(), + None => area.album.clone().unwrap_or_else(|| { + path.file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_else(|| labels.album.clone()) + }), + }; + let artist = match remembered + .as_ref() + .filter(|memory| !memory.album_artist.is_empty()) + { + Some(memory) => Some(memory.album_artist.clone()), + None => area.artist.clone().filter(|artist| !artist.is_empty()), + }; + + let artwork = MetadataExtractor::find_folder_artwork(path, Some(&album)).and_then(|found| { + MetadataExtractor::cache_artwork_file(Path::new(&found), &crate::get_artwork_cache_dir()) + }); + match artwork.as_deref() { + Some(_) => log::info!("[sacd] cover cached"), + None => log::info!("[sacd] no cover beside the image"), + } + + let facts = image_facts(path); + let indexed_at = unix_now_secs(); + let tracks: Vec<LocalTrack> = area + .tracks + .iter() + .enumerate() + .map(|(i, track)| LocalTrack { + // A corrected title wins over the disc's own; the disc's own wins + // over the number. Indexed defensively — a remembered row can be + // shorter than the disc if it was written for a different area. + title: remembered + .as_ref() + .and_then(|memory| memory.tracks.get(i)) + .map(|row| row.title.clone()) + .filter(|title| !title.is_empty()) + .or_else(|| track.title.clone().filter(|title| !title.is_empty())) + .unwrap_or_else(|| labels.track.replace("{}", &track.number.to_string())), + album: album.clone(), + album_group_title: album.clone(), + // Geometry, not mutable naming, is the album identity. This also + // keeps two same-title discs separate and survives a correction. + album_group_key: format!("sacd|||{fingerprint}"), + artist: remembered + .as_ref() + .and_then(|memory| memory.tracks.get(i)) + .map(|row| row.artist.clone()) + .filter(|artist| !artist.is_empty()) + .or_else(|| artist.clone()) + .unwrap_or_default(), + album_artist: artist.clone(), + track_number: Some(track.number as u32), + disc_number: Some(1), + duration_secs: track.duration_secs as u64, + // DSD64 stereo. `bit_depth` is the format's nominal 1, and the + // rate is the DSD bit rate — the same shape a .dsf row carries, + // which is what makes the quality badge read DSD64. + sample_rate: 2_822_400.0, + bit_depth: Some(1), + format: AudioFormat::Dsd, + artwork_path: artwork.clone(), + last_modified: facts.last_modified, + indexed_at, + source: Some("user".to_string()), + is_network_mount: facts.is_network_mount, + file_path: qbz_disc::SacdRef { + image: path.to_path_buf(), + track: track.number, + } + .to_path_string(), + ..Default::default() + }) + .collect(); + + // Remember what the DISC said, so the metadata button has a baseline to + // show and the rip wizard has defaults. `put_auto` will not touch a row a + // human corrected — that rule lives in the store, not here. + qbz_disc::store::put_auto( + &fingerprint, + None, + &qbz_disc::store::DiscMemory { + album: album.clone(), + album_artist: artist.clone().unwrap_or_default(), + year: None, + tracks: tracks + .iter() + .enumerate() + .map(|(i, track)| qbz_disc::store::TrackMemory { + number: track.track_number.unwrap_or(i as u32 + 1), + title: track.title.clone(), + artist: track.artist.clone(), + }) + .collect(), + release_id: None, + release_group_id: None, + cover_path: artwork.clone(), + edited: false, + }, + ); + + let import = SacdImageImport { + fingerprint: fingerprint.clone(), + image_path: path.to_string_lossy().into_owned(), + image_size_bytes: facts.size_bytes, + image_modified_ns: facts.modified_ns, + observed_at: observation_token(), + parser_revision: SACD_PARSER_REVISION, + tracks: tracks.clone(), + }; + Ok(SacdImageRows { + fingerprint, + album, + artist, + total_playtime_secs: area.total_playtime_secs, + tracks, + import, + }) +} + +/// What one root's SACD pass did. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SacdScanSummary { + /// `.iso` files seen. + pub candidates: u32, + /// Discs parsed and (re)imported. + pub imported: u32, + /// Known discs skipped on size + mtime, or an import the DB called stale. + pub unchanged: u32, + /// Files without the `SACDMTOC` signature — silently left alone. + pub ignored: u32, + /// Persistent virtual tracks removed after a complete root walk. + pub removed: u32, + /// True only when the complete walk reached its ownership prune. + pub prune_authorized: bool, + /// Real SACDs the parser or the import rejected: (path, reason). + pub failed: Vec<(String, String)>, +} + +fn is_iso(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("iso")) +} + +/// Find and import the SACD images under one root. Runs after the root's +/// audio/cue passes; nothing here touches `local_scan_files`. A complete walk +/// prunes images no longer present under this root, while cancellation or a +/// traversal error preserves the last known-good rows. +pub fn scan_root_for_sacd( + db: &LibraryDatabase, + root_id: i64, + observed_generation: i64, + root: &Path, + labels: &SacdLabels, + cancel: &AtomicBool, +) -> SacdScanSummary { + let mut summary = SacdScanSummary::default(); + let mut traversal_complete = true; + let walk = walkdir::WalkDir::new(root) + .follow_links(true) + .sort_by_file_name() + .into_iter(); + for entry in walk { + if cancel.load(Ordering::Acquire) { + break; + } + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + log::debug!("[sacd] scan walk error: {error}"); + traversal_complete = false; + continue; + } + }; + if !entry.file_type().is_file() || !is_iso(entry.path()) { + continue; + } + summary.candidates = summary.candidates.saturating_add(1); + let path = entry.path(); + let path_string = path.to_string_lossy().into_owned(); + + // Known and unchanged: no file I/O at all. + let facts = image_facts(path); + match db.observe_sacd_image( + root_id, + observed_generation, + &path_string, + facts.size_bytes, + facts.modified_ns, + SACD_PARSER_REVISION, + ) { + Ok(true) => { + summary.unchanged = summary.unchanged.saturating_add(1); + continue; + } + Ok(false) => {} + Err(error) => { + traversal_complete = false; + summary.failed.push((path_string, error.to_string())); + continue; + } + } + + // Signature first: a DVD or a data ISO leaves here silently. + match qbz_disc::sacd::sniff_sacd_image(path) { + Ok(SacdSniff::Sacd) => {} + Ok(SacdSniff::NotSacd) => { + summary.ignored = summary.ignored.saturating_add(1); + match db.remove_sacd_image_at_path(&path_string) { + Ok(removed) => { + summary.removed = summary + .removed + .saturating_add(removed.min(u32::MAX as usize) as u32); + } + Err(error) => { + traversal_complete = false; + summary.failed.push((path_string, error.to_string())); + } + } + continue; + } + Err(error) => { + log::warn!("[sacd] scan: signed image has an invalid Master TOC: {error}"); + summary.failed.push((path_string, error.to_string())); + continue; + } + } + + match build_image_rows(path, labels) { + Ok(rows) => match db.import_sacd_image(root_id, observed_generation, &rows.import) { + Ok(result) if result.stale => { + summary.unchanged = summary.unchanged.saturating_add(1); + } + Ok(result) => { + summary.imported = summary.imported.saturating_add(1); + log::info!( + "[sacd] scan adopted {:?}: tracks={} inserted={} updated={} removed={}", + rows.album, + rows.tracks.len(), + result.inserted, + result.updated, + result.removed + ); + } + Err(error) => summary.failed.push((path_string, error.to_string())), + }, + Err(error) => { + log::warn!("[sacd] scan: image unusable: {error}"); + summary.failed.push((path_string, error.to_string())); + } + } + } + if !cancel.load(Ordering::Acquire) && traversal_complete { + match db.finish_sacd_root_scan(root_id, observed_generation) { + Ok(removed) => { + summary.removed = summary + .removed + .saturating_add(removed.min(u32::MAX as usize) as u32); + summary.prune_authorized = true; + } + Err(error) => summary + .failed + .push((root.to_string_lossy().into_owned(), error.to_string())), + } + } + summary +} + +#[cfg(test)] +mod tests { + use super::{is_iso, scan_root_for_sacd, SacdLabels, SacdScanSummary}; + use crate::LibraryDatabase; + use std::io::{Seek, SeekFrom, Write}; + use std::sync::atomic::AtomicBool; + use tempfile::TempDir; + + /// A file that passes the ISO 9660 PVD check but carries no Master TOC: + /// what a DVD or a data image looks like to the sniff. + fn write_plain_iso(path: &std::path::Path) { + let mut file = std::fs::File::create(path).unwrap(); + file.seek(SeekFrom::Start(16 * 2048)).unwrap(); + let mut pvd = [0u8; 2048]; + pvd[0] = 1; + pvd[1..6].copy_from_slice(b"CD001"); + pvd[128] = 0; + pvd[129] = 8; + file.write_all(&pvd).unwrap(); + file.seek(SeekFrom::Start(531 * 2048)).unwrap(); + file.write_all(&[0u8; 2048]).unwrap(); + } + + #[test] + fn extension_match_is_case_insensitive_and_exact() { + assert!(is_iso(std::path::Path::new("/x/Disc.ISO"))); + assert!(is_iso(std::path::Path::new("/x/disc.iso"))); + assert!(!is_iso(std::path::Path::new("/x/disc.iso.txt"))); + assert!(!is_iso(std::path::Path::new("/x/disc.flac"))); + } + + #[test] + fn non_sacd_images_are_ignored_without_rows_or_errors() { + let temp = TempDir::new().unwrap(); + let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); + let root = temp.path().join("music"); + std::fs::create_dir_all(root.join("Some DVD")).unwrap(); + let root_id = db + .add_folder_with_network_info(&root.to_string_lossy(), false, None) + .unwrap(); + write_plain_iso(&root.join("Some DVD/movie.iso")); + std::fs::write(root.join("not-an-image.iso"), b"just bytes").unwrap(); + std::fs::write(root.join("song.flac"), b"").unwrap(); + + let summary = scan_root_for_sacd( + &db, + root_id, + 1, + &root, + &SacdLabels::default(), + &AtomicBool::new(false), + ); + assert_eq!( + summary, + SacdScanSummary { + candidates: 2, + imported: 0, + unchanged: 0, + ignored: 2, + removed: 0, + prune_authorized: true, + failed: Vec::new(), + } + ); + assert!(!db + .observe_sacd_image( + root_id, + 2, + &root.join("Some DVD/movie.iso").to_string_lossy(), + 0, + 0, + super::SACD_PARSER_REVISION, + ) + .unwrap()); + } + + #[test] + fn raw_sacd_scan_imports_once_and_keeps_tracks_when_the_image_is_truncated() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("music"); + std::fs::create_dir(&root).unwrap(); + let image_path = root.join("physical.ISO"); + let mut logical = vec![0u8; 640 * 2048]; + let master = &mut logical[510 * 2048..511 * 2048]; + master[..8].copy_from_slice(b"SACDMTOC"); + master[8..10].copy_from_slice(&[1, 20]); + master[0x40..0x44].copy_from_slice(&544u32.to_be_bytes()); + master[0x54..0x56].copy_from_slice(&3u16.to_be_bytes()); + let toc = &mut logical[544 * 2048..547 * 2048]; + toc[..8].copy_from_slice(b"TWOCHTOC"); + toc[8..12].copy_from_slice(&[1, 20, 0, 3]); + toc[0x14] = 4; + toc[0x15] = 2; + toc[0x20] = 2; + toc[0x42] = 4; + toc[0x45] = 1; + toc[0x48..0x4c].copy_from_slice(&600u32.to_be_bytes()); + toc[0x4c..0x50].copy_from_slice(&619u32.to_be_bytes()); + toc[2048..2056].copy_from_slice(b"SACDTRL1"); + toc[2056..2060].copy_from_slice(&600u32.to_be_bytes()); + toc[3076..3080].copy_from_slice(&20u32.to_be_bytes()); + toc[4096..4104].copy_from_slice(b"SACDTRL2"); + toc[5126] = 4; + let mut file = std::fs::File::create(&image_path).unwrap(); + for sector in logical.chunks_exact(2048) { + file.write_all(&[0xa5; 12]).unwrap(); + file.write_all(sector).unwrap(); + file.write_all(&[0x5a; 4]).unwrap(); + } + drop(file); + + // Manual open builds the same rows without requiring catalogue import. + let rows = super::build_image_rows(&image_path, &SacdLabels::default()).unwrap(); + assert_eq!(rows.album, "physical"); + assert_eq!(rows.tracks.len(), 1); + assert_eq!( + rows.tracks[0].file_path, + format!("sacd:{}#1", image_path.display()) + ); + let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); + let root_id = db + .add_folder_with_network_info(&root.to_string_lossy(), false, None) + .unwrap(); + let scan = |generation| { + scan_root_for_sacd( + &db, + root_id, + generation, + &root, + &SacdLabels::default(), + &AtomicBool::new(false), + ) + }; + let first = scan(1); + assert_eq!(first.imported, 1); + assert!(first.failed.is_empty()); + let tracks = db.get_all_track_paths().unwrap(); + assert_eq!(tracks.len(), 1); + let second = scan(2); + assert_eq!(second.unchanged, 1); + assert_eq!(second.imported, 0); + assert_eq!(db.get_all_track_paths().unwrap(), tracks); + + // Even a missing physical trailer is a failed SACD read, not a file + // that may be silently ignored and have its known tracks pruned. + std::fs::OpenOptions::new() + .write(true) + .open(&image_path) + .unwrap() + .set_len(640 * 2064 - 4) + .unwrap(); + let third = scan(3); + assert_eq!(third.failed.len(), 1); + assert_eq!(third.ignored, 0); + assert_eq!(third.removed, 0); + assert_eq!(db.get_all_track_paths().unwrap(), tracks); + } + + #[test] + fn cancel_stops_before_the_first_candidate() { + let temp = TempDir::new().unwrap(); + let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); + write_plain_iso(&temp.path().join("movie.iso")); + let root_id = db + .add_folder_with_network_info(&temp.path().to_string_lossy(), false, None) + .unwrap(); + let summary = scan_root_for_sacd( + &db, + root_id, + 1, + temp.path(), + &SacdLabels::default(), + &AtomicBool::new(true), + ); + assert_eq!(summary, SacdScanSummary::default()); + } +} diff --git a/crates/qbz-library/src/scan.rs b/crates/qbz-library/src/scan.rs index e10af300f..47e101308 100644 --- a/crates/qbz-library/src/scan.rs +++ b/crates/qbz-library/src/scan.rs @@ -1,388 +1,2346 @@ -//! Progress-emitting library scan (frontend-agnostic). +//! Incremental, root-scoped local-library scanner. //! -//! Lifts the scan-orchestration loop that lived in the Tauri command -//! `v2_library_scan` / `v2_library_scan_folder` into the core crate so any -//! frontend (Slint, TUI) can drive it. The Tauri side polled an -//! `Arc<Mutex<ScanProgress>>` over the IPC boundary; in-process callers get -//! the same information pushed through `on_event` and check `cancel` at every -//! file boundary. The per-file logic (CUE-first, sidecar override, embedded → -//! folder artwork, insert, missing-file cleanup) is replicated exactly. +//! Enumeration is streaming and deterministic. Each root owns a persistent +//! generation/checkpoint, cheap fingerprints skip unchanged metadata, changed +//! files are extracted by a bounded worker pool, and SQLite writes are grouped +//! into bounded transactions. A root only prunes rows after both of its passes +//! finish without traversal errors. Cancellation, an inaccessible mount, or a +//! WalkDir error therefore means "not observed", never "deleted". -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::hash::Hash; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use rusqlite::{params, OptionalExtension}; use crate::{ - cue_to_tracks, AlbumTagSidecar, CueParser, LibraryDatabase, LibraryError, LibraryScanner, - LocalTrack, MetadataExtractor, ScanError, ScanStatus, + cue_to_tracks, AlbumTagSidecar, CueParser, CueSheet, LibraryDatabase, LibraryError, + LibraryFolder, LibraryScanner, LocalTrack, MetadataExtractor, ScanError, ScanFileKind, + ScanStatus, }; -/// One step of a scan, pushed to the caller. The caller maps these onto its -/// own progress surface (and may coalesce the per-file stream). +const SCAN_BATCH_FILES: usize = 100; +const MAX_EXTRACT_WORKERS: usize = 8; +const PASS_CACHE_ENTRIES: usize = 2_048; + +/// One step of a scan, pushed to the caller. Per-file events are intentionally +/// compatible with the existing Qt progress surface; root events carry the +/// incremental/prune telemetry without exposing full paths in logs. pub enum ScanEvent { - /// Scan started (status = Scanning, counters reset). Started, - /// A folder's file count was folded into the running total. - TotalsAdded { total: u32 }, - /// A file is about to be processed (caller trims to a basename). - FileStarted { path: String }, - /// A file finished (processed/total advanced). - FileDone { processed: u32, total: u32 }, - /// Entering the missing-file cleanup phase. + TotalsAdded { + total: u32, + }, + FileStarted { + path: String, + }, + FileDone { + processed: u32, + total: u32, + }, + RootStarted { + root_id: i64, + generation: u64, + resumed: bool, + is_network: bool, + }, + RootFinished { + root_id: i64, + generation: u64, + discovered: u64, + extracted: u64, + reused: u64, + pruned: u64, + prune_authorized: bool, + elapsed: Duration, + }, Cleanup, - /// Terminal: Complete / Cancelled / Error, with any per-file errors. Finished { status: ScanStatus, errors: Vec<ScanError>, }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScanPhase { + Cues, + Audio, +} + +impl ScanPhase { + fn kind(self) -> ScanFileKind { + match self { + Self::Cues => ScanFileKind::Cue, + Self::Audio => ScanFileKind::Audio, + } + } +} + +#[derive(Debug, Clone)] +struct RootState { + generation: u64, + phase: ScanPhase, + checkpoint_path: String, + discovered: u64, + processed: u64, + status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileFingerprint { + file_id: String, + size_bytes: i64, + mtime_ns: i64, +} + +#[derive(Default)] +struct RootMetrics { + extracted: u64, + reused: u64, + pruned: u64, +} + +#[derive(Debug, Clone)] +enum ExtractTask { + Audio { path: PathBuf, root: PathBuf }, + Cue { cue: CueSheet, audio_path: PathBuf }, +} + +trait ExtractionBackend: Sync { + fn extract(&self, task: &ExtractTask) -> Result<Vec<LocalTrack>, String>; +} + +struct RealExtraction; + +impl ExtractionBackend for RealExtraction { + fn extract(&self, task: &ExtractTask) -> Result<Vec<LocalTrack>, String> { + match task { + ExtractTask::Audio { path, root } => { + MetadataExtractor::extract_with_roots(path, std::slice::from_ref(root)) + .map(|track| vec![track]) + .map_err(|error| error.to_string()) + } + ExtractTask::Cue { cue, audio_path } => { + let properties = MetadataExtractor::extract_properties(audio_path) + .map_err(|error| error.to_string())?; + let format = MetadataExtractor::detect_format(audio_path); + let tracks = cue_to_tracks(cue, properties.duration_secs, format, &properties); + if tracks.is_empty() { + Err("CUE produced no tracks".to_string()) + } else { + Ok(tracks) + } + } + } + } +} + +#[derive(Debug)] +enum PreparedAction { + Reuse, + Extract(ExtractTask), + Preserve(String), + /// A valid CUE with several FILE directives describes already-split + /// audio. Keep the CUE scan record but publish no virtual tracks and + /// retire rows produced by older single-file-only parsing. + IgnoreMultiFileCue, + SkipCueAudio, +} + +#[derive(Debug)] +struct PreparedFile { + traversal_path: String, + canonical_path: String, + kind: ScanFileKind, + fingerprint: Option<FileFingerprint>, + dependency: String, + current_cue_audio_path: Option<String>, + previous_cue_audio_path: Option<String>, + action: PreparedAction, +} + +struct ResolvedFile { + prepared: PreparedFile, + tracks: Option<Result<Vec<LocalTrack>, String>>, +} + +struct BoundedCache<K, V> { + values: HashMap<K, V>, + order: VecDeque<K>, + limit: usize, +} + +impl<K, V> BoundedCache<K, V> +where + K: Clone + Eq + Hash, + V: Clone, +{ + fn new(limit: usize) -> Self { + Self { + values: HashMap::new(), + order: VecDeque::new(), + limit, + } + } + + fn get_or_insert_with(&mut self, key: K, make: impl FnOnce() -> V) -> V { + if let Some(value) = self.values.get(&key).cloned() { + return value; + } + let value = make(); + self.values.insert(key.clone(), value.clone()); + self.order.push_back(key); + while self.values.len() > self.limit { + let Some(oldest) = self.order.pop_front() else { + break; + }; + self.values.remove(&oldest); + } + value + } +} + +struct PassCaches { + sidecars: BoundedCache<String, Option<AlbumTagSidecar>>, + artwork: BoundedCache<String, Option<String>>, + dependencies: BoundedCache<PathBuf, String>, +} + +impl Default for PassCaches { + fn default() -> Self { + Self { + sidecars: BoundedCache::new(PASS_CACHE_ENTRIES), + artwork: BoundedCache::new(PASS_CACHE_ENTRIES), + dependencies: BoundedCache::new(PASS_CACHE_ENTRIES), + } + } +} + fn normalize_path(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } fn now_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64) .unwrap_or(0) } -/// Apply a per-album sidecar override to a scanned track, caching the sidecar -/// read per album group key. Mirrors Tauri's -/// `library_apply_sidecar_override_if_present`. +#[cfg(unix)] +fn fingerprint(path: &Path) -> Result<FileFingerprint, LibraryError> { + use std::os::unix::fs::MetadataExt; + + let metadata = std::fs::metadata(path)?; + let nanos = + i128::from(metadata.mtime()) * 1_000_000_000_i128 + i128::from(metadata.mtime_nsec()); + Ok(FileFingerprint { + file_id: format!("{}:{}", metadata.dev(), metadata.ino()), + size_bytes: metadata.len().min(i64::MAX as u64) as i64, + mtime_ns: nanos.clamp(i64::MIN as i128, i64::MAX as i128) as i64, + }) +} + +#[cfg(not(unix))] +fn fingerprint(path: &Path) -> Result<FileFingerprint, LibraryError> { + let metadata = std::fs::metadata(path)?; + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos().min(i64::MAX as u128) as i64) + .unwrap_or(0); + Ok(FileFingerprint { + file_id: String::new(), + size_bytes: metadata.len().min(i64::MAX as u64) as i64, + mtime_ns: modified, + }) +} + +fn fingerprint_word(value: &FileFingerprint) -> String { + format!("{}:{}:{}", value.file_id, value.size_bytes, value.mtime_ns) +} + +fn is_artwork_file(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref(), + Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "tif" | "tiff") + ) +} + +/// Fingerprint the inexpensive non-audio dependencies that can change a +/// stored row: album sidecars and candidate folder artwork. The result is +/// cached per directory and bounded; it never retains the whole root. +fn directory_dependency(audio_path: &Path, caches: &mut PassCaches) -> String { + let parent = audio_path.parent().unwrap_or(audio_path).to_path_buf(); + caches.dependencies.get_or_insert_with(parent.clone(), || { + let album_root = PathBuf::from(MetadataExtractor::album_group_info(audio_path, None).0); + let mut directories = vec![album_root]; + if directories[0] != parent { + directories.push(parent); + } + let mut parts = Vec::new(); + for directory in directories { + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let sidecar = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == ".qbz.json"); + if !sidecar && !is_artwork_file(&path) { + continue; + } + if let Ok(value) = fingerprint(&path) { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + parts.push(format!("{name}:{}", fingerprint_word(&value))); + } + } + } + parts.sort(); + parts.join("|") + }) +} + fn apply_sidecar_override( track: &mut LocalTrack, - cache: &mut HashMap<String, Option<AlbumTagSidecar>>, + cache: &mut BoundedCache<String, Option<AlbumTagSidecar>>, ) { let group_key = track.album_group_key.trim(); if group_key.is_empty() { return; } - let cached = cache.entry(group_key.to_string()).or_insert_with(|| { - let album_dir = Path::new(group_key); - if !album_dir.is_dir() { - return None; + let album_dir = PathBuf::from(group_key); + let mut directories = vec![album_dir.clone()]; + if let Some(own_dir) = Path::new(&track.file_path).parent() { + if own_dir != album_dir { + directories.push(own_dir.to_path_buf()); + } + } + for directory in directories { + let key = directory.to_string_lossy().into_owned(); + let sidecar = cache.get_or_insert_with(key, || { + crate::tag_sidecar::read_album_sidecar(&directory).unwrap_or(None) + }); + if let Some(sidecar) = sidecar.as_ref() { + crate::tag_sidecar::apply_sidecar_to_track(track, sidecar); + return; } - crate::tag_sidecar::read_album_sidecar(album_dir).unwrap_or(None) - }); - if let Some(sidecar) = cached.as_ref() { - crate::tag_sidecar::apply_sidecar_to_track(track, sidecar); } } -/// Parse a CUE sheet into its virtual tracks and insert them. Mirrors Tauri's -/// `library_process_cue_file` (the per-track artwork loop already covers what -/// the deprecated `update_album_group_artwork` shortcut did, so it is omitted). -fn process_cue_file( - db: &LibraryDatabase, - cue_path: &Path, - artwork_cache: &Path, -) -> Result<(), String> { - let mut cue = CueParser::parse(cue_path).map_err(|e| e.to_string())?; - let audio_path = normalize_path(Path::new(&cue.audio_file)); - if !audio_path.exists() { - return Err(format!("Audio file not found: {}", cue.audio_file)); +fn decorate_tracks(tracks: &mut [LocalTrack], artwork_cache: &Path, caches: &mut PassCaches) { + for track in tracks { + apply_sidecar_override(track, &mut caches.sidecars); + let audio_path = PathBuf::from(&track.file_path); + let own_dir = audio_path.parent().unwrap_or(audio_path.as_path()); + let collection_key = if track.album_group_key.trim().is_empty() { + own_dir.to_string_lossy().into_owned() + } else { + track.album_group_key.clone() + }; + let album_hint = if track.album_group_title.trim().is_empty() { + track.album.as_str() + } else { + track.album_group_title.as_str() + }; + + // Artwork is track/disc metadata, not merely an album-card property. + // Resolve from the narrowest scope outwards so a box set cannot stamp + // Disc 01's cover onto every queue row and media-control surface: + // embedded tag -> this disc's folder -> collection root -> none. + let embedded_key = format!("embedded:{}", audio_path.to_string_lossy()); + let embedded = caches.artwork.get_or_insert_with(embedded_key, || { + MetadataExtractor::extract_artwork(&audio_path, artwork_cache) + }); + let disc_key = format!("disc-folder:{}", own_dir.to_string_lossy()); + let disc_file = caches.artwork.get_or_insert_with(disc_key, || { + MetadataExtractor::folder_artwork_in_dir(own_dir).and_then(|path| { + MetadataExtractor::cache_artwork_file(Path::new(&path), artwork_cache) + }) + }); + let collection_art_key = format!("collection:{collection_key}"); + let collection = caches + .artwork + .get_or_insert_with(collection_art_key, || { + MetadataExtractor::find_folder_artwork(&audio_path, Some(album_hint)).and_then( + |folder_art| { + MetadataExtractor::cache_artwork_file(Path::new(&folder_art), artwork_cache) + }, + ) + }); + let artwork = embedded.or(disc_file).or(collection); + track.artwork_path = artwork; } - cue.audio_file = audio_path.to_string_lossy().to_string(); +} - let properties = MetadataExtractor::extract_properties(&audio_path).map_err(|e| e.to_string())?; - let format = MetadataExtractor::detect_format(&audio_path); - let mut tracks = cue_to_tracks(&cue, properties.duration_secs, format, &properties); +fn scan_file_matches( + db: &LibraryDatabase, + root_id: i64, + path: &str, + kind: ScanFileKind, + value: &FileFingerprint, + dependency: &str, +) -> Result<bool, LibraryError> { + db.with_connection(|connection| { + connection + .query_row( + "SELECT 1 FROM local_scan_files + WHERE root_id=?1 AND file_path=?2 AND file_kind=?3 + AND file_id=?4 AND size_bytes=?5 AND mtime_ns=?6 + AND dependency_fingerprint=?7 AND extraction_ok=1", + params![ + root_id, + path, + kind_word(kind), + value.file_id, + value.size_bytes, + value.mtime_ns, + dependency, + ], + |_| Ok(()), + ) + .optional() + .map(|value| value.is_some()) + .map_err(|error| LibraryError::Database(error.to_string())) + }) +} - if let Some(group_key) = tracks - .first() - .map(|t| t.album_group_key.trim().to_string()) - .filter(|k| !k.is_empty()) - { - let album_dir = Path::new(&group_key); - if album_dir.is_dir() { - if let Ok(Some(sidecar)) = crate::tag_sidecar::read_album_sidecar(album_dir) { - for t in tracks.iter_mut() { - crate::tag_sidecar::apply_sidecar_to_track(t, &sidecar); - } +fn known_cue_audio_path( + db: &LibraryDatabase, + root_id: i64, + cue_path: &str, +) -> Result<Option<String>, LibraryError> { + db.with_connection(|connection| { + connection + .query_row( + "SELECT cue_audio_path FROM local_scan_files + WHERE root_id=?1 AND file_path=?2 AND file_kind='cue'", + params![root_id, cue_path], + |row| row.get(0), + ) + .optional() + .map(|value| value.flatten()) + .map_err(|error| LibraryError::Database(error.to_string())) + }) +} + +fn kind_word(kind: ScanFileKind) -> &'static str { + match kind { + ScanFileKind::Audio => "audio", + ScanFileKind::Cue => "cue", + } +} + +fn cue_audio_is_referenced( + db: &LibraryDatabase, + root_id: i64, + generation: u64, + path: &str, +) -> Result<bool, LibraryError> { + db.with_connection(|connection| { + connection + .query_row( + "SELECT 1 FROM local_scan_cue_refs + WHERE root_id=?1 AND generation=?2 AND audio_path=?3", + params![root_id, generation.min(i64::MAX as u64) as i64, path], + |_| Ok(()), + ) + .optional() + .map(|value| value.is_some()) + .map_err(|error| LibraryError::Database(error.to_string())) + }) +} + +fn load_root_state(db: &LibraryDatabase, root_id: i64) -> Result<Option<RootState>, LibraryError> { + db.with_connection(|connection| { + connection + .query_row( + "SELECT generation,phase,checkpoint_path,discovered,processed,status + FROM local_scan_roots WHERE root_id=?1", + params![root_id], + |row| { + let phase: String = row.get(1)?; + Ok(RootState { + generation: row.get::<_, i64>(0)?.max(0) as u64, + phase: if phase == "audio" { + ScanPhase::Audio + } else { + ScanPhase::Cues + }, + checkpoint_path: row.get(2)?, + discovered: row.get::<_, i64>(3)?.max(0) as u64, + processed: row.get::<_, i64>(4)?.max(0) as u64, + status: row.get(5)?, + }) + }, + ) + .optional() + .map_err(|error| LibraryError::Database(error.to_string())) + }) +} + +fn prepare_root( + db: &LibraryDatabase, + folder: &LibraryFolder, +) -> Result<(RootState, bool), LibraryError> { + let previous = load_root_state(db, folder.id)?; + let resumable = previous.as_ref().is_some_and(|state| { + matches!( + state.status.as_str(), + "running" | "cancelled" | "unavailable" + ) && (state.checkpoint_path.is_empty() || Path::new(&state.checkpoint_path).exists()) + }); + if resumable { + let mut state = previous.expect("checked above"); + state.status = "running".to_string(); + db.with_connection(|connection| { + connection.execute( + "UPDATE local_scan_roots + SET status='running',prune_authorized=0,updated_at=?2 + WHERE root_id=?1", + params![folder.id, now_secs()], + ) + }) + .map_err(|error| LibraryError::Database(error.to_string()))?; + return Ok((state, true)); + } + + let generation = previous + .map(|state| state.generation) + .unwrap_or(0) + .wrapping_add(1) + .max(1); + db.with_connection(|connection| { + let transaction = connection + .unchecked_transaction() + .map_err(|error| LibraryError::Database(error.to_string()))?; + transaction + .execute( + "INSERT INTO local_scan_roots( + root_id,generation,phase,checkpoint_path,discovered,processed, + status,prune_authorized,updated_at + ) VALUES (?1,?2,'cues','',0,0,'running',0,?3) + ON CONFLICT(root_id) DO UPDATE SET + generation=excluded.generation,phase='cues',checkpoint_path='', + discovered=0,processed=0,status='running',prune_authorized=0, + updated_at=excluded.updated_at", + params![ + folder.id, + generation.min(i64::MAX as u64) as i64, + now_secs() + ], + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + transaction + .execute( + "DELETE FROM local_scan_cue_refs WHERE root_id=?1", + params![folder.id], + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + transaction + .commit() + .map_err(|error| LibraryError::Database(error.to_string())) + })?; + Ok(( + RootState { + generation, + phase: ScanPhase::Cues, + checkpoint_path: String::new(), + discovered: 0, + processed: 0, + status: "running".to_string(), + }, + false, + )) +} + +fn mark_root_status(db: &LibraryDatabase, root_id: i64, status: &str) -> Result<(), LibraryError> { + db.with_connection(|connection| { + connection.execute( + "UPDATE local_scan_roots + SET status=?2,prune_authorized=0,updated_at=?3 WHERE root_id=?1", + params![root_id, status, now_secs()], + ) + }) + .map(|_| ()) + .map_err(|error| LibraryError::Database(error.to_string())) +} + +fn transition_to_audio( + db: &LibraryDatabase, + state: &mut RootState, + root_id: i64, +) -> Result<(), LibraryError> { + state.phase = ScanPhase::Audio; + state.checkpoint_path.clear(); + db.with_connection(|connection| { + connection.execute( + "UPDATE local_scan_roots + SET phase='audio',checkpoint_path='',status='running',updated_at=?2 + WHERE root_id=?1", + params![root_id, now_secs()], + ) + }) + .map(|_| ()) + .map_err(|error| LibraryError::Database(error.to_string())) +} + +fn resolve_batch(batch: Vec<PreparedFile>, backend: &dyn ExtractionBackend) -> Vec<ResolvedFile> { + let mut tasks = VecDeque::new(); + let mut passthrough = HashMap::new(); + for (index, prepared) in batch.into_iter().enumerate() { + match prepared.action { + PreparedAction::Extract(ref task) => tasks.push_back((index, task.clone(), prepared)), + _ => { + passthrough.insert( + index, + ResolvedFile { + prepared, + tracks: None, + }, + ); } } } - - let mut artwork = MetadataExtractor::extract_artwork(&audio_path, artwork_cache); - if artwork.is_none() { - if let Some(folder_art) = - MetadataExtractor::find_folder_artwork(&audio_path, cue.title.as_deref()) - { - artwork = - MetadataExtractor::cache_artwork_file(Path::new(&folder_art), artwork_cache); - } - } - if let Some(p) = artwork.as_ref() { - for t in tracks.iter_mut() { - t.artwork_path = Some(p.clone()); + let work = Arc::new(Mutex::new(tasks)); + let results = Arc::new(Mutex::new(Vec::new())); + let workers = work.lock().map(|queue| queue.len()).unwrap_or(0).min( + std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(2) + .clamp(1, MAX_EXTRACT_WORKERS), + ); + std::thread::scope(|scope| { + for _ in 0..workers { + let work = Arc::clone(&work); + let results = Arc::clone(&results); + scope.spawn(move || loop { + let next = work.lock().ok().and_then(|mut queue| queue.pop_front()); + let Some((index, task, prepared)) = next else { + break; + }; + let tracks = backend.extract(&task).and_then(|tracks| { + if tracks.is_empty() { + Err("metadata extraction produced no tracks".to_string()) + } else { + Ok(tracks) + } + }); + if let Ok(mut output) = results.lock() { + output.push(( + index, + ResolvedFile { + prepared, + tracks: Some(tracks), + }, + )); + } + }); } + }); + let extracted = Arc::try_unwrap(results) + .ok() + .and_then(|mutex| mutex.into_inner().ok()) + .unwrap_or_default(); + for (index, result) in extracted { + passthrough.insert(index, result); } + let mut ordered = passthrough.into_iter().collect::<Vec<_>>(); + ordered.sort_by_key(|(index, _)| *index); + ordered.into_iter().map(|(_, result)| result).collect() +} - for track in &tracks { - db.insert_track(track).map_err(|e| e.to_string())?; +fn remove_obsolete_cue_tracks( + db: &LibraryDatabase, + cue_path: &str, + current: &[LocalTrack], +) -> Result<(), LibraryError> { + let keep = current + .iter() + .map(|track| { + ( + track.file_path.clone(), + track.cue_start_secs.map(f64::to_bits), + ) + }) + .collect::<HashSet<_>>(); + let stale = db.with_connection(|connection| { + let mut statement = connection + .prepare( + "SELECT id,file_path,cue_start_secs + FROM local_tracks WHERE cue_file_path=?1", + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + let rows = statement + .query_map(params![cue_path], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option<f64>>(2)?.map(f64::to_bits), + )) + }) + .map_err(|error| LibraryError::Database(error.to_string()))?; + let mut ids = Vec::new(); + for row in rows { + let (id, file_path, start) = + row.map_err(|error| LibraryError::Database(error.to_string()))?; + if !keep.contains(&(file_path, start)) { + ids.push(id); + } + } + Ok::<_, LibraryError>(ids) + })?; + for ids in stale.chunks(500) { + db.delete_tracks_by_ids(ids)?; } Ok(()) } -/// Scan the library (or a single folder set) with progress + cancellation. -/// -/// `folder_ids = None` scans every ENABLED folder (full scan); `Some(&[id])` -/// scans only those enabled folders (single/per-folder parity). `artwork_cache` -/// is the cache dir for extracted/copied covers. `cancel` is checked at every -/// file boundary; on cancel the scan returns early with `Finished{Cancelled}` -/// and does NOT run cleanup (Tauri parity). `on_event` receives each step. -/// -/// Improvements over the Tauri loop (both verified against source): -/// - the full scan updates each folder's `last_scan` on success (Tauri only -/// did this for single-folder scans); -/// - network status is re-detected for every scanned folder that is not -/// user-overridden (Tauri only did this for single-folder scans). +fn commit_batch( + db: &LibraryDatabase, + folder: &LibraryFolder, + state: &RootState, + batch: &mut [ResolvedFile], + artwork_cache: &Path, + caches: &mut PassCaches, + metrics: &mut RootMetrics, + errors: &mut Vec<ScanError>, +) -> Result<(), LibraryError> { + for file in batch.iter_mut() { + if let Some(Ok(tracks)) = file.tracks.as_mut() { + decorate_tracks(tracks, artwork_cache, caches); + } + } + + db.with_connection(|connection| connection.execute_batch("BEGIN IMMEDIATE;")) + .map_err(|error| LibraryError::Database(error.to_string()))?; + let result = (|| { + for file in batch.iter() { + match &file.prepared.action { + PreparedAction::Reuse => { + metrics.reused = metrics.reused.saturating_add(1); + } + PreparedAction::Extract(_) => match file.tracks.as_ref() { + Some(Ok(tracks)) => { + for track in tracks { + db.insert_scanned_track(track, folder.is_network)?; + } + if file.prepared.kind == ScanFileKind::Cue { + remove_obsolete_cue_tracks(db, &file.prepared.canonical_path, tracks)?; + } + metrics.extracted = metrics.extracted.saturating_add(1); + } + Some(Err(error)) => errors.push(ScanError { + file_path: file.prepared.canonical_path.clone(), + error: error.clone(), + }), + None => {} + }, + PreparedAction::Preserve(error) => errors.push(ScanError { + file_path: file.prepared.canonical_path.clone(), + error: error.clone(), + }), + PreparedAction::IgnoreMultiFileCue => { + remove_obsolete_cue_tracks(db, &file.prepared.canonical_path, &[])?; + metrics.extracted = metrics.extracted.saturating_add(1); + } + PreparedAction::SkipCueAudio => {} + } + + let successful = matches!(file.prepared.action, PreparedAction::Reuse) + || matches!(file.prepared.action, PreparedAction::IgnoreMultiFileCue) + || matches!(file.tracks.as_ref(), Some(Ok(_))); + if let Some(value) = file.prepared.fingerprint.as_ref() { + // A present but unreadable file receives a negative scan row. + // That row protects pre-G data from prune while extraction_ok + // forces another metadata attempt on the next generation. + let stored_cue_audio_path = if successful { + file.prepared.current_cue_audio_path.as_ref() + } else { + file.prepared + .previous_cue_audio_path + .as_ref() + .or(file.prepared.current_cue_audio_path.as_ref()) + }; + db.with_connection(|connection| { + connection.execute( + "INSERT INTO local_scan_files( + root_id,file_path,file_kind,file_id,size_bytes,mtime_ns, + dependency_fingerprint,cue_audio_path,extraction_ok, + observed_generation + ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10) + ON CONFLICT(root_id,file_path,file_kind) DO UPDATE SET + file_id=excluded.file_id,size_bytes=excluded.size_bytes, + mtime_ns=excluded.mtime_ns, + dependency_fingerprint=excluded.dependency_fingerprint, + cue_audio_path=excluded.cue_audio_path, + extraction_ok=excluded.extraction_ok, + observed_generation=excluded.observed_generation", + params![ + folder.id, + file.prepared.canonical_path, + kind_word(file.prepared.kind), + value.file_id, + value.size_bytes, + value.mtime_ns, + file.prepared.dependency, + stored_cue_audio_path, + successful as i64, + state.generation.min(i64::MAX as u64) as i64, + ], + ) + }) + .map_err(|error| LibraryError::Database(error.to_string()))?; + } + let mut cue_references = Vec::new(); + if let Some(audio_path) = file.prepared.current_cue_audio_path.as_ref() { + cue_references.push(audio_path); + } + if !successful { + if let Some(audio_path) = file.prepared.previous_cue_audio_path.as_ref() { + if !cue_references.contains(&audio_path) { + cue_references.push(audio_path); + } + } + } + for audio_path in cue_references { + db.with_connection(|connection| { + connection.execute( + "INSERT OR IGNORE INTO local_scan_cue_refs(root_id,generation,audio_path) + VALUES (?1,?2,?3)", + params![ + folder.id, + state.generation.min(i64::MAX as u64) as i64, + audio_path, + ], + ) + }) + .map_err(|error| LibraryError::Database(error.to_string()))?; + } + } + let checkpoint = batch + .last() + .map(|file| file.prepared.traversal_path.as_str()) + .unwrap_or(state.checkpoint_path.as_str()); + db.with_connection(|connection| { + connection.execute( + "UPDATE local_scan_roots + SET checkpoint_path=?2,discovered=?3,processed=?4, + status='running',prune_authorized=0,updated_at=?5 + WHERE root_id=?1", + params![ + folder.id, + checkpoint, + state.discovered.min(i64::MAX as u64) as i64, + state.processed.min(i64::MAX as u64) as i64, + now_secs(), + ], + ) + }) + .map_err(|error| LibraryError::Database(error.to_string()))?; + Ok::<(), LibraryError>(()) + })(); + match result { + Ok(()) => db + .with_connection(|connection| connection.execute_batch("COMMIT;")) + .map_err(|error| LibraryError::Database(error.to_string())), + Err(error) => { + let _ = db.with_connection(|connection| connection.execute_batch("ROLLBACK;")); + Err(error) + } + } +} + +fn prepare_file( + db: &LibraryDatabase, + folder: &LibraryFolder, + state: &RootState, + phase: ScanPhase, + traversal_path: PathBuf, + caches: &mut PassCaches, +) -> Result<PreparedFile, LibraryError> { + let canonical = std::fs::canonicalize(&traversal_path)?; + let canonical_path = canonical.to_string_lossy().into_owned(); + if phase == ScanPhase::Audio + && cue_audio_is_referenced(db, folder.id, state.generation, &canonical_path)? + { + return Ok(PreparedFile { + traversal_path: traversal_path.to_string_lossy().into_owned(), + canonical_path, + kind: ScanFileKind::Audio, + fingerprint: None, + dependency: String::new(), + current_cue_audio_path: None, + previous_cue_audio_path: None, + action: PreparedAction::SkipCueAudio, + }); + } + + let value = fingerprint(&canonical)?; + match phase { + ScanPhase::Audio => { + let dependency = directory_dependency(&canonical, caches); + let action = if scan_file_matches( + db, + folder.id, + &canonical_path, + ScanFileKind::Audio, + &value, + &dependency, + )? { + PreparedAction::Reuse + } else { + PreparedAction::Extract(ExtractTask::Audio { + path: canonical, + root: normalize_path(Path::new(&folder.path)), + }) + }; + Ok(PreparedFile { + traversal_path: traversal_path.to_string_lossy().into_owned(), + canonical_path, + kind: ScanFileKind::Audio, + fingerprint: Some(value), + dependency, + current_cue_audio_path: None, + previous_cue_audio_path: None, + action, + }) + } + ScanPhase::Cues => { + let previous_cue_audio_path = known_cue_audio_path(db, folder.id, &canonical_path)?; + let parsed = CueParser::parse(&canonical); + let (action, dependency, current_cue_audio_path) = match parsed { + Ok(mut cue) => { + cue.file_path = canonical_path.clone(); + if !cue.is_single_file_image() { + // Multi-file sheets are useful metadata sidecars but + // the referenced split audio files are the playable, + // independently tagged sources. Marking this as a + // successful observation also clears any virtual CUE + // rows left by the old parser while allowing the audio + // phase to index every real file normally. + let dependency = + format!("multi-file-sidecar-v1|files={}", cue.audio_file_count()); + let action = if previous_cue_audio_path.is_none() + && scan_file_matches( + db, + folder.id, + &canonical_path, + ScanFileKind::Cue, + &value, + &dependency, + )? { + PreparedAction::Reuse + } else { + PreparedAction::IgnoreMultiFileCue + }; + return Ok(PreparedFile { + traversal_path: traversal_path.to_string_lossy().into_owned(), + canonical_path, + kind: ScanFileKind::Cue, + fingerprint: Some(value), + dependency, + current_cue_audio_path: None, + previous_cue_audio_path, + action, + }); + } + let audio = normalize_path(Path::new(&cue.audio_file)); + cue.audio_file = audio.to_string_lossy().into_owned(); + let audio_path = cue.audio_file.clone(); + match fingerprint(&audio) { + Ok(audio_fingerprint) => { + let dependency = format!( + "{}|{}", + fingerprint_word(&audio_fingerprint), + directory_dependency(&audio, caches) + ); + let action = if scan_file_matches( + db, + folder.id, + &canonical_path, + ScanFileKind::Cue, + &value, + &dependency, + )? { + PreparedAction::Reuse + } else { + PreparedAction::Extract(ExtractTask::Cue { + cue, + audio_path: audio, + }) + }; + (action, dependency, Some(audio_path)) + } + Err(error) => ( + PreparedAction::Preserve(error.to_string()), + String::new(), + Some(audio_path), + ), + } + } + Err(error) => ( + PreparedAction::Preserve(error.to_string()), + String::new(), + None, + ), + }; + Ok(PreparedFile { + traversal_path: traversal_path.to_string_lossy().into_owned(), + canonical_path, + kind: ScanFileKind::Cue, + fingerprint: Some(value), + dependency, + current_cue_audio_path, + previous_cue_audio_path, + action, + }) + } + } +} + +enum PhaseResult { + Complete, + Cancelled, + TraversalIncomplete, +} + +#[allow(clippy::too_many_arguments)] +fn run_phase( + db: &LibraryDatabase, + folder: &LibraryFolder, + state: &mut RootState, + phase: ScanPhase, + progress_base: u64, + artwork_cache: &Path, + cancel: &AtomicBool, + on_event: &(dyn Fn(ScanEvent) + Send + Sync), + backend: &dyn ExtractionBackend, + caches: &mut PassCaches, + metrics: &mut RootMetrics, + errors: &mut Vec<ScanError>, +) -> Result<PhaseResult, LibraryError> { + if cancel.load(Ordering::Acquire) { + return Ok(PhaseResult::Cancelled); + } + let scanner = LibraryScanner::new(); + let stream = match scanner.stream_directory(Path::new(&folder.path)) { + Ok(stream) => stream, + Err(error) => { + errors.push(ScanError { + file_path: folder.path.clone(), + error: error.to_string(), + }); + return Ok(PhaseResult::TraversalIncomplete); + } + }; + let mut resume_pending = !state.checkpoint_path.is_empty(); + let mut traversal_incomplete = false; + let mut batch = Vec::with_capacity(SCAN_BATCH_FILES); + + for entry in stream { + if cancel.load(Ordering::Acquire) { + if !batch.is_empty() { + let mut resolved = resolve_batch(std::mem::take(&mut batch), backend); + let processed_before = state.processed; + state.processed = state.processed.saturating_add(resolved.len() as u64); + commit_batch( + db, + folder, + state, + &mut resolved, + artwork_cache, + caches, + metrics, + errors, + )?; + emit_file_done( + on_event, + progress_base, + processed_before, + resolved.len(), + state.discovered, + ); + } + return Ok(PhaseResult::Cancelled); + } + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + traversal_incomplete = true; + errors.push(ScanError { + file_path: error + .path + .unwrap_or(error.subtree) + .to_string_lossy() + .into_owned(), + error: error.message, + }); + continue; + } + }; + if entry.kind != phase.kind() { + continue; + } + let traversal_word = entry.path.to_string_lossy().into_owned(); + if resume_pending { + if traversal_word == state.checkpoint_path { + resume_pending = false; + } + continue; + } + on_event(ScanEvent::FileStarted { + path: traversal_word.clone(), + }); + state.discovered = state.discovered.saturating_add(1); + on_event(ScanEvent::TotalsAdded { + total: progress_base + .saturating_add(state.discovered) + .min(u32::MAX as u64) as u32, + }); + match prepare_file(db, folder, state, phase, entry.path.clone(), caches) { + Ok(prepared) => batch.push(prepared), + Err(error) => { + traversal_incomplete = true; + errors.push(ScanError { + file_path: traversal_word, + error: error.to_string(), + }); + } + } + if batch.len() < SCAN_BATCH_FILES { + continue; + } + let mut resolved = resolve_batch(std::mem::take(&mut batch), backend); + let processed_before = state.processed; + state.processed = state.processed.saturating_add(resolved.len() as u64); + commit_batch( + db, + folder, + state, + &mut resolved, + artwork_cache, + caches, + metrics, + errors, + )?; + emit_file_done( + on_event, + progress_base, + processed_before, + resolved.len(), + state.discovered, + ); + state.checkpoint_path = resolved + .last() + .map(|file| file.prepared.traversal_path.clone()) + .unwrap_or_default(); + if cancel.load(Ordering::Acquire) { + return Ok(PhaseResult::Cancelled); + } + } + if resume_pending { + traversal_incomplete = true; + errors.push(ScanError { + file_path: state.checkpoint_path.clone(), + error: "scan checkpoint disappeared before resume".to_string(), + }); + } + if !batch.is_empty() { + let mut resolved = resolve_batch(batch, backend); + let processed_before = state.processed; + state.processed = state.processed.saturating_add(resolved.len() as u64); + commit_batch( + db, + folder, + state, + &mut resolved, + artwork_cache, + caches, + metrics, + errors, + )?; + emit_file_done( + on_event, + progress_base, + processed_before, + resolved.len(), + state.discovered, + ); + state.checkpoint_path = resolved + .last() + .map(|file| file.prepared.traversal_path.clone()) + .unwrap_or_default(); + if cancel.load(Ordering::Acquire) { + return Ok(PhaseResult::Cancelled); + } + } + if cancel.load(Ordering::Acquire) { + return Ok(PhaseResult::Cancelled); + } + Ok(if traversal_incomplete { + PhaseResult::TraversalIncomplete + } else { + PhaseResult::Complete + }) +} + +fn emit_file_done( + on_event: &(dyn Fn(ScanEvent) + Send + Sync), + progress_base: u64, + processed_before: u64, + batch_len: usize, + discovered: u64, +) { + let total = progress_base + .saturating_add(discovered) + .min(u32::MAX as u64) as u32; + for completed in 1..=batch_len as u64 { + on_event(ScanEvent::FileDone { + processed: progress_base + .saturating_add(processed_before) + .saturating_add(completed) + .min(u32::MAX as u64) as u32, + total, + }); + } +} + +fn finish_root( + db: &LibraryDatabase, + folder: &LibraryFolder, + state: &RootState, +) -> Result<u64, LibraryError> { + let generation = state.generation.min(i64::MAX as u64) as i64; + let root_prefix = format!("{}/", folder.path.trim_end_matches('/')); + db.with_connection(|connection| { + let transaction = connection + .unchecked_transaction() + .map_err(|error| LibraryError::Database(error.to_string()))?; + let removed = transaction + .execute( + "DELETE FROM local_tracks AS lt + WHERE (lt.source IS NULL OR lt.source='user') + AND ( + EXISTS ( + SELECT 1 FROM local_scan_files stale + WHERE stale.root_id=?1 AND stale.observed_generation<>?2 + AND ((stale.file_kind='audio' AND lt.cue_file_path IS NULL + AND lt.file_path=stale.file_path) + OR (stale.file_kind='cue' AND lt.cue_file_path=stale.file_path)) + AND NOT EXISTS ( + SELECT 1 FROM local_scan_files keep + WHERE keep.root_id<>?1 + AND keep.file_path=stale.file_path + AND keep.file_kind=stale.file_kind + ) + ) + OR (lt.cue_file_path IS NULL + AND substr(lt.file_path,1,length(?3))=?3 + AND NOT EXISTS ( + SELECT 1 FROM local_scan_files seen + WHERE seen.file_kind='audio' AND seen.file_path=lt.file_path + )) + OR (lt.cue_file_path IS NOT NULL + AND substr(lt.cue_file_path,1,length(?3))=?3 + AND NOT EXISTS ( + SELECT 1 FROM local_scan_files seen + WHERE seen.file_kind='cue' AND seen.file_path=lt.cue_file_path + )) + )", + params![folder.id, generation, root_prefix], + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + transaction + .execute( + "DELETE FROM local_scan_files + WHERE root_id=?1 AND observed_generation<>?2", + params![folder.id, generation], + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + transaction + .execute( + "DELETE FROM local_scan_cue_refs + WHERE root_id=?1 AND generation<>?2", + params![folder.id, generation], + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + transaction + .execute( + "UPDATE local_scan_roots + SET phase='idle',checkpoint_path='',status='complete', + prune_authorized=1,updated_at=?2 + WHERE root_id=?1", + params![folder.id, now_secs()], + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + transaction + .execute( + "UPDATE library_folders SET last_scan=?2 WHERE id=?1", + params![folder.id, now_secs()], + ) + .map_err(|error| LibraryError::Database(error.to_string()))?; + transaction + .commit() + .map_err(|error| LibraryError::Database(error.to_string()))?; + Ok(removed as u64) + }) +} + +enum RootResult { + Complete(u64), + Cancelled, + Incomplete(u64), +} + +fn report_root_without_prune( + folder: &LibraryFolder, + state: &RootState, + metrics: &RootMetrics, + status: &str, + started: Instant, + on_event: &(dyn Fn(ScanEvent) + Send + Sync), +) { + log::warn!( + "[local-scan] root_id={} generation={} network={} status={} discovered={} extracted={} reused={} pruned=0 prune_authorized=false elapsed={:?}", + folder.id, + state.generation, + folder.is_network, + status, + state.discovered, + metrics.extracted, + metrics.reused, + started.elapsed(), + ); + on_event(ScanEvent::RootFinished { + root_id: folder.id, + generation: state.generation, + discovered: state.discovered, + extracted: metrics.extracted, + reused: metrics.reused, + pruned: 0, + prune_authorized: false, + elapsed: started.elapsed(), + }); +} + +#[allow(clippy::too_many_arguments)] +fn scan_root( + db: &LibraryDatabase, + folder: &mut LibraryFolder, + progress_base: u64, + artwork_cache: &Path, + cancel: &AtomicBool, + on_event: &(dyn Fn(ScanEvent) + Send + Sync), + backend: &dyn ExtractionBackend, + errors: &mut Vec<ScanError>, +) -> Result<RootResult, LibraryError> { + let started = Instant::now(); + if crate::reachability::probe_default(Path::new(&folder.path)) + != crate::reachability::Reach::Present + { + let previous = load_root_state(db, folder.id)?; + if previous.as_ref().is_some_and(|state| { + matches!( + state.status.as_str(), + "running" | "cancelled" | "unavailable" + ) + }) { + // Preserve a resumable in-flight generation. A previously + // completed generation stays completed; reusing its generation + // after remount would make deleted files look observed forever. + mark_root_status(db, folder.id, "unavailable")?; + } + errors.push(ScanError { + file_path: folder.path.clone(), + error: "root unavailable; previous rows preserved".to_string(), + }); + log::warn!( + "[local-scan] root_id={} generation={} status=unavailable discovered=0 extracted=0 reused=0 pruned=0 prune_authorized=false elapsed={:?}", + folder.id, + previous.as_ref().map(|state| state.generation).unwrap_or(0), + started.elapsed(), + ); + on_event(ScanEvent::RootFinished { + root_id: folder.id, + generation: previous.map(|state| state.generation).unwrap_or(0), + discovered: 0, + extracted: 0, + reused: 0, + pruned: 0, + prune_authorized: false, + elapsed: started.elapsed(), + }); + return Ok(RootResult::Incomplete(0)); + } + + if !folder.user_override_network { + let is_network = crate::mount_info::is_network_path(Path::new(&folder.path)); + if is_network != folder.is_network { + let label = is_network + .then(|| crate::mount_info::network_fs_label(Path::new(&folder.path))) + .flatten(); + db.update_folder_settings( + folder.id, + folder.alias.as_deref(), + folder.enabled, + is_network, + label.as_deref(), + false, + )?; + folder.is_network = is_network; + } + } + + let (mut state, resumed) = prepare_root(db, folder)?; + on_event(ScanEvent::RootStarted { + root_id: folder.id, + generation: state.generation, + resumed, + is_network: folder.is_network, + }); + if state.discovered > 0 { + on_event(ScanEvent::TotalsAdded { + total: progress_base + .saturating_add(state.discovered) + .min(u32::MAX as u64) as u32, + }); + if state.processed > 0 { + on_event(ScanEvent::FileDone { + processed: progress_base + .saturating_add(state.processed) + .min(u32::MAX as u64) as u32, + total: progress_base + .saturating_add(state.discovered) + .min(u32::MAX as u64) as u32, + }); + } + } + let mut caches = PassCaches::default(); + let mut metrics = RootMetrics::default(); + + if state.phase == ScanPhase::Cues { + match run_phase( + db, + folder, + &mut state, + ScanPhase::Cues, + progress_base, + artwork_cache, + cancel, + on_event, + backend, + &mut caches, + &mut metrics, + errors, + )? { + PhaseResult::Complete => transition_to_audio(db, &mut state, folder.id)?, + PhaseResult::Cancelled => { + mark_root_status(db, folder.id, "cancelled")?; + report_root_without_prune(folder, &state, &metrics, "cancelled", started, on_event); + return Ok(RootResult::Cancelled); + } + PhaseResult::TraversalIncomplete => { + mark_root_status(db, folder.id, "error")?; + report_root_without_prune( + folder, + &state, + &metrics, + "traversal-incomplete", + started, + on_event, + ); + return Ok(RootResult::Incomplete(state.discovered)); + } + } + } + + match run_phase( + db, + folder, + &mut state, + ScanPhase::Audio, + progress_base, + artwork_cache, + cancel, + on_event, + backend, + &mut caches, + &mut metrics, + errors, + )? { + PhaseResult::Complete => {} + PhaseResult::Cancelled => { + mark_root_status(db, folder.id, "cancelled")?; + report_root_without_prune(folder, &state, &metrics, "cancelled", started, on_event); + return Ok(RootResult::Cancelled); + } + PhaseResult::TraversalIncomplete => { + mark_root_status(db, folder.id, "error")?; + report_root_without_prune( + folder, + &state, + &metrics, + "traversal-incomplete", + started, + on_event, + ); + return Ok(RootResult::Incomplete(state.discovered)); + } + } + + if cancel.load(Ordering::Acquire) { + mark_root_status(db, folder.id, "cancelled")?; + report_root_without_prune(folder, &state, &metrics, "cancelled", started, on_event); + return Ok(RootResult::Cancelled); + } + on_event(ScanEvent::Cleanup); + metrics.pruned = finish_root(db, folder, &state)?; + + // SACD images live outside the cue/audio phases: the walk keys on the + // extension, the `SACDMTOC` sniff keeps DVD/data ISOs silent, and the + // generation-based import owns the `sacd:` rows (sacd_scan.rs). A parser + // or import rejection is a scan error like any unreadable file. + let sacd = crate::sacd_scan::scan_root_for_sacd( + db, + folder.id, + state.generation.min(i64::MAX as u64) as i64, + Path::new(&folder.path), + &crate::sacd_scan::SacdLabels::default(), + cancel, + ); + if sacd.candidates > 0 || sacd.removed > 0 || !sacd.failed.is_empty() { + log::info!( + "[local-scan] root_id={} sacd candidates={} imported={} unchanged={} ignored={} removed={} prune_authorized={} failed={}", + folder.id, + sacd.candidates, + sacd.imported, + sacd.unchanged, + sacd.ignored, + sacd.removed, + sacd.prune_authorized, + sacd.failed.len(), + ); + } + for (file_path, error) in sacd.failed { + errors.push(ScanError { file_path, error }); + } + log::info!( + "[local-scan] root_id={} generation={} network={} discovered={} extracted={} reused={} pruned={} prune_authorized=true elapsed={:?}", + folder.id, + state.generation, + folder.is_network, + state.discovered, + metrics.extracted, + metrics.reused, + metrics.pruned, + started.elapsed(), + ); + on_event(ScanEvent::RootFinished { + root_id: folder.id, + generation: state.generation, + discovered: state.discovered, + extracted: metrics.extracted, + reused: metrics.reused, + pruned: metrics.pruned, + prune_authorized: true, + elapsed: started.elapsed(), + }); + Ok(RootResult::Complete(state.discovered)) +} + +/// Scan every enabled root (`None`) or the requested enabled roots. Existing +/// databases are authoritative and remain readable throughout each bounded +/// batch; the derived catalog catches up after the caller observes Finished. pub fn scan_with_progress( db: &LibraryDatabase, folder_ids: Option<&[i64]>, artwork_cache: &Path, cancel: &AtomicBool, on_event: &(dyn Fn(ScanEvent) + Send + Sync), +) -> Result<(), LibraryError> { + scan_with_backend( + db, + folder_ids, + artwork_cache, + cancel, + on_event, + &RealExtraction, + ) +} + +fn scan_with_backend( + db: &LibraryDatabase, + folder_ids: Option<&[i64]>, + artwork_cache: &Path, + cancel: &AtomicBool, + on_event: &(dyn Fn(ScanEvent) + Send + Sync), + backend: &dyn ExtractionBackend, ) -> Result<(), LibraryError> { let all = db.get_folders_with_metadata()?; - let targets: Vec<crate::LibraryFolder> = match folder_ids { - None => all.into_iter().filter(|f| f.enabled).collect(), + let mut targets = match folder_ids { + None => all + .into_iter() + .filter(|folder| folder.enabled) + .collect::<Vec<_>>(), Some(ids) => all .into_iter() - .filter(|f| f.enabled && ids.contains(&f.id)) - .collect(), + .filter(|folder| folder.enabled && ids.contains(&folder.id)) + .collect::<Vec<_>>(), }; if targets.is_empty() { - return Err(LibraryError::Other("No library folders to scan".to_string())); - } - let single = folder_ids.is_some(); - - // Refresh network detection for non-overridden folders being scanned. - for f in &targets { - if f.user_override_network { - continue; - } - let p = Path::new(&f.path); - let is_net = crate::mount_info::is_network_path(p); - if is_net != f.is_network { - let fs = if is_net { - crate::mount_info::network_fs_label(p) - } else { - None - }; - let _ = db.update_folder_settings( - f.id, - f.alias.as_deref(), - f.enabled, - is_net, - fs.as_deref(), - false, - ); - } + return Err(LibraryError::Other( + "No library folders to scan".to_string(), + )); } on_event(ScanEvent::Started); - - let scanner = LibraryScanner::new(); - let mut all_errors: Vec<ScanError> = Vec::new(); - let mut sidecar_cache: HashMap<String, Option<AlbumTagSidecar>> = HashMap::new(); - let mut total: u32 = 0; - let mut processed: u32 = 0; - - for folder in &targets { - // The folder's own normalized path, for the untagged-artist root - // clamp (an album dir directly under THIS root must not inherit the - // root's name as the artist — spec §C). - let folder_root = normalize_path(Path::new(&folder.path)); - let scan_result = match scanner.scan_directory(Path::new(&folder.path)) { - Ok(r) => r, - Err(e) => { - all_errors.push(ScanError { - file_path: folder.path.clone(), - error: e.to_string(), - }); - continue; - } - }; - - total += (scan_result.audio_files.len() + scan_result.cue_files.len()) as u32; - on_event(ScanEvent::TotalsAdded { total }); - - // CUE files first (one file -> several virtual tracks). - for cue_path in &scan_result.cue_files { - if cancel.load(Ordering::Relaxed) { + let mut errors = Vec::new(); + let mut progress_base = 0_u64; + for folder in &mut targets { + if cancel.load(Ordering::Acquire) { + on_event(ScanEvent::Finished { + status: ScanStatus::Cancelled, + errors, + }); + return Ok(()); + } + match scan_root( + db, + folder, + progress_base, + artwork_cache, + cancel, + on_event, + backend, + &mut errors, + )? { + RootResult::Cancelled => { on_event(ScanEvent::Finished { status: ScanStatus::Cancelled, - errors: std::mem::take(&mut all_errors), + errors, }); return Ok(()); } - on_event(ScanEvent::FileStarted { - path: cue_path.to_string_lossy().to_string(), - }); - if let Err(e) = process_cue_file(db, cue_path, artwork_cache) { - all_errors.push(ScanError { - file_path: cue_path.to_string_lossy().to_string(), - error: e, - }); + RootResult::Complete(discovered) | RootResult::Incomplete(discovered) => { + progress_base = progress_base.saturating_add(discovered); } - processed += 1; - on_event(ScanEvent::FileDone { processed, total }); - } - - // Audio files (skipping any referenced by a CUE sheet). - let cue_audio_files: HashSet<String> = scan_result - .cue_files - .iter() - .filter_map(|p| { - CueParser::parse(p).ok().map(|cue| { - normalize_path(Path::new(&cue.audio_file)) - .to_string_lossy() - .to_string() - }) - }) - .collect(); - - let mut folder_artwork_cache: HashMap<PathBuf, Option<String>> = HashMap::new(); - - for audio_path in &scan_result.audio_files { - if cancel.load(Ordering::Relaxed) { - on_event(ScanEvent::Finished { - status: ScanStatus::Cancelled, - errors: std::mem::take(&mut all_errors), - }); - return Ok(()); - } - - let canonical = normalize_path(audio_path); - let path_str = canonical.to_string_lossy().to_string(); - if cue_audio_files.contains(&path_str) { - processed += 1; - on_event(ScanEvent::FileDone { processed, total }); - continue; - } - - on_event(ScanEvent::FileStarted { - path: path_str.clone(), - }); - - match MetadataExtractor::extract_with_roots( - &canonical, - std::slice::from_ref(&folder_root), - ) { - Ok(mut track) => { - apply_sidecar_override(&mut track, &mut sidecar_cache); - let mut artwork = MetadataExtractor::extract_artwork(&canonical, artwork_cache); - if artwork.is_none() { - let album_hint: Option<String> = if !track.album_group_title.is_empty() { - Some(track.album_group_title.clone()) - } else { - Some(track.album.clone()) - }; - let folder_dir = canonical - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| canonical.clone()); - let cached = folder_artwork_cache - .entry(folder_dir) - .or_insert_with(|| { - MetadataExtractor::find_folder_artwork( - &canonical, - album_hint.as_deref(), - ) - }) - .clone(); - if let Some(folder_art) = cached { - artwork = MetadataExtractor::cache_artwork_file( - Path::new(&folder_art), - artwork_cache, - ); - } - } - track.artwork_path = artwork; - - if let Err(e) = db.insert_track(&track) { - all_errors.push(ScanError { - file_path: path_str, - error: e.to_string(), - }); - } - } - Err(e) => all_errors.push(ScanError { - file_path: path_str, - error: e.to_string(), - }), - } - - processed += 1; - on_event(ScanEvent::FileDone { processed, total }); } } - - // Cleanup: remove tracks whose files no longer exist. Full scan checks the - // whole DB; single-folder scan only the scanned folders' subtrees. - // - // GUARD: a network folder whose mount is currently DOWN must not have its - // subtree treated as missing — while unmounted every stat fails, and - // deleting the rows would wipe a whole folder's index over a transient - // condition (e.g. a reboot where the share didn't auto-mount). Those - // subtrees are skipped; they rehabilitate on the next scan after remount. - on_event(ScanEvent::Cleanup); - let folder_prefix = |path: &str| { - if path.ends_with('/') { - path.to_string() - } else { - format!("{}/", path) - } - }; - let unavailable_prefixes: Vec<String> = db - .get_folders_with_metadata() - .map(|folders| { - folders - .iter() - .filter(|f| f.is_network && std::fs::read_dir(&f.path).is_err()) - .map(|f| folder_prefix(&f.path)) - .collect() - }) - .unwrap_or_default(); - let under_unavailable = - |p: &str| unavailable_prefixes.iter().any(|pre| p.starts_with(pre.as_str())); - if let Ok(tracks) = db.get_all_track_paths() { - let missing: Vec<i64> = if single { - let prefixes: Vec<String> = - targets.iter().map(|f| folder_prefix(&f.path)).collect(); - tracks - .iter() - .filter(|(_, p)| { - prefixes.iter().any(|pre| p.starts_with(pre)) - && !under_unavailable(p) - && !Path::new(p).exists() - }) - .map(|(id, _)| *id) - .collect() - } else { - tracks - .iter() - .filter(|(_, p)| !under_unavailable(p) && !Path::new(p).exists()) - .map(|(id, _)| *id) - .collect() - }; - for chunk in missing.chunks(500) { - let _ = db.delete_tracks_by_ids(chunk); - } - } - - // Stamp each scanned folder's last_scan (improvement: full scan too). - let now = now_secs(); - for f in &targets { - let _ = db.update_folder_scan_time(&f.path, now); - } - on_event(ScanEvent::Finished { status: ScanStatus::Complete, - errors: all_errors, + errors, }); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + + struct FakeExtraction { + calls: AtomicUsize, + cancel_after: Option<usize>, + cancel: Arc<AtomicBool>, + } + + impl FakeExtraction { + fn new(cancel: Arc<AtomicBool>) -> Self { + Self { + calls: AtomicUsize::new(0), + cancel_after: None, + cancel, + } + } + + fn cancelling(cancel: Arc<AtomicBool>, after: usize) -> Self { + Self { + calls: AtomicUsize::new(0), + cancel_after: Some(after), + cancel, + } + } + } + + impl ExtractionBackend for FakeExtraction { + fn extract(&self, task: &ExtractTask) -> Result<Vec<LocalTrack>, String> { + let call = self.calls.fetch_add(1, AtomicOrdering::SeqCst) + 1; + if self.cancel_after.is_some_and(|after| call >= after) { + self.cancel.store(true, Ordering::Release); + } + let make_track = |path: &Path, title: String, cue: Option<(&CueSheet, f64)>| { + let mut track = LocalTrack::default(); + track.file_path = path.to_string_lossy().into_owned(); + track.title = title; + track.artist = "Fixture Artist".to_string(); + track.album = "Fixture Album".to_string(); + track.album_group_key = + path.parent().unwrap_or(path).to_string_lossy().into_owned(); + track.album_group_title = "Fixture Album".to_string(); + track.file_size_bytes = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0); + track.last_modified = now_secs(); + track.indexed_at = now_secs(); + if let Some((sheet, start)) = cue { + track.cue_file_path = Some(sheet.file_path.clone()); + track.cue_start_secs = Some(start); + } + track + }; + Ok(match task { + ExtractTask::Audio { path, .. } => vec![make_track( + path, + path.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Track".to_string()), + None, + )], + ExtractTask::Cue { cue, audio_path } => cue + .tracks + .iter() + .map(|track| { + make_track( + audio_path, + track.title.clone(), + Some((cue, track.start_secs)), + ) + }) + .collect(), + }) + } + } + + struct AlwaysFail; + + impl ExtractionBackend for AlwaysFail { + fn extract(&self, _task: &ExtractTask) -> Result<Vec<LocalTrack>, String> { + Err("fixture extraction failure".to_string()) + } + } + + fn fixture() -> (tempfile::TempDir, LibraryDatabase, i64, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("music"); + fs::create_dir_all(&root).unwrap(); + let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); + let root_id = db + .add_folder_with_network_info(&root.to_string_lossy(), false, None) + .unwrap(); + (temp, db, root_id, root) + } + + fn run_fake( + db: &LibraryDatabase, + root_id: i64, + temp: &tempfile::TempDir, + cancel: &Arc<AtomicBool>, + backend: &FakeExtraction, + ) { + scan_with_backend( + db, + Some(&[root_id]), + &temp.path().join("art"), + cancel, + &|_| {}, + backend, + ) + .unwrap(); + } + + fn track_rows(db: &LibraryDatabase) -> Vec<(i64, String)> { + db.with_connection(|connection| { + let mut statement = connection + .prepare("SELECT id,file_path FROM local_tracks ORDER BY file_path") + .unwrap(); + statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::<rusqlite::Result<Vec<_>>>() + .unwrap() + }) + } + + fn cue_rows(db: &LibraryDatabase) -> Vec<(i64, String, Option<String>)> { + db.with_connection(|connection| { + let mut statement = connection + .prepare("SELECT id,title,cue_file_path FROM local_tracks ORDER BY cue_start_secs") + .unwrap(); + statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap() + .collect::<rusqlite::Result<Vec<_>>>() + .unwrap() + }) + } + + fn write_cue(path: &Path, tracks: &[(&str, &str)]) { + let mut contents = String::from( + "PERFORMER \"Fixture Artist\"\nTITLE \"Fixture Album\"\nFILE \"album.flac\" WAVE\n", + ); + for (number, (title, index)) in tracks.iter().enumerate() { + contents.push_str(&format!( + " TRACK {:02} AUDIO\n TITLE \"{}\"\n INDEX 01 {}\n", + number + 1, + title, + index + )); + } + fs::write(path, contents).unwrap(); + } + + fn write_multi_file_cue(path: &Path, tracks: &[(&str, &str)]) { + let mut contents = String::from("PERFORMER \"Fixture Artist\"\nTITLE \"Fixture Album\"\n"); + for (number, (file, title)) in tracks.iter().enumerate() { + contents.push_str(&format!( + "FILE \"{}\" WAVE\n TRACK {:02} AUDIO\n TITLE \"{}\"\n INDEX 01 00:00:00\n", + file, + number + 1, + title + )); + } + fs::write(path, contents).unwrap(); + } + + fn write_artwork(path: &Path, rgb: [u8; 3]) { + image::RgbImage::from_pixel(2, 2, image::Rgb(rgb)) + .save(path) + .unwrap(); + } + + #[test] + fn artwork_resolution_prefers_each_disc_then_collection() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("Box Set"); + let disc_one = root.join("Disc 01 - First"); + let disc_two = root.join("Disc 02 - Second"); + let disc_three = root.join("Disc 03 - No Own Art"); + for directory in [&disc_one, &disc_two, &disc_three] { + fs::create_dir_all(directory).unwrap(); + fs::write(directory.join("01.flac"), b"not-a-real-audio-file").unwrap(); + } + write_artwork(&root.join("cover.png"), [10, 10, 10]); + write_artwork(&disc_one.join("cover.png"), [200, 10, 10]); + // A uniquely named image is still authoritative for its own disc. + write_artwork(&disc_two.join("TV Series Soundtrack 02.png"), [10, 200, 10]); + + let track = |disc: u32, directory: &Path| LocalTrack { + file_path: directory.join("01.flac").to_string_lossy().into_owned(), + album: "Box Set".to_string(), + album_group_key: root.to_string_lossy().into_owned(), + album_group_title: "Box Set".to_string(), + disc_number: Some(disc), + ..Default::default() + }; + let mut tracks = vec![ + track(1, &disc_one), + track(2, &disc_two), + track(3, &disc_three), + ]; + let mut caches = PassCaches::default(); + decorate_tracks(&mut tracks, temp.path(), &mut caches); + + let one = tracks[0].artwork_path.as_deref().unwrap(); + let two = tracks[1].artwork_path.as_deref().unwrap(); + let three = tracks[2].artwork_path.as_deref().unwrap(); + assert_ne!(one, two, "two distinct disc covers collapsed"); + assert_ne!(one, three, "disc 1 leaked into the collection fallback"); + assert_ne!(two, three, "disc 2 leaked into the collection fallback"); + } + + #[test] + fn unchanged_files_skip_extraction_and_changed_file_keeps_its_id() { + let (temp, db, root_id, root) = fixture(); + for index in 0..5 { + fs::write(root.join(format!("track-{index}.flac")), b"fixture").unwrap(); + } + let cancel = Arc::new(AtomicBool::new(false)); + let first = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &first); + assert_eq!(first.calls.load(AtomicOrdering::SeqCst), 5); + let before = track_rows(&db); + + let second = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &second); + assert_eq!(second.calls.load(AtomicOrdering::SeqCst), 0); + assert_eq!(track_rows(&db), before); + + fs::write(root.join("track-2.flac"), b"fixture changed").unwrap(); + let third = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &third); + assert_eq!(third.calls.load(AtomicOrdering::SeqCst), 1); + assert_eq!(track_rows(&db), before); + } + + #[test] + fn extraction_failure_preserves_pre_incremental_row_and_retries() { + let (temp, db, root_id, root) = fixture(); + let path = root.join("unreadable.flac"); + fs::write(&path, b"not real audio").unwrap(); + let mut existing = LocalTrack::default(); + existing.file_path = path.to_string_lossy().into_owned(); + existing.title = "Previously indexed".to_string(); + existing.artist = "Fixture Artist".to_string(); + existing.album = "Fixture Album".to_string(); + existing.album_group_key = root.to_string_lossy().into_owned(); + existing.album_group_title = existing.album.clone(); + let id = db.insert_track(&existing).unwrap(); + let cancel = Arc::new(AtomicBool::new(false)); + scan_with_backend( + &db, + Some(&[root_id]), + &temp.path().join("art"), + &cancel, + &|_| {}, + &AlwaysFail, + ) + .unwrap(); + assert_eq!(track_rows(&db), vec![(id, existing.file_path.clone())]); + let extraction_ok: i64 = db.with_connection(|connection| { + connection + .query_row( + "SELECT extraction_ok FROM local_scan_files WHERE root_id=?1", + params![root_id], + |row| row.get(0), + ) + .unwrap() + }); + assert_eq!(extraction_ok, 0); + + let retry = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &retry); + assert_eq!(retry.calls.load(AtomicOrdering::SeqCst), 1); + assert_eq!(track_rows(&db)[0].0, id); + } + + #[test] + fn cue_changes_remove_obsolete_virtual_tracks_and_parse_failure_never_duplicates_audio() { + let (temp, db, root_id, root) = fixture(); + fs::write(root.join("album.flac"), b"fixture audio").unwrap(); + let cue_path = root.join("album.cue"); + write_cue( + &cue_path, + &[ + ("First movement", "00:00:00"), + ("Second movement", "03:00:00"), + ], + ); + let cancel = Arc::new(AtomicBool::new(false)); + let first = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &first); + assert_eq!(first.calls.load(AtomicOrdering::SeqCst), 1); + let initial = cue_rows(&db); + assert_eq!(initial.len(), 2); + assert!(initial.iter().all(|(_, _, cue)| cue.is_some())); + + write_cue(&cue_path, &[("First movement revised", "00:00:00")]); + let changed = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &changed); + let revised = cue_rows(&db); + assert_eq!(changed.calls.load(AtomicOrdering::SeqCst), 1); + assert_eq!(revised.len(), 1); + assert_eq!(revised[0].0, initial[0].0); + assert_eq!(revised[0].1, "First movement revised"); + + fs::write(&cue_path, b"TRACK 01 AUDIO\n").unwrap(); + let malformed = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &malformed); + let preserved = cue_rows(&db); + assert_eq!(malformed.calls.load(AtomicOrdering::SeqCst), 0); + assert_eq!(preserved, revised); + assert!(preserved[0].2.is_some()); + + write_cue(&cue_path, &[("First movement repaired", "00:00:00")]); + let repaired = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &repaired); + assert_eq!(repaired.calls.load(AtomicOrdering::SeqCst), 1); + assert_eq!(cue_rows(&db)[0].0, initial[0].0); + + fs::remove_file(cue_path).unwrap(); + let cue_removed = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &cue_removed); + let plain = cue_rows(&db); + assert_eq!(cue_removed.calls.load(AtomicOrdering::SeqCst), 1); + assert_eq!(plain.len(), 1); + assert!(plain[0].2.is_none()); + } + + #[test] + fn multi_file_cue_retires_virtual_rows_and_indexes_only_split_audio() { + let (temp, db, root_id, root) = fixture(); + let first_path = root.join("01. First.flac"); + let second_path = root.join("02. Second.flac"); + fs::write(&first_path, b"fixture first audio").unwrap(); + fs::write(&second_path, b"fixture second audio").unwrap(); + let cue_path = root.join("leftover.cue"); + write_multi_file_cue( + &cue_path, + &[("01. First.flac", "First"), ("02. Second.flac", "Second")], + ); + + // Simulate rows produced by the legacy parser, which attached every + // virtual entry to one arbitrary FILE from the sheet. + for (index, title) in ["Legacy First", "Legacy Second"].iter().enumerate() { + let mut stale = LocalTrack::default(); + stale.file_path = second_path.to_string_lossy().into_owned(); + stale.title = (*title).to_string(); + stale.artist = "Fixture Artist".to_string(); + stale.album = "Fixture Album".to_string(); + stale.album_group_key = root.to_string_lossy().into_owned(); + stale.album_group_title = stale.album.clone(); + stale.cue_file_path = Some(cue_path.to_string_lossy().into_owned()); + stale.cue_start_secs = Some(index as f64); + db.insert_track(&stale).unwrap(); + } + + let cancel = Arc::new(AtomicBool::new(false)); + let extraction = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &extraction); + + // Only the two real files require extraction; the valid multi-file + // CUE is observed as a sidecar and never reaches the CUE backend. + assert_eq!(extraction.calls.load(AtomicOrdering::SeqCst), 2); + let rows = cue_rows(&db); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|(_, _, cue)| cue.is_none())); + let paths = track_rows(&db) + .into_iter() + .map(|(_, path)| path) + .collect::<HashSet<_>>(); + assert_eq!(paths.len(), 2); + assert!(paths.contains(&first_path.to_string_lossy().into_owned())); + assert!(paths.contains(&second_path.to_string_lossy().into_owned())); + } + + #[test] + fn root_prefix_treats_sql_wildcards_as_plain_path_characters() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("music%root"); + let sibling = temp.path().join("musicXroot"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&sibling).unwrap(); + fs::write(root.join("inside.flac"), b"inside").unwrap(); + let outside_path = sibling.join("outside.flac"); + fs::write(&outside_path, b"outside").unwrap(); + let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); + let root_id = db + .add_folder_with_network_info(&root.to_string_lossy(), false, None) + .unwrap(); + let mut outside = LocalTrack::default(); + outside.file_path = outside_path.to_string_lossy().into_owned(); + outside.title = "Outside".to_string(); + outside.artist = "Fixture Artist".to_string(); + outside.album = "Fixture Album".to_string(); + outside.album_group_key = sibling.to_string_lossy().into_owned(); + outside.album_group_title = outside.album.clone(); + let outside_id = db.insert_track(&outside).unwrap(); + let cancel = Arc::new(AtomicBool::new(false)); + run_fake( + &db, + root_id, + &temp, + &cancel, + &FakeExtraction::new(Arc::clone(&cancel)), + ); + let rows = track_rows(&db); + assert_eq!(rows.len(), 2); + assert!(rows.iter().any(|(id, _)| *id == outside_id)); + } + + #[test] + fn progress_is_monotonic_across_multiple_roots() { + let (temp, db, _first_id, first) = fixture(); + let second = temp.path().join("second-root"); + fs::create_dir_all(&second).unwrap(); + fs::write(first.join("one.flac"), b"one").unwrap(); + fs::write(second.join("two.flac"), b"two").unwrap(); + fs::write(second.join("three.flac"), b"three").unwrap(); + db.add_folder_with_network_info(&second.to_string_lossy(), false, None) + .unwrap(); + let cancel = Arc::new(AtomicBool::new(false)); + let progress = Mutex::new(Vec::new()); + scan_with_backend( + &db, + None, + &temp.path().join("art"), + &cancel, + &|event| { + if let ScanEvent::FileDone { processed, .. } = event { + progress.lock().unwrap().push(processed); + } + }, + &FakeExtraction::new(Arc::clone(&cancel)), + ) + .unwrap(); + let progress = progress.into_inner().unwrap(); + assert_eq!(progress.last(), Some(&3)); + assert!(progress.windows(2).all(|pair| pair[0] <= pair[1])); + } + + #[test] + fn incremental_scan_metric_fixture_reuses_two_thousand_fingerprints() { + let (temp, db, root_id, root) = fixture(); + const FILES: usize = 2_000; + for index in 0..FILES { + fs::write(root.join(format!("track-{index:04}.flac")), b"fixture").unwrap(); + } + let cancel = Arc::new(AtomicBool::new(false)); + let first_metrics = Mutex::new(None); + let first_backend = FakeExtraction::new(Arc::clone(&cancel)); + let first_wall = Instant::now(); + scan_with_backend( + &db, + Some(&[root_id]), + &temp.path().join("art"), + &cancel, + &|event| { + if let ScanEvent::RootFinished { + discovered, + extracted, + reused, + elapsed, + .. + } = event + { + *first_metrics.lock().unwrap() = Some((discovered, extracted, reused, elapsed)); + } + }, + &first_backend, + ) + .unwrap(); + let first_wall = first_wall.elapsed(); + assert_eq!(first_backend.calls.load(AtomicOrdering::SeqCst), FILES); + assert_eq!(track_rows(&db).len(), FILES); + let first_metrics = first_metrics.into_inner().unwrap().unwrap(); + assert_eq!(first_metrics.0, FILES as u64); + assert_eq!(first_metrics.1, FILES as u64); + assert_eq!(first_metrics.2, 0); + + let second_metrics = Mutex::new(None); + let second_backend = FakeExtraction::new(Arc::clone(&cancel)); + let second_wall = Instant::now(); + scan_with_backend( + &db, + Some(&[root_id]), + &temp.path().join("art"), + &cancel, + &|event| { + if let ScanEvent::RootFinished { + discovered, + extracted, + reused, + elapsed, + .. + } = event + { + *second_metrics.lock().unwrap() = + Some((discovered, extracted, reused, elapsed)); + } + }, + &second_backend, + ) + .unwrap(); + let second_wall = second_wall.elapsed(); + assert_eq!(second_backend.calls.load(AtomicOrdering::SeqCst), 0); + let second_metrics = second_metrics.into_inner().unwrap().unwrap(); + assert_eq!(second_metrics.0, FILES as u64); + assert_eq!(second_metrics.1, 0); + assert_eq!(second_metrics.2, FILES as u64); + println!( + "G_SCAN_METRIC files={FILES} first_root_ms={} first_wall_ms={} second_root_ms={} second_wall_ms={} first_extracted={} second_reused={}", + first_metrics.3.as_millis(), + first_wall.as_millis(), + second_metrics.3.as_millis(), + second_wall.as_millis(), + first_metrics.1, + second_metrics.2, + ); + } + + #[test] + fn completed_root_prunes_but_unavailable_network_root_never_does() { + let (temp, db, root_id, root) = fixture(); + fs::write(root.join("keep.flac"), b"keep").unwrap(); + fs::write(root.join("remove.flac"), b"remove").unwrap(); + let cancel = Arc::new(AtomicBool::new(false)); + run_fake( + &db, + root_id, + &temp, + &cancel, + &FakeExtraction::new(Arc::clone(&cancel)), + ); + fs::remove_file(root.join("remove.flac")).unwrap(); + run_fake( + &db, + root_id, + &temp, + &cancel, + &FakeExtraction::new(Arc::clone(&cancel)), + ); + assert_eq!(track_rows(&db).len(), 1); + + db.update_folder_settings(root_id, None, true, true, Some("nfs"), true) + .unwrap(); + let hidden = temp.path().join("network-offline"); + fs::rename(&root, &hidden).unwrap(); + let attempted_prune = AtomicBool::new(true); + scan_with_backend( + &db, + Some(&[root_id]), + &temp.path().join("art"), + &cancel, + &|event| { + if let ScanEvent::RootFinished { + prune_authorized, .. + } = event + { + attempted_prune.store(prune_authorized, Ordering::Release); + } + }, + &FakeExtraction::new(Arc::clone(&cancel)), + ) + .unwrap(); + assert_eq!(track_rows(&db).len(), 1); + assert!(!attempted_prune.load(Ordering::Acquire)); + let prune_authorized: i64 = db.with_connection(|connection| { + connection + .query_row( + "SELECT prune_authorized FROM local_scan_roots WHERE root_id=?1", + params![root_id], + |row| row.get(0), + ) + .unwrap() + }); + // The last good generation remains a valid snapshot even though the + // current inaccessible attempt was not allowed to prune it. + assert_eq!(prune_authorized, 1); + } + + #[test] + fn cancelled_generation_resumes_without_duplicates_or_prune() { + let (temp, db, root_id, root) = fixture(); + for index in 0..230 { + fs::write(root.join(format!("track-{index:04}.flac")), b"fixture").unwrap(); + } + let cancel = Arc::new(AtomicBool::new(false)); + let first = FakeExtraction::cancelling(Arc::clone(&cancel), 2); + run_fake(&db, root_id, &temp, &cancel, &first); + let state = load_root_state(&db, root_id).unwrap().unwrap(); + assert_eq!(state.status, "cancelled"); + assert!(!state.checkpoint_path.is_empty()); + assert!(track_rows(&db).len() <= SCAN_BATCH_FILES); + + cancel.store(false, Ordering::Release); + let second = FakeExtraction::new(Arc::clone(&cancel)); + run_fake(&db, root_id, &temp, &cancel, &second); + let rows = track_rows(&db); + assert_eq!(rows.len(), 230); + assert_eq!( + rows.iter() + .map(|(id, _)| *id) + .collect::<std::collections::HashSet<_>>() + .len(), + rows.len() + ); + let complete = load_root_state(&db, root_id).unwrap().unwrap(); + assert_eq!(complete.generation, state.generation); + assert_eq!(complete.status, "complete"); + } + + #[test] + fn cancellation_during_the_final_batch_never_authorizes_prune() { + let (temp, db, root_id, root) = fixture(); + fs::write(root.join("keep.flac"), b"keep").unwrap(); + fs::write(root.join("temporarily-absent.flac"), b"present").unwrap(); + let cancel = Arc::new(AtomicBool::new(false)); + run_fake( + &db, + root_id, + &temp, + &cancel, + &FakeExtraction::new(Arc::clone(&cancel)), + ); + assert_eq!(track_rows(&db).len(), 2); + fs::remove_file(root.join("temporarily-absent.flac")).unwrap(); + fs::write(root.join("keep.flac"), b"keep changed").unwrap(); + + let prune_authorized = AtomicBool::new(true); + let cancelling = FakeExtraction::cancelling(Arc::clone(&cancel), 1); + scan_with_backend( + &db, + Some(&[root_id]), + &temp.path().join("art"), + &cancel, + &|event| { + if let ScanEvent::RootFinished { + prune_authorized: allowed, + .. + } = event + { + prune_authorized.store(allowed, Ordering::Release); + } + }, + &cancelling, + ) + .unwrap(); + assert!(!prune_authorized.load(Ordering::Acquire)); + assert_eq!(track_rows(&db).len(), 2); + assert_eq!( + load_root_state(&db, root_id).unwrap().unwrap().status, + "cancelled" + ); + } + + #[cfg(unix)] + #[test] + fn symlink_cycle_is_visible_and_blocks_prune_until_a_clean_pass() { + use std::os::unix::fs::symlink; + + let (temp, db, root_id, root) = fixture(); + fs::write(root.join("track.flac"), b"fixture").unwrap(); + symlink(&root, root.join("cycle")).unwrap(); + let cancel = Arc::new(AtomicBool::new(false)); + let events = Mutex::new(Vec::new()); + scan_with_backend( + &db, + Some(&[root_id]), + &temp.path().join("art"), + &cancel, + &|event| { + if let ScanEvent::Finished { errors, .. } = event { + events.lock().unwrap().extend(errors); + } + }, + &FakeExtraction::new(Arc::clone(&cancel)), + ) + .unwrap(); + assert!(!events.lock().unwrap().is_empty()); + let state = load_root_state(&db, root_id).unwrap().unwrap(); + assert_eq!(state.status, "error"); + fs::remove_file(root.join("cycle")).unwrap(); + run_fake( + &db, + root_id, + &temp, + &cancel, + &FakeExtraction::new(Arc::clone(&cancel)), + ); + assert_eq!( + load_root_state(&db, root_id).unwrap().unwrap().status, + "complete" + ); + } +} diff --git a/crates/qbz-library/src/scanner.rs b/crates/qbz-library/src/scanner.rs index 074865d55..1d0d47566 100644 --- a/crates/qbz-library/src/scanner.rs +++ b/crates/qbz-library/src/scanner.rs @@ -6,11 +6,95 @@ use walkdir::WalkDir; use crate::LibraryError; /// Supported audio file extensions -const SUPPORTED_AUDIO_EXTENSIONS: &[&str] = &["flac", "m4a", "wav", "aiff", "aif", "ape", "mp3", "dsf", "dff"]; +const SUPPORTED_AUDIO_EXTENSIONS: &[&str] = &[ + "flac", "m4a", "wav", "aiff", "aif", "ape", "mp3", "dsf", "dff", +]; /// CUE file extension const CUE_EXTENSION: &str = "cue"; +/// Explicit symlink policy for a root. The compatibility default follows +/// links, but WalkDir's loop detector remains enabled and every loop/error is +/// surfaced to the caller; there is no silent `filter_map(Result::ok)` path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SymlinkPolicy { + Ignore, + FollowWithCycleDetection, +} + +impl Default for SymlinkPolicy { + fn default() -> Self { + Self::FollowWithCycleDetection + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScanFileKind { + Audio, + Cue, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScanEntry { + pub path: PathBuf, + pub kind: ScanFileKind, +} + +/// A traversal error tied to the path/subtree WalkDir could not enumerate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScanWalkError { + pub path: Option<PathBuf>, + pub subtree: PathBuf, + pub message: String, +} + +pub struct ScanStream { + root: PathBuf, + inner: walkdir::IntoIter, +} + +impl Iterator for ScanStream { + type Item = Result<ScanEntry, ScanWalkError>; + + fn next(&mut self) -> Option<Self::Item> { + loop { + let entry = match self.inner.next()? { + Ok(entry) => entry, + Err(error) => { + let path = error.path().map(Path::to_path_buf); + let subtree = error + .loop_ancestor() + .map(Path::to_path_buf) + .or_else(|| path.clone()) + .unwrap_or_else(|| self.root.clone()); + return Some(Err(ScanWalkError { + path, + subtree, + message: error.to_string(), + })); + } + }; + if !entry.file_type().is_file() { + continue; + } + let Some(ext) = entry.path().extension().and_then(|value| value.to_str()) else { + continue; + }; + let kind = if LibraryScanner::is_supported_audio_extension(&ext.to_lowercase()) { + ScanFileKind::Audio + } else if ext.eq_ignore_ascii_case(CUE_EXTENSION) { + ScanFileKind::Cue + } else { + continue; + }; + return Some(Ok(ScanEntry { + path: entry.into_path(), + kind, + })); + } + } +} + /// Result of scanning a directory #[derive(Debug, Default)] pub struct ScanResult { @@ -29,6 +113,38 @@ impl LibraryScanner { Self } + /// Enumerate one root lazily in deterministic depth-first order. + pub fn stream_directory(&self, path: &Path) -> Result<ScanStream, LibraryError> { + self.stream_directory_with_policy(path, SymlinkPolicy::default()) + } + + pub fn stream_directory_with_policy( + &self, + path: &Path, + policy: SymlinkPolicy, + ) -> Result<ScanStream, LibraryError> { + if !path.exists() { + return Err(LibraryError::InvalidPath(format!( + "Path does not exist: {}", + path.display() + ))); + } + if !path.is_dir() { + return Err(LibraryError::InvalidPath(format!( + "Path is not a directory: {}", + path.display() + ))); + } + let follow = policy == SymlinkPolicy::FollowWithCycleDetection; + Ok(ScanStream { + root: path.to_path_buf(), + inner: WalkDir::new(path) + .follow_links(follow) + .sort_by_file_name() + .into_iter(), + }) + } + /// Scan a directory recursively for audio and CUE files pub fn scan_directory(&self, path: &Path) -> Result<ScanResult, LibraryError> { if !path.exists() { @@ -46,32 +162,16 @@ impl LibraryScanner { } let mut result = ScanResult::default(); - - for entry in WalkDir::new(path) - .follow_links(true) - .into_iter() - .filter_map(|e| e.ok()) - { - let path = entry.path(); - - if !path.is_file() { - continue; - } - - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - let ext_lower = ext.to_lowercase(); - - if Self::is_supported_audio_extension(&ext_lower) { - result.audio_files.push(path.to_path_buf()); - } else if ext_lower == CUE_EXTENSION { - result.cue_files.push(path.to_path_buf()); - } + for entry in self.stream_directory(path)? { + let entry = entry.map_err(|error| LibraryError::Other(error.message))?; + match entry.kind { + ScanFileKind::Audio => result.audio_files.push(entry.path), + ScanFileKind::Cue => result.cue_files.push(entry.path), } } log::info!( - "Scanned {}: found {} audio files, {} CUE files", - path.display(), + "Filesystem scan complete: {} audio files, {} CUE files", result.audio_files.len(), result.cue_files.len() ); diff --git a/crates/qbz-library/src/tag_sidecar.rs b/crates/qbz-library/src/tag_sidecar.rs index f16d06586..39f1844dc 100644 --- a/crates/qbz-library/src/tag_sidecar.rs +++ b/crates/qbz-library/src/tag_sidecar.rs @@ -32,6 +32,42 @@ pub struct TrackMetadataOverride { pub track_number: Option<u32>, } +/// Lossless editor fields which the flat local-library index does not yet +/// project. Keeping them in sidecars prevents a local-only user from losing +/// ordered credits or provider identifiers merely because they chose the +/// non-destructive persistence mode. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AlbumExtendedMetadataOverride { + #[serde(default)] + pub album_artists: Vec<String>, + pub compilation: Option<bool>, + pub musicbrainz_release_id: Option<String>, + pub musicbrainz_release_group_id: Option<String>, + #[serde(default)] + pub musicbrainz_album_artist_ids: Vec<String>, + pub discogs_release_id: Option<String>, + pub artwork_path: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TrackExtendedMetadataOverride { + pub file_path: String, + pub cue_start_secs: Option<f64>, + pub artist_credit: String, + #[serde(default)] + pub artists: Vec<String>, + #[serde(default)] + pub composers: Vec<String>, + #[serde(default)] + pub performers: Vec<String>, + pub musicbrainz_recording_id: Option<String>, + pub musicbrainz_track_id: Option<String>, + #[serde(default)] + pub musicbrainz_artist_ids: Vec<String>, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AlbumTagSidecar { @@ -39,6 +75,10 @@ pub struct AlbumTagSidecar { pub updated_at: i64, pub album: AlbumMetadataOverride, pub tracks: Vec<TrackMetadataOverride>, + #[serde(default)] + pub extended_album: Option<AlbumExtendedMetadataOverride>, + #[serde(default)] + pub extended_tracks: Vec<TrackExtendedMetadataOverride>, } impl AlbumTagSidecar { @@ -53,8 +93,21 @@ impl AlbumTagSidecar { updated_at: now, album, tracks, + extended_album: None, + extended_tracks: Vec::new(), } } + + pub fn with_extended( + mut self, + album: AlbumExtendedMetadataOverride, + tracks: Vec<TrackExtendedMetadataOverride>, + ) -> Self { + self.version = 2; + self.extended_album = Some(album); + self.extended_tracks = tracks; + self + } } pub fn sidecar_path(album_dir: &Path) -> PathBuf { @@ -109,30 +162,31 @@ pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar) track.album_group_title = title.clone(); } - if let Some(album_artist) = sidecar - .album - .album_artist - .as_ref() - .and_then(|s| normalize(s)) - { - track.album_artist = Some(album_artist.clone()); + if let Some(album_artist) = sidecar.album.album_artist.as_ref() { + track.album_artist = normalize(album_artist); } if let Some(year) = sidecar.album.year { - track.year = Some(year); + track.year = (year != 0).then_some(year); } - if let Some(genre) = sidecar.album.genre.as_ref().and_then(|s| normalize(s)) { - track.genre = Some(genre.clone()); + if let Some(genre) = sidecar.album.genre.as_ref() { + track.genre = normalize(genre); + track.genres = track.genre.iter().cloned().collect(); } - if let Some(cat) = sidecar - .album - .catalog_number - .as_ref() - .and_then(|s| normalize(s)) - { - track.catalog_number = Some(cat.clone()); + if let Some(catalog_number) = sidecar.album.catalog_number.as_ref() { + track.catalog_number = normalize(catalog_number); + } + + if let Some(extended) = sidecar.extended_album.as_ref() { + if let Some(path) = extended + .artwork_path + .as_ref() + .and_then(|path| normalize(path)) + { + track.artwork_path = Some(path); + } } if let Some(entry) = sidecar.tracks.iter().find(|t| { @@ -147,10 +201,23 @@ pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar) track.title = title.clone(); } if let Some(disc) = entry.disc_number { - track.disc_number = Some(disc); + track.disc_number = (disc != 0).then_some(disc); } if let Some(no) = entry.track_number { - track.track_number = Some(no); + track.track_number = (no != 0).then_some(no); + } + } + + if let Some(entry) = sidecar.extended_tracks.iter().find(|entry| { + entry.file_path == track.file_path + && match (entry.cue_start_secs, track.cue_start_secs) { + (Some(a), Some(b)) => (a - b).abs() < 0.001, + (None, None) => true, + _ => false, + } + }) { + if let Some(artist) = normalize(&entry.artist_credit) { + track.artist = artist; } } } @@ -163,3 +230,130 @@ fn normalize(value: &str) -> Option<String> { Some(trimmed.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn track() -> LocalTrack { + LocalTrack { + id: 42, + file_path: "/music/album/01.flac".to_string(), + title: "Old title".to_string(), + album: "Old album".to_string(), + album_group_title: "Old album".to_string(), + album_artist: Some("Old artist".to_string()), + year: Some(1999), + genre: Some("Old genre".to_string()), + catalog_number: Some("OLD-1".to_string()), + track_number: Some(1), + disc_number: Some(2), + ..LocalTrack::default() + } + } + + #[test] + fn explicit_empty_and_zero_sentinels_clear_metadata() { + let mut track = track(); + let sidecar = AlbumTagSidecar::new( + AlbumMetadataOverride { + album_title: Some("New album".to_string()), + album_artist: Some(" ".to_string()), + year: Some(0), + genre: Some(String::new()), + catalog_number: Some(String::new()), + }, + vec![TrackMetadataOverride { + file_path: track.file_path.clone(), + cue_start_secs: None, + title: Some("New title".to_string()), + disc_number: Some(0), + track_number: Some(0), + }], + ); + + apply_sidecar_to_track(&mut track, &sidecar); + + assert_eq!(track.album, "New album"); + assert_eq!(track.title, "New title"); + assert_eq!(track.album_artist, None); + assert_eq!(track.year, None); + assert_eq!(track.genre, None); + assert_eq!(track.catalog_number, None); + assert_eq!(track.disc_number, None); + assert_eq!(track.track_number, None); + } + + #[test] + fn absent_fields_keep_scanned_metadata_for_v1_compatibility() { + let mut track = track(); + let sidecar = AlbumTagSidecar::new( + AlbumMetadataOverride::default(), + vec![TrackMetadataOverride { + file_path: track.file_path.clone(), + cue_start_secs: None, + title: None, + disc_number: None, + track_number: None, + }], + ); + + apply_sidecar_to_track(&mut track, &sidecar); + + assert_eq!(track.album_artist.as_deref(), Some("Old artist")); + assert_eq!(track.year, Some(1999)); + assert_eq!(track.genre.as_deref(), Some("Old genre")); + assert_eq!(track.catalog_number.as_deref(), Some("OLD-1")); + assert_eq!(track.disc_number, Some(2)); + assert_eq!(track.track_number, Some(1)); + } + + #[test] + fn legacy_v1_json_deserializes_without_extended_fields() { + let json = r#"{ + "version": 1, + "updatedAt": 42, + "album": {}, + "tracks": [] + }"#; + let sidecar: AlbumTagSidecar = serde_json::from_str(json).unwrap(); + assert!(sidecar.extended_album.is_none()); + assert!(sidecar.extended_tracks.is_empty()); + } + + #[test] + fn v2_roundtrip_preserves_ordered_credits_and_provider_ids() { + let sidecar = AlbumTagSidecar::new(AlbumMetadataOverride::default(), Vec::new()) + .with_extended( + AlbumExtendedMetadataOverride { + album_artists: vec!["Alpha".into(), "Beta".into()], + compilation: Some(true), + musicbrainz_release_id: Some("release".into()), + musicbrainz_release_group_id: Some("group".into()), + musicbrainz_album_artist_ids: vec!["a".into(), "b".into()], + discogs_release_id: Some("123".into()), + artwork_path: Some("/cache/cover.jpg".into()), + }, + vec![TrackExtendedMetadataOverride { + file_path: "/music/01.flac".into(), + cue_start_secs: None, + artist_credit: "Alpha feat. Beta".into(), + artists: vec!["Alpha".into(), "Beta".into()], + composers: vec!["Composer".into()], + performers: vec!["Player (guitar)".into()], + musicbrainz_recording_id: Some("recording".into()), + musicbrainz_track_id: Some("track".into()), + musicbrainz_artist_ids: vec!["a".into(), "b".into()], + }], + ); + + let json = serde_json::to_string(&sidecar).unwrap(); + let decoded: AlbumTagSidecar = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.version, 2); + assert_eq!( + decoded.extended_album.unwrap().album_artists, + ["Alpha", "Beta"] + ); + assert_eq!(decoded.extended_tracks[0].artist_credit, "Alpha feat. Beta"); + } +} diff --git a/crates/qbz-library/src/tag_writer.rs b/crates/qbz-library/src/tag_writer.rs index cd778ca66..b6016beea 100644 --- a/crates/qbz-library/src/tag_writer.rs +++ b/crates/qbz-library/src/tag_writer.rs @@ -4,13 +4,214 @@ //! reported through an `on_progress` closure (no Tauri event bus); the caller //! orchestrates the DB update + sidecar removal. +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs::OpenOptions; use std::path::Path; use crate::{LibraryError, LocalTrack}; +/// ID3 generation used when the container's canonical tag is ID3v2. +/// +/// v2.4 is the standards-current default. v2.3 is an explicit compatibility +/// option for older hardware and software; it is never selected merely +/// because a file happened to arrive with a legacy tag. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Id3v2WriteVersion { + #[default] + V24, + V23, +} + +/// Direct-write policy. The default updates only the container's canonical +/// tag and preserves every secondary tag byte-for-byte as far as Lofty permits. +/// `synchronize_secondary_tags` is opt-in because a secondary tag may be there +/// specifically for an old player with stricter text/length limits. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DirectTagWriteOptions { + pub id3v2_version: Id3v2WriteVersion, + pub synchronize_secondary_tags: bool, +} + +/// One tag layer observed across the selected physical album version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TagLayerInspection { + pub name: String, + pub file_count: usize, + pub canonical_file_count: usize, + pub writable_file_count: usize, +} + +/// Bounded preflight information for the editor. No audio or artwork bytes +/// escape this API; only format/tag names and aggregate counts do. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AlbumTagInspection { + pub file_count: usize, + pub canonical_layers: Vec<TagLayerInspection>, + pub present_layers: Vec<TagLayerInspection>, + /// Files where two non-empty tag layers disagree on at least one field the + /// editor owns. The canonical layer still wins deterministically. + pub conflicting_files: usize, + /// Files whose canonical tag can be written by this Lofty build. + pub writable_files: usize, +} + +impl AlbumTagInspection { + pub fn direct_write_supported(&self) -> bool { + self.file_count > 0 && self.file_count == self.writable_files + } +} + +fn tag_type_name(tag_type: lofty::tag::TagType) -> &'static str { + use lofty::tag::TagType; + match tag_type { + TagType::Ape => "APEv2", + TagType::Id3v1 => "ID3v1", + TagType::Id3v2 => "ID3v2", + TagType::Mp4Ilst => "MP4 ilst", + TagType::VorbisComments => "Vorbis comments", + TagType::RiffInfo => "RIFF INFO", + TagType::AiffText => "AIFF text", + _ => "Other", + } +} + +fn tag_has_editor_conflict(tagged_file: &lofty::file::TaggedFile) -> bool { + use lofty::prelude::*; + use lofty::tag::ItemKey; + + fn distinct_text(tagged_file: &lofty::file::TaggedFile, key: ItemKey) -> bool { + let mut values = Vec::<String>::new(); + for tag in tagged_file.tags() { + if let Some(value) = tag + .get_string(key.clone()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !values.iter().any(|seen| seen.eq_ignore_ascii_case(value)) { + values.push(value.to_string()); + } + } + } + values.len() > 1 + } + + let text_conflict = [ + ItemKey::TrackTitle, + ItemKey::TrackArtist, + ItemKey::AlbumTitle, + ItemKey::AlbumArtist, + ItemKey::Genre, + ItemKey::RecordingDate, + ItemKey::Year, + ItemKey::CatalogNumber, + ] + .into_iter() + .any(|key| distinct_text(tagged_file, key)); + if text_conflict { + return true; + } + + let mut tracks = HashSet::new(); + let mut discs = HashSet::new(); + for tag in tagged_file.tags() { + if let Some(value) = tag.track() { + tracks.insert(value); + } + if let Some(value) = tag.disk() { + discs.insert(value); + } + } + tracks.len() > 1 || discs.len() > 1 +} + +/// Inspect every distinct file before the editor offers direct write. +/// +/// All files are opened read-only. A malformed/unsupported member fails the +/// inspection rather than silently producing a reassuring partial summary. +pub fn inspect_album_tag_layers(paths: &[String]) -> Result<AlbumTagInspection, LibraryError> { + use lofty::prelude::*; + + #[derive(Default)] + struct Counts { + present: usize, + canonical: usize, + writable: usize, + } + + let mut seen = HashSet::new(); + let unique: Vec<&String> = paths + .iter() + .filter(|path| seen.insert((*path).clone())) + .collect(); + if unique.is_empty() { + return Err(LibraryError::Metadata( + "No audio files were provided for tag inspection.".to_string(), + )); + } + + let mut layers = BTreeMap::<String, Counts>::new(); + let mut canonical = BTreeMap::<String, Counts>::new(); + let mut conflicting_files = 0usize; + let mut writable_files = 0usize; + + for path in &unique { + let file_path = Path::new(path); + if !file_path.is_file() { + return Err(LibraryError::Metadata(format!( + "Audio file not found: {}", + file_path.display() + ))); + } + let tagged_file = lofty::read_from_path(file_path).map_err(|error| { + LibraryError::Metadata(format!( + "Failed to inspect tags in {}: {error}", + file_path.display() + )) + })?; + let primary = tagged_file.primary_tag_type(); + let primary_name = tag_type_name(primary).to_string(); + let support = tagged_file.tag_support(primary); + let counts = canonical.entry(primary_name).or_default(); + counts.canonical += 1; + counts.present += usize::from(tagged_file.primary_tag().is_some()); + counts.writable += usize::from(support.is_writable()); + writable_files += usize::from(support.is_writable()); + + for tag in tagged_file.tags() { + let counts = layers + .entry(tag_type_name(tag.tag_type()).to_string()) + .or_default(); + counts.present += 1; + counts.canonical += usize::from(tag.tag_type() == primary); + counts.writable += usize::from(tagged_file.tag_support(tag.tag_type()).is_writable()); + } + conflicting_files += usize::from(tag_has_editor_conflict(&tagged_file)); + } + + let map_layers = |map: BTreeMap<String, Counts>| { + map.into_iter() + .map(|(name, counts)| TagLayerInspection { + name, + file_count: counts.present, + canonical_file_count: counts.canonical, + writable_file_count: counts.writable, + }) + .collect() + }; + + Ok(AlbumTagInspection { + file_count: unique.len(), + canonical_layers: map_layers(canonical), + present_layers: map_layers(layers), + conflicting_files, + writable_files, + }) +} + /// Album-level fields written into every file's embedded tags. A `None` /// (or blank) field REMOVES that tag (direct write is destructive, unlike the /// override-only sidecar). +#[derive(Debug, Clone)] pub struct AlbumTagWrite { pub album_title: String, pub album_artist: String, // "" => remove the AlbumArtist tag @@ -20,6 +221,7 @@ pub struct AlbumTagWrite { } /// One file's per-track fields. +#[derive(Debug, Clone)] pub struct TrackTagWrite { pub file_path: String, pub title: String, @@ -27,124 +229,765 @@ pub struct TrackTagWrite { pub disc_number: Option<u32>, } -/// Write embedded tags to each file. Dedups by `file_path` keeping the FIRST -/// occurrence (order preserved). `on_progress(current, total)` is called -/// BEFORE each file write (1-based; total = deduped count). Partial-failure -/// unsafe by design: returns `Err` on the first failing file with prior files -/// already modified. Does NOT touch the DB or the sidecar. -pub fn write_album_tags_to_files( +/// Standards-oriented album fields owned by the expanded Qt editor. +/// +/// `album_artist` in [`AlbumTagWrite`] remains the human-readable credit. +/// `album_artists` stores the ordered, lossless components in the dedicated +/// ALBUMARTISTS key where the target tag format supports it. +#[derive(Debug, Clone, Default)] +pub struct ExtendedAlbumTagWrite { + pub album_artists: Vec<String>, + pub compilation: Option<bool>, + pub musicbrainz_release_id: Option<String>, + pub musicbrainz_release_group_id: Option<String>, + pub musicbrainz_album_artist_ids: Vec<String>, + /// Provenance retained by the sidecar. There is no interoperable Discogs + /// key in Lofty's generic tag map, so it is deliberately not embedded. + pub discogs_release_id: Option<String>, +} + +/// Standards-oriented fields for one physical track. +#[derive(Debug, Clone, Default)] +pub struct ExtendedTrackTagWrite { + pub file_path: String, + /// Display credit, including intentional join phrases such as "A feat. B". + pub artist_credit: String, + /// Ordered components written to ARTISTS independently from ARTIST. + pub artists: Vec<String>, + pub composers: Vec<String>, + pub performers: Vec<String>, + pub musicbrainz_recording_id: Option<String>, + pub musicbrainz_track_id: Option<String>, + pub musicbrainz_artist_ids: Vec<String>, +} + +/// A validated front-cover candidate. Callers stage bytes before save; the +/// writer never follows a QML-provided path or URL while mutating audio files. +#[derive(Debug, Clone)] +pub struct FrontCoverWrite { + pub bytes: Vec<u8>, +} + +/// Decode and atomically install a conventional folder front cover. +/// The old file is restored if the final rename fails. +pub fn write_folder_front_cover(album_dir: &Path, bytes: &[u8]) -> Result<String, LibraryError> { + if bytes.is_empty() || bytes.len() > 25 * 1024 * 1024 { + return Err(LibraryError::Metadata( + "The selected cover is empty or larger than 25 MiB.".to_string(), + )); + } + let image = image::load_from_memory(bytes).map_err(|error| { + LibraryError::Metadata(format!("The selected cover could not be decoded: {error}")) + })?; + if image.width() == 0 + || image.height() == 0 + || image.width() > 12_000 + || image.height() > 12_000 + { + return Err(LibraryError::Metadata( + "The selected cover has unsupported dimensions.".to_string(), + )); + } + let target = album_dir.join("cover.jpg"); + let temporary = album_dir.join(".cover.jpg.qbz-tmp"); + let backup = album_dir.join(".cover.jpg.qbz-backup"); + image + .save_with_format(&temporary, image::ImageFormat::Jpeg) + .map_err(|error| LibraryError::Metadata(format!("Cover encode failed: {error}")))?; + + let had_target = target.is_file(); + if had_target { + if backup.exists() { + std::fs::remove_file(&backup).map_err(LibraryError::Io)?; + } + std::fs::rename(&target, &backup).map_err(LibraryError::Io)?; + } + if let Err(error) = std::fs::rename(&temporary, &target) { + if had_target { + let _ = std::fs::rename(&backup, &target); + } + return Err(LibraryError::Io(error)); + } + if had_target && backup.exists() { + std::fs::remove_file(&backup).map_err(LibraryError::Io)?; + } + Ok(target.to_string_lossy().to_string()) +} + +/// Rich values read from one file's canonical tag for editor seeding. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EditorTrackTagSnapshot { + pub file_path: String, + pub artist_credit: String, + pub artists: Vec<String>, + pub album_artists: Vec<String>, + pub composers: Vec<String>, + pub performers: Vec<String>, + pub compilation: Option<bool>, + pub musicbrainz_release_id: Option<String>, + pub musicbrainz_release_group_id: Option<String>, + pub musicbrainz_recording_id: Option<String>, + pub musicbrainz_track_id: Option<String>, + pub musicbrainz_artist_ids: Vec<String>, + pub musicbrainz_album_artist_ids: Vec<String>, +} + +fn normalized_values(tag: &lofty::tag::Tag, key: lofty::tag::ItemKey) -> Vec<String> { + let mut seen = HashSet::<String>::new(); + tag.get_strings(key) + .map(str::trim) + .filter(|value| !value.is_empty()) + .filter_map(|value| { + let folded = value.to_lowercase(); + seen.insert(folded).then(|| value.to_string()) + }) + .collect() +} + +fn normalized_optional(tag: &lofty::tag::Tag, key: lofty::tag::ItemKey) -> Option<String> { + normalized_values(tag, key).into_iter().next() +} + +/// Read the canonical rich-tag projection used by the Qt editor. +/// Unreadable rows return an empty snapshot rather than borrowing values from +/// a neighbouring file; the ordinary editor preflight reports writability. +pub fn read_editor_tag_snapshots(paths: &[String]) -> Vec<EditorTrackTagSnapshot> { + use lofty::prelude::*; + use lofty::tag::ItemKey; + + paths + .iter() + .map(|path| { + let mut snapshot = EditorTrackTagSnapshot { + file_path: path.clone(), + ..EditorTrackTagSnapshot::default() + }; + let Ok(file) = lofty::read_from_path(Path::new(path)) else { + return snapshot; + }; + let Some(tag) = file.primary_tag().or_else(|| file.first_tag()) else { + return snapshot; + }; + snapshot.artist_credit = + normalized_optional(tag, ItemKey::TrackArtist).unwrap_or_default(); + snapshot.artists = normalized_values(tag, ItemKey::TrackArtists); + if snapshot.artists.is_empty() && !snapshot.artist_credit.is_empty() { + snapshot.artists.push(snapshot.artist_credit.clone()); + } + snapshot.album_artists = normalized_values(tag, ItemKey::AlbumArtists); + snapshot.composers = normalized_values(tag, ItemKey::Composer); + snapshot.performers = normalized_values(tag, ItemKey::Performer); + snapshot.compilation = normalized_optional(tag, ItemKey::FlagCompilation) + .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")); + snapshot.musicbrainz_release_id = + normalized_optional(tag, ItemKey::MusicBrainzReleaseId); + snapshot.musicbrainz_release_group_id = + normalized_optional(tag, ItemKey::MusicBrainzReleaseGroupId); + snapshot.musicbrainz_recording_id = + normalized_optional(tag, ItemKey::MusicBrainzRecordingId); + snapshot.musicbrainz_track_id = normalized_optional(tag, ItemKey::MusicBrainzTrackId); + snapshot.musicbrainz_artist_ids = normalized_values(tag, ItemKey::MusicBrainzArtistId); + snapshot.musicbrainz_album_artist_ids = + normalized_values(tag, ItemKey::MusicBrainzReleaseArtistId); + snapshot + }) + .collect() +} + +/// Should this file's per-track ARTIST be rewritten to the album artist? +/// +/// ARTIST is TRACK scope — a release with guest performers legitimately holds a +/// different artist per track — but the editor offers exactly one artist field. +/// The DB index resolves that by renaming the track artist ONLY where it still +/// equals the value that was uniform across the album before the edit +/// (`Database::update_album_group_metadata`). The file writer has to agree with +/// it, or the two halves of one save disagree. +/// +/// It used to call `set_artist(album_artist)` unconditionally on every file, +/// which meant a various-artists release — where the editor leaves the artist +/// field EMPTY precisely because the track artists disagree — had an empty +/// ARTIST written over every one of its files. Irreversibly. +fn should_rename_artist( + current: Option<&str>, + previous_uniform: Option<&str>, + new_artist: &str, +) -> bool { + if new_artist.trim().is_empty() { + return false; + } + let Some(previous) = previous_uniform.map(str::trim).filter(|p| !p.is_empty()) else { + // The album had no single prior artist, so there is no value this edit + // can be said to be renaming. Leave every file's ARTIST alone. + return false; + }; + current.map(str::trim) == Some(previous) +} + +/// The artist shared by every file, or `None` when they disagree or any is +/// blank. Read from the FILES rather than the library index, because the files +/// are what is about to be overwritten. +fn uniform_file_artist(paths: &[&str]) -> Option<String> { + use lofty::prelude::*; + + let mut shared: Option<String> = None; + for path in paths { + let file = lofty::read_from_path(Path::new(path)).ok()?; + let tag = file.primary_tag().or_else(|| file.first_tag())?; + let artist = tag.artist()?.trim().to_string(); + if artist.is_empty() { + return None; + } + match &shared { + None => shared = Some(artist), + Some(seen) if *seen == artist => {} + Some(_) => return None, + } + } + shared +} + +fn apply_editor_fields( + tag: &mut lofty::tag::Tag, album: &AlbumTagWrite, + track: &TrackTagWrite, + previous_artist: Option<&str>, +) { + use lofty::prelude::*; + use lofty::tag::ItemKey; + + tag.set_title(track.title.trim().to_string()); + tag.set_album(album.album_title.trim().to_string()); + + // ARTIST is track scope — see `should_rename_artist`. + let current_artist = tag.artist().map(|artist| artist.into_owned()); + if should_rename_artist( + current_artist.as_deref(), + previous_artist, + &album.album_artist, + ) { + tag.set_artist(album.album_artist.trim().to_string()); + } + + match track.track_number { + Some(number) => tag.set_track(number), + None => tag.remove_track(), + } + match track.disc_number { + Some(number) => tag.set_disk(number), + None => tag.remove_disk(), + } + + if album.album_artist.trim().is_empty() { + tag.remove_key(ItemKey::AlbumArtist); + } else { + tag.insert_text(ItemKey::AlbumArtist, album.album_artist.trim().to_string()); + } + + if let Some(year) = album.year { + tag.set_date(lofty::tag::items::Timestamp { + year: year as u16, + ..Default::default() + }); + } else { + tag.remove_date(); + } + + if let Some(genre) = album + .genre + .as_deref() + .map(str::trim) + .filter(|genre| !genre.is_empty()) + { + tag.set_genre(genre.to_string()); + } else { + tag.remove_genre(); + } + + if let Some(catalog) = album + .catalog_number + .as_deref() + .map(str::trim) + .filter(|catalog| !catalog.is_empty()) + { + tag.insert_text(ItemKey::CatalogNumber, catalog.to_string()); + } else { + tag.remove_key(ItemKey::CatalogNumber); + } +} + +fn replace_text_values(tag: &mut lofty::tag::Tag, key: lofty::tag::ItemKey, values: &[String]) { + use lofty::tag::{ItemValue, TagItem}; + + tag.remove_key(key); + for value in values + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + tag.push(TagItem::new(key, ItemValue::Text(value.to_string()))); + } +} + +fn replace_optional_text(tag: &mut lofty::tag::Tag, key: lofty::tag::ItemKey, value: Option<&str>) { + let values = value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| vec![value.to_string()]) + .unwrap_or_default(); + replace_text_values(tag, key, &values); +} + +fn apply_extended_editor_fields( + tag: &mut lofty::tag::Tag, + album: &ExtendedAlbumTagWrite, + track: &ExtendedTrackTagWrite, + artwork: Option<&lofty::picture::Picture>, +) { + use lofty::picture::PictureType; + use lofty::prelude::*; + use lofty::tag::ItemKey; + + if !track.artist_credit.trim().is_empty() { + tag.set_artist(track.artist_credit.trim().to_string()); + } else { + tag.remove_key(ItemKey::TrackArtist); + } + replace_text_values(tag, ItemKey::TrackArtists, &track.artists); + replace_text_values(tag, ItemKey::AlbumArtists, &album.album_artists); + replace_text_values(tag, ItemKey::Composer, &track.composers); + replace_text_values(tag, ItemKey::Performer, &track.performers); + match album.compilation { + Some(true) => replace_optional_text(tag, ItemKey::FlagCompilation, Some("1")), + Some(false) => tag.remove_key(ItemKey::FlagCompilation), + None => {} + } + replace_optional_text( + tag, + ItemKey::MusicBrainzReleaseId, + album.musicbrainz_release_id.as_deref(), + ); + replace_optional_text( + tag, + ItemKey::MusicBrainzReleaseGroupId, + album.musicbrainz_release_group_id.as_deref(), + ); + replace_text_values( + tag, + ItemKey::MusicBrainzReleaseArtistId, + &album.musicbrainz_album_artist_ids, + ); + replace_optional_text( + tag, + ItemKey::MusicBrainzRecordingId, + track.musicbrainz_recording_id.as_deref(), + ); + replace_optional_text( + tag, + ItemKey::MusicBrainzTrackId, + track.musicbrainz_track_id.as_deref(), + ); + replace_text_values( + tag, + ItemKey::MusicBrainzArtistId, + &track.musicbrainz_artist_ids, + ); + if let Some(picture) = artwork { + tag.remove_picture_type(PictureType::CoverFront); + tag.push_picture(picture.clone()); + } +} + +fn normalized_tag_text(tag: &lofty::tag::Tag, key: lofty::tag::ItemKey) -> Option<String> { + tag.get_string(key) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn verify_editor_write( + path: &Path, + album: &AlbumTagWrite, + track: &TrackTagWrite, + expected_artist: Option<&str>, + extended_album: Option<&ExtendedAlbumTagWrite>, + extended_track: Option<&ExtendedTrackTagWrite>, + expected_artwork: Option<&[u8]>, +) -> Result<(), LibraryError> { + use lofty::prelude::*; + use lofty::tag::ItemKey; + + let tagged_file = lofty::read_from_path(path).map_err(|error| { + LibraryError::Metadata(format!( + "Tags were written but could not be verified in {}: {error}", + path.display() + )) + })?; + let tag = tagged_file.primary_tag().ok_or_else(|| { + LibraryError::Metadata(format!( + "The canonical tag was missing after writing {}.", + path.display() + )) + })?; + + let actual_year = tag.date().map(|date| date.year as u32); + let expected_genre = album + .genre + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let expected_catalog = album + .catalog_number + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let expected_album_artist = + (!album.album_artist.trim().is_empty()).then(|| album.album_artist.trim().to_string()); + + let mut matches = tag.title().as_deref().map(str::trim) == Some(track.title.trim()) + && tag.album().as_deref().map(str::trim) == Some(album.album_title.trim()) + && tag.track() == track.track_number + && tag.disk() == track.disc_number + && actual_year == album.year + && tag.genre().as_deref().map(str::trim).map(str::to_string) == expected_genre + && normalized_tag_text(tag, ItemKey::AlbumArtist) == expected_album_artist + && normalized_tag_text(tag, ItemKey::CatalogNumber) == expected_catalog + && expected_artist.map_or(true, |artist| { + tag.artist().as_deref().map(str::trim) == Some(artist) + }); + if let (Some(album), Some(track)) = (extended_album, extended_track) { + let supports = |key: ItemKey| key.map_key(tag.tag_type()).is_some(); + let values_match = |key: ItemKey, expected: &[String]| { + !supports(key) || normalized_values(tag, key) == expected + }; + let option_match = |key: ItemKey, expected: Option<&str>| { + !supports(key) + || normalized_optional(tag, key).as_deref() + == expected.map(str::trim).filter(|value| !value.is_empty()) + }; + matches = matches + && tag.artist().as_deref().map(str::trim) == Some(track.artist_credit.trim()) + && values_match(ItemKey::TrackArtists, &track.artists) + && values_match(ItemKey::AlbumArtists, &album.album_artists) + && values_match(ItemKey::Composer, &track.composers) + && values_match(ItemKey::Performer, &track.performers) + && option_match( + ItemKey::MusicBrainzReleaseId, + album.musicbrainz_release_id.as_deref(), + ) + && option_match( + ItemKey::MusicBrainzReleaseGroupId, + album.musicbrainz_release_group_id.as_deref(), + ) + && values_match( + ItemKey::MusicBrainzReleaseArtistId, + &album.musicbrainz_album_artist_ids, + ) + && option_match( + ItemKey::MusicBrainzRecordingId, + track.musicbrainz_recording_id.as_deref(), + ) + && option_match( + ItemKey::MusicBrainzTrackId, + track.musicbrainz_track_id.as_deref(), + ) + && values_match(ItemKey::MusicBrainzArtistId, &track.musicbrainz_artist_ids); + if let Some(compilation) = album.compilation { + matches = matches + && (!supports(ItemKey::FlagCompilation) + || normalized_optional(tag, ItemKey::FlagCompilation) + .map(|value| { + matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes") + }) + .unwrap_or(false) + == compilation); + } + } + if let Some(bytes) = expected_artwork { + use lofty::picture::PictureType; + matches = matches + && tag + .get_picture_type(PictureType::CoverFront) + .map(|picture| picture.data()) + == Some(bytes); + } + if matches { + Ok(()) + } else { + Err(LibraryError::Metadata(format!( + "Tag verification failed for {}. The library index was not updated.", + path.display() + ))) + } +} + +/// Standards-aware direct embedded-tag writer. +/// +/// The operation deduplicates by path, preflights **every** member for +/// existence, read/write access, parseability and a writable canonical tag +/// before touching the first file, then verifies the canonical values after +/// every save. An I/O failure can still leave earlier files changed — no audio +/// format offers a cross-file transaction — but predictable permission, +/// format and missing-mount failures are caught before that point. +/// +/// Existing secondary layers are preserved. With +/// `synchronize_secondary_tags`, existing writable modern layers are updated +/// too; ID3v1 stays untouched because its 30-byte/Latin-1 limits would silently +/// truncate or corrupt values that are valid in the canonical tag. +fn write_album_tags_impl( + album: &AlbumTagWrite, + extended_album: &ExtendedAlbumTagWrite, tracks: &[TrackTagWrite], + extended_tracks: &[ExtendedTrackTagWrite], + front_cover: Option<&FrontCoverWrite>, + apply_extended: bool, + options: DirectTagWriteOptions, mut on_progress: impl FnMut(usize, usize), ) -> Result<(), LibraryError> { use lofty::config::WriteOptions; use lofty::prelude::*; - use lofty::tag::{ItemKey, Tag}; + use lofty::tag::{Tag, TagType}; - // Dedup by file_path, first wins, original order preserved. - let mut seen = std::collections::HashSet::new(); + let extended_by_path = extended_tracks + .iter() + .map(|track| (track.file_path.as_str(), track)) + .collect::<HashMap<_, _>>(); + if apply_extended + && tracks + .iter() + .any(|track| !extended_by_path.contains_key(track.file_path.as_str())) + { + return Err(LibraryError::Metadata( + "The extended tag rows do not match the audio rows.".to_string(), + )); + } + let artwork = front_cover + .map(|cover| { + let mut picture = lofty::picture::Picture::from_reader(&mut cover.bytes.as_slice()) + .map_err(|error| { + LibraryError::Metadata(format!( + "The selected cover is not a supported image: {error}" + )) + })?; + picture.set_pic_type(lofty::picture::PictureType::CoverFront); + Ok::<_, LibraryError>(picture) + }) + .transpose()?; + + let mut seen = HashSet::new(); let unique: Vec<&TrackTagWrite> = tracks .iter() - .filter(|t| seen.insert(t.file_path.clone())) + .filter(|track| seen.insert(track.file_path.clone())) .collect(); - let total = unique.len(); + if unique.is_empty() { + return Err(LibraryError::Metadata( + "No audio files were provided for direct tag writing.".to_string(), + )); + } - for (i, track) in unique.iter().enumerate() { - on_progress(i + 1, total); + // Read the prior per-track artist BEFORE any edit, so the rename rule sees + // one coherent album rather than the progressively modified loop state. + let previous_artist = if album.album_artist.trim().is_empty() { + None + } else { + let paths: Vec<&str> = unique + .iter() + .map(|track| track.file_path.as_str()) + .collect(); + uniform_file_artist(&paths) + }; + // Full preflight + in-memory mutation. The write loop below therefore has + // no expected missing-file, permission, parse or unsupported-tag failure. + let mut prepared = Vec::with_capacity(unique.len()); + for track in unique { let path = Path::new(&track.file_path); if !path.is_file() { - return Err(LibraryError::Metadata( - "One or more audio files were not found on disk.".to_string(), - )); + return Err(LibraryError::Metadata(format!( + "Audio file not found: {}", + path.display() + ))); + } + OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|error| { + LibraryError::Metadata(format!( + "Audio file is not writable ({}): {error}", + path.display() + )) + })?; + + let mut tagged_file = lofty::read_from_path(path).map_err(|error| { + LibraryError::Metadata(format!( + "Failed to read audio tags in {}: {error}", + path.display() + )) + })?; + let primary_type = tagged_file.primary_tag_type(); + if !tagged_file.tag_support(primary_type).is_writable() { + return Err(LibraryError::Metadata(format!( + "{} does not have a writable canonical tag for this format.", + path.display() + ))); } - let mut tagged_file = lofty::read_from_path(path) - .map_err(|_| LibraryError::Metadata("Failed to read audio file tags.".to_string()))?; + let expected_artist = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + .and_then(|tag| tag.artist()) + .map(|artist| artist.into_owned()) + .filter(|artist| { + should_rename_artist( + Some(artist.as_str()), + previous_artist.as_deref(), + &album.album_artist, + ) + }) + .map(|_| album.album_artist.trim().to_string()); - let primary_type = tagged_file.primary_tag_type(); - if tagged_file.primary_tag_mut().is_none() && tagged_file.first_tag_mut().is_none() { + if tagged_file.primary_tag().is_none() { tagged_file.insert_tag(Tag::new(primary_type)); } - { - let tag = if let Some(tag) = tagged_file.primary_tag_mut() { - tag - } else if let Some(tag) = tagged_file.first_tag_mut() { - tag - } else { - return Err(LibraryError::Metadata( - "Failed to access audio file tags.".to_string(), - )); - }; - - tag.set_title(track.title.trim().to_string()); - tag.set_album(album.album_title.trim().to_string()); - tag.set_artist(album.album_artist.trim().to_string()); - - if let Some(no) = track.track_number { - tag.set_track(no); + let mut targets = vec![primary_type]; + if options.synchronize_secondary_tags { + for tag in tagged_file.tags() { + let tag_type = tag.tag_type(); + if tag_type != primary_type + && tag_type != TagType::Id3v1 + && tagged_file.tag_support(tag_type).is_writable() + && !targets.contains(&tag_type) + { + targets.push(tag_type); + } } - if let Some(disc) = track.disc_number { - tag.set_disk(disc); - } - - // Album artist (not part of the Accessor trait). - if album.album_artist.trim().is_empty() { - tag.remove_key(ItemKey::AlbumArtist); - } else { - tag.insert_text(ItemKey::AlbumArtist, album.album_artist.trim().to_string()); - } - - // Year. - if let Some(year) = album.year { - tag.set_date(lofty::tag::items::Timestamp { - year: year as u16, - ..Default::default() - }); - } else { - tag.remove_date(); - } - - // Genre. - if let Some(g) = album - .genre - .as_ref() - .map(|g| g.trim()) - .filter(|g| !g.is_empty()) - { - tag.set_genre(g.to_string()); - } else { - tag.remove_genre(); - } - - // Catalog number. - if let Some(c) = album - .catalog_number - .as_ref() - .map(|c| c.trim()) - .filter(|c| !c.is_empty()) - { - tag.insert_text(ItemKey::CatalogNumber, c.to_string()); - } else { - tag.remove_key(ItemKey::CatalogNumber); + } + for tag_type in targets { + let tag = tagged_file.tag_mut(tag_type).ok_or_else(|| { + LibraryError::Metadata(format!( + "Failed to access {} in {}.", + tag_type_name(tag_type), + path.display() + )) + })?; + apply_editor_fields(tag, album, track, previous_artist.as_deref()); + if apply_extended { + let extended_track = extended_by_path + .get(track.file_path.as_str()) + .expect("validated extended editor row"); + apply_extended_editor_fields(tag, extended_album, extended_track, artwork.as_ref()); } } + prepared.push((track, tagged_file, expected_artist)); + } + + let total = prepared.len(); + let mut write_options = WriteOptions::new(); + write_options.use_id3v23(options.id3v2_version == Id3v2WriteVersion::V23); + // Never turn a valid Unicode tag into '?' merely because a secondary + // layer cannot represent it. The canonical formats all support Unicode. + write_options.lossy_text_encoding(false); + + for (index, (track, tagged_file, expected_artist)) in prepared.into_iter().enumerate() { + let path = Path::new(&track.file_path); tagged_file - .save_to_path(path, WriteOptions::default()) - .map_err(|_| { - LibraryError::Metadata( - "Failed to write tags to audio files. Check that the album folder is mounted \ - read-write and you have permissions." - .to_string(), - ) + .save_to_path(path, write_options) + .map_err(|error| { + LibraryError::Metadata(format!( + "Failed to write tags to {}: {error}", + path.display() + )) })?; + let extended_track = apply_extended.then(|| { + extended_by_path + .get(track.file_path.as_str()) + .copied() + .expect("validated extended editor row") + }); + verify_editor_write( + path, + album, + track, + expected_artist.as_deref(), + apply_extended.then_some(extended_album), + extended_track, + apply_extended + .then(|| front_cover.map(|cover| cover.bytes.as_slice())) + .flatten(), + )?; + on_progress(index + 1, total); } Ok(()) } +/// Write the editor's canonical display fields, ordered artist components, +/// credits, standard MusicBrainz identifiers and an optional front cover. +pub fn write_album_tags_to_files_extended( + album: &AlbumTagWrite, + extended_album: &ExtendedAlbumTagWrite, + tracks: &[TrackTagWrite], + extended_tracks: &[ExtendedTrackTagWrite], + front_cover: Option<&FrontCoverWrite>, + options: DirectTagWriteOptions, + on_progress: impl FnMut(usize, usize), +) -> Result<(), LibraryError> { + write_album_tags_impl( + album, + extended_album, + tracks, + extended_tracks, + front_cover, + true, + options, + on_progress, + ) +} + +pub fn write_album_tags_to_files_with_options( + album: &AlbumTagWrite, + tracks: &[TrackTagWrite], + options: DirectTagWriteOptions, + on_progress: impl FnMut(usize, usize), +) -> Result<(), LibraryError> { + let extended_tracks = tracks + .iter() + .map(|track| ExtendedTrackTagWrite { + file_path: track.file_path.clone(), + ..ExtendedTrackTagWrite::default() + }) + .collect::<Vec<_>>(); + write_album_tags_impl( + album, + &ExtendedAlbumTagWrite::default(), + tracks, + &extended_tracks, + None, + false, + options, + on_progress, + ) +} + +/// Backwards-compatible default used by the frozen Slint frontend: canonical +/// tag, ID3v2.4, secondary layers preserved. +pub fn write_album_tags_to_files( + album: &AlbumTagWrite, + tracks: &[TrackTagWrite], + on_progress: impl FnMut(usize, usize), +) -> Result<(), LibraryError> { + write_album_tags_to_files_with_options( + album, + tracks, + DirectTagWriteOptions::default(), + on_progress, + ) +} + /// Returns `Some(v)` iff every non-blank track shares one /// `album_artist ?? artist`, else `None`. Empty / all-blank => `None`. /// Port of the Tauri `library_compute_track_artist_match`. @@ -166,3 +1009,528 @@ pub fn compute_track_artist_match(tracks: &[LocalTrack]) -> Option<String> { } artists.into_iter().next() } + +// ─── Purchase downloads ────────────────────────────────────────────────────── + +/// Everything a freshly downloaded purchased track should carry in its embedded +/// tags. Every field comes from the API payload the download already fetched +/// (`/track/get` runs before every purchase download), so nothing here is +/// guessed from the file. +#[derive(Debug, Clone, Default)] +pub struct PurchaseTagWrite { + pub title: String, + /// Track subtitle ("Live", "Remastered"). Written to its own tag rather than + /// glued onto TITLE, so the value survives losslessly and TITLE keeps + /// matching the on-disk filename. + pub version: Option<String>, + /// The track's OWN performer. This is the field that makes a purpose-built + /// writer necessary — see [`write_purchase_tags`]. + pub artist: String, + pub album_title: String, + pub album_artist: String, + pub track_number: Option<u32>, + pub disc_number: Option<u32>, + pub year: Option<u32>, + pub genre: Option<String>, + pub label: Option<String>, + pub isrc: Option<String>, + pub composer: Option<String>, + pub copyright: Option<String>, +} + +/// Tag a freshly downloaded purchased file, optionally embedding cover art. +/// +/// **Why this is not `write_album_tags_to_files`.** That writer serves the +/// metadata EDITOR, and two of its defining behaviours are wrong here: +/// +/// 1. It never writes ARTIST outright. It renames the track artist only where +/// the file's existing value still matches the album's previously-uniform +/// one (`should_rename_artist`), and it derives that prior value by READING +/// the files. A just-downloaded file has no tags at all, so the uniform +/// lookup yields `None`, the rename is declined, and every purchased track +/// would land with NO artist. A purchase knows the real per-track performer +/// from the API, so it writes it directly and skips the rename machinery. +/// 2. It is destructive by design — a `None` field REMOVES that tag, which is +/// what an editor means by clearing a field. A download has nothing to +/// clear, so absent fields are skipped instead. +/// +/// It is also single-file and independent: the album loop tags each track as it +/// lands, so a cancelled or partly-failed album still leaves correct tags on +/// whatever did download. +/// +/// **Failure is never fatal to the download.** The file is the deliverable; tags +/// are an enhancement over the reference, which wrote none at all. Callers log +/// the error and move on. +pub fn write_purchase_tags( + file_path: &str, + meta: &PurchaseTagWrite, + cover_jpeg: Option<&[u8]>, +) -> Result<(), LibraryError> { + use lofty::config::WriteOptions; + use lofty::picture::{MimeType, Picture, PictureType}; + use lofty::prelude::*; + use lofty::tag::{ItemKey, Tag}; + + let path = Path::new(file_path); + let mut tagged_file = lofty::read_from_path(path) + .map_err(|e| LibraryError::Metadata(format!("Failed to read downloaded file tags: {e}")))?; + + // ALWAYS the format's PRIMARY tag type — never a "first tag we can find" + // fallback. This is the difference between tagging the file and silently + // tagging nothing: + // + // lofty will not WRITE a tag type a container only supports READING. A FLAC + // may legitimately arrive carrying an ID3v2 tag; for such a file + // `primary_tag_mut()` (VorbisComments) is `None` while `first_tag_mut()` is + // `Some(id3v2)`. A fallback would happily fill that ID3v2 tag, and then + // `save_to_path` would SKIP it — `tagged_file.rs` does `continue` on any + // non-writable tag rather than erroring, precisely because callers are not + // expected to know which ones are read-only. The result is a file with none + // of its tags and a `Ok(())` return: no log line, no failure, nothing to + // notice. The same trap costs six fields plus all artwork on an MP3 that + // carries only ID3v1, whose key set is eight entries wide. + // + // Inserting the primary type is safe unconditionally — `insert_tag` replaces + // any same-type tag — but it is done only when absent so an existing tag's + // unrelated fields survive. + let primary_type = tagged_file.primary_tag_type(); + if tagged_file.primary_tag_mut().is_none() { + tagged_file.insert_tag(Tag::new(primary_type)); + } + + { + let tag = tagged_file.primary_tag_mut().ok_or_else(|| { + LibraryError::Metadata("Failed to access downloaded file tags.".to_string()) + })?; + + let set_text = |tag: &mut Tag, key: ItemKey, value: Option<&str>| { + if let Some(value) = value.map(str::trim).filter(|v| !v.is_empty()) { + tag.insert_text(key, value.to_string()); + } + }; + + // Empties are SKIPPED, not written. A download has nothing to clear, and + // an explicit empty TITLE/ALBUM is worse than an absent one — it defeats + // any later repair pass that looks for missing tags. + if !meta.title.trim().is_empty() { + tag.set_title(meta.title.trim().to_string()); + } + if !meta.album_title.trim().is_empty() { + tag.set_album(meta.album_title.trim().to_string()); + } + + // Written unconditionally, from the API — never inferred from the file. + if !meta.artist.trim().is_empty() { + tag.set_artist(meta.artist.trim().to_string()); + } + + // `Track.track_number` is a non-optional `u32` that DEFAULTS to 0 when + // the API omits it, so an unguarded write stamps `TRACKNUMBER=0` on + // singles and unnumbered releases. Zero is not a track number. + if let Some(no) = meta.track_number.filter(|n| *n > 0) { + tag.set_track(no); + } + if let Some(disc) = meta.disc_number.filter(|d| *d > 0) { + tag.set_disk(disc); + } + if let Some(year) = meta.year { + tag.set_date(lofty::tag::items::Timestamp { + year: year as u16, + ..Default::default() + }); + } + if let Some(genre) = meta + .genre + .as_deref() + .map(str::trim) + .filter(|g| !g.is_empty()) + { + tag.set_genre(genre.to_string()); + } + + set_text(tag, ItemKey::AlbumArtist, Some(meta.album_artist.as_str())); + set_text(tag, ItemKey::TrackSubtitle, meta.version.as_deref()); + set_text(tag, ItemKey::Label, meta.label.as_deref()); + set_text(tag, ItemKey::Isrc, meta.isrc.as_deref()); + set_text(tag, ItemKey::Composer, meta.composer.as_deref()); + set_text(tag, ItemKey::CopyrightMessage, meta.copyright.as_deref()); + + if let Some(bytes) = cover_jpeg.filter(|b| !b.is_empty()) { + // Drop any existing embedded art before pushing. On the download path + // the file is always brand new, so today this is a no-op — but + // `push_picture` APPENDS, so the moment anything re-tags an existing + // file (a retry against a file the rename did not replace, or a + // future re-tag action) the art would accumulate a second copy and + // bloat every track by the size of the cover. + // + // BOTH types are removed, not just `CoverFront`: vendor-tagged files + // commonly store the front cover as `Other`, and clearing only + // `CoverFront` would leave that one in place and produce exactly the + // duplicate this guard exists to prevent. + tag.remove_picture_type(PictureType::CoverFront); + tag.remove_picture_type(PictureType::Other); + // `unchecked` skips lofty's format sniffing: the bytes come from the + // album's `image.large`, which Qobuz serves as JPEG, and the mime is + // declared rather than inferred. + tag.push_picture( + Picture::unchecked(bytes.to_vec()) + .pic_type(PictureType::CoverFront) + .mime_type(MimeType::Jpeg) + .build(), + ); + } + } + + tagged_file + .save_to_path(path, WriteOptions::default()) + .map_err(|e| LibraryError::Metadata(format!("Failed to write downloaded file tags: {e}"))) +} + +#[cfg(test)] +mod editor_write_target_tests { + use super::*; + + /// A FLAC that arrives carrying an ID3v2 tag — an ordinary shape, and the + /// one that exposed a silent data-loss bug in the metadata editor. + fn flac_with_leading_id3v2() -> Vec<u8> { + let mut frame = b"TIT2".to_vec(); + let text = b"\x03OldTitle"; // 0x03 = UTF-8 + frame.extend_from_slice(&(text.len() as u32).to_be_bytes()); + frame.extend_from_slice(&[0x00, 0x00]); + frame.extend_from_slice(text); + + let size = frame.len() as u32; + let syncsafe = [ + ((size >> 21) & 0x7F) as u8, + ((size >> 14) & 0x7F) as u8, + ((size >> 7) & 0x7F) as u8, + (size & 0x7F) as u8, + ]; + + let mut out = b"ID3".to_vec(); + out.extend_from_slice(&[0x04, 0x00, 0x00]); + out.extend_from_slice(&syncsafe); + out.extend_from_slice(&frame); + + // A minimal but valid FLAC: the marker plus one final STREAMINFO block. + out.extend_from_slice(b"fLaC"); + out.extend_from_slice(&[0x80, 0x00, 0x00, 0x22]); + let mut info = vec![0u8; 34]; + info[0..2].copy_from_slice(&4096u16.to_be_bytes()); + info[2..4].copy_from_slice(&4096u16.to_be_bytes()); + let sample_rate: u32 = 44_100; + info[10] = (sample_rate >> 12) as u8; + info[11] = ((sample_rate >> 4) & 0xFF) as u8; + info[12] = (((sample_rate & 0x0F) as u8) << 4) | (1 << 1); + info[13] = 0xF0; + out.extend_from_slice(&info); + out + } + + /// The metadata editor must write into the tag type the container can + /// actually SAVE, not merely into the first tag the file happens to carry. + /// + /// This is the regression for a silent data-loss bug: the write target used + /// to fall back to `first_tag_mut()`, which for this file is the ID3v2 tag. + /// lofty refuses to write ID3v2 to FLAC and skips it WITHOUT erroring, so + /// every edited field was discarded while the call returned `Ok(())` — the + /// user pressed Save, got a success toast, and the file never changed. + /// + /// Verified to fail against the old target selection before being accepted. + #[test] + fn editing_a_flac_that_carries_id3v2_actually_writes() { + use lofty::prelude::*; + + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("legacy.flac"); + std::fs::write(&file, flac_with_leading_id3v2()).unwrap(); + + // The fixture must genuinely present the trap, or the test proves nothing. + let before = lofty::read_from_path(&file).expect("readable FLAC fixture"); + assert!( + before.tag(lofty::tag::TagType::Id3v2).is_some(), + "fixture must carry ID3v2" + ); + assert!( + before.tag(lofty::tag::TagType::VorbisComments).is_none(), + "fixture must not already have Vorbis comments" + ); + + let album = AlbumTagWrite { + album_title: "Edited Album".to_string(), + album_artist: "Edited Artist".to_string(), + year: Some(1999), + genre: Some("Jazz".to_string()), + catalog_number: None, + }; + let tracks = vec![TrackTagWrite { + file_path: file.to_string_lossy().to_string(), + title: "Edited Title".to_string(), + track_number: Some(4), + disc_number: Some(2), + }]; + + write_album_tags_to_files(&album, &tracks, |_, _| {}).expect("the edit must succeed"); + + let after = lofty::read_from_path(&file).expect("re-read"); + let vorbis = after + .tag(lofty::tag::TagType::VorbisComments) + .expect("the edit must land in VORBIS COMMENTS — the type FLAC can write"); + + assert_eq!(vorbis.title().as_deref(), Some("Edited Title")); + assert_eq!(vorbis.album().as_deref(), Some("Edited Album")); + assert_eq!(vorbis.track(), Some(4)); + assert_eq!(vorbis.disk(), Some(2)); + assert_eq!(vorbis.genre().as_deref(), Some("Jazz")); + } + + #[test] + fn inspection_exposes_canonical_and_secondary_layers_without_writing() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("legacy.flac"); + std::fs::write(&file, flac_with_leading_id3v2()).unwrap(); + + let inspection = inspect_album_tag_layers(&[file.to_string_lossy().to_string()]).unwrap(); + assert_eq!(inspection.file_count, 1); + assert_eq!(inspection.writable_files, 1); + assert!(inspection.direct_write_supported()); + assert_eq!(inspection.canonical_layers.len(), 1); + assert_eq!(inspection.canonical_layers[0].name, "Vorbis comments"); + assert_eq!(inspection.canonical_layers[0].file_count, 0); + assert_eq!(inspection.canonical_layers[0].writable_file_count, 1); + assert_eq!(inspection.present_layers.len(), 1); + assert_eq!(inspection.present_layers[0].name, "ID3v2"); + assert_eq!(inspection.present_layers[0].writable_file_count, 0); + } + + #[test] + fn canonical_write_preserves_secondary_layer_and_reports_its_conflict() { + use lofty::prelude::*; + + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("legacy.flac"); + std::fs::write(&file, flac_with_leading_id3v2()).unwrap(); + let album = AlbumTagWrite { + album_title: "Edited Album".to_string(), + album_artist: "Edited Artist".to_string(), + year: Some(1999), + genre: None, + catalog_number: None, + }; + let tracks = vec![TrackTagWrite { + file_path: file.to_string_lossy().to_string(), + title: "Edited Title".to_string(), + track_number: Some(1), + disc_number: Some(1), + }]; + + write_album_tags_to_files(&album, &tracks, |_, _| {}).unwrap(); + let after = lofty::read_from_path(&file).unwrap(); + assert_eq!( + after + .tag(lofty::tag::TagType::Id3v2) + .and_then(|tag| tag.title()) + .as_deref(), + Some("OldTitle") + ); + assert_eq!( + after + .tag(lofty::tag::TagType::VorbisComments) + .and_then(|tag| tag.title()) + .as_deref(), + Some("Edited Title") + ); + + let inspection = inspect_album_tag_layers(&[file.to_string_lossy().to_string()]).unwrap(); + assert_eq!(inspection.conflicting_files, 1); + assert_eq!(inspection.present_layers.len(), 2); + } + + #[test] + fn clearing_track_and_disc_numbers_is_persisted_and_verified() { + use lofty::prelude::*; + + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("legacy.flac"); + std::fs::write(&file, flac_with_leading_id3v2()).unwrap(); + let album = AlbumTagWrite { + album_title: "Edited Album".to_string(), + album_artist: String::new(), + year: None, + genre: None, + catalog_number: None, + }; + let mut tracks = vec![TrackTagWrite { + file_path: file.to_string_lossy().to_string(), + title: "Edited Title".to_string(), + track_number: Some(9), + disc_number: Some(3), + }]; + write_album_tags_to_files(&album, &tracks, |_, _| {}).unwrap(); + + tracks[0].track_number = None; + tracks[0].disc_number = None; + let mut progress = Vec::new(); + write_album_tags_to_files(&album, &tracks, |current, total| { + progress.push((current, total)); + }) + .unwrap(); + + let after = lofty::read_from_path(&file).unwrap(); + let tag = after.primary_tag().unwrap(); + assert_eq!(tag.track(), None); + assert_eq!(tag.disk(), None); + assert_eq!(progress, vec![(1, 1)]); + } + + #[test] + fn rich_writer_preserves_display_credit_components_ids_and_front_cover() { + use lofty::picture::PictureType; + use lofty::prelude::*; + use std::io::Cursor; + + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("credits.flac"); + std::fs::write(&file, flac_with_leading_id3v2()).unwrap(); + let mut cover = Vec::new(); + image::DynamicImage::new_rgb8(2, 2) + .write_to(&mut Cursor::new(&mut cover), image::ImageFormat::Png) + .unwrap(); + let album = AlbumTagWrite { + album_title: "Compilation".to_string(), + album_artist: "Various Artists".to_string(), + year: Some(2026), + genre: Some("Soundtrack".to_string()), + catalog_number: Some("CAT-42".to_string()), + }; + let extended_album = ExtendedAlbumTagWrite { + album_artists: vec!["Various Artists".to_string()], + compilation: Some(true), + musicbrainz_release_id: Some("release-id".to_string()), + musicbrainz_release_group_id: Some("group-id".to_string()), + musicbrainz_album_artist_ids: vec!["va-id".to_string()], + discogs_release_id: Some("123".to_string()), + }; + let tracks = vec![TrackTagWrite { + file_path: file.to_string_lossy().to_string(), + title: "Collaboration".to_string(), + track_number: Some(1), + disc_number: Some(1), + }]; + let rich_tracks = vec![ExtendedTrackTagWrite { + file_path: tracks[0].file_path.clone(), + artist_credit: "Alpha feat. Beta".to_string(), + artists: vec!["Alpha".to_string(), "Beta".to_string()], + composers: vec!["Composer One".to_string()], + performers: vec!["Player One (guitar)".to_string()], + musicbrainz_recording_id: Some("recording-id".to_string()), + musicbrainz_track_id: Some("track-id".to_string()), + musicbrainz_artist_ids: vec!["alpha-id".to_string(), "beta-id".to_string()], + }]; + + write_album_tags_to_files_extended( + &album, + &extended_album, + &tracks, + &rich_tracks, + Some(&FrontCoverWrite { + bytes: cover.clone(), + }), + DirectTagWriteOptions::default(), + |_, _| {}, + ) + .unwrap(); + + let snapshot = read_editor_tag_snapshots( + &tracks + .iter() + .map(|t| t.file_path.clone()) + .collect::<Vec<_>>(), + ); + assert_eq!(snapshot[0].artist_credit, "Alpha feat. Beta"); + assert_eq!(snapshot[0].artists, ["Alpha", "Beta"]); + assert_eq!(snapshot[0].album_artists, ["Various Artists"]); + assert_eq!(snapshot[0].composers, ["Composer One"]); + assert_eq!( + snapshot[0].musicbrainz_release_id.as_deref(), + Some("release-id") + ); + assert_eq!( + snapshot[0].musicbrainz_recording_id.as_deref(), + Some("recording-id") + ); + assert_eq!(snapshot[0].musicbrainz_artist_ids, ["alpha-id", "beta-id"]); + let after = lofty::read_from_path(&file).unwrap(); + assert_eq!( + after + .primary_tag() + .and_then(|tag| tag.get_picture_type(PictureType::CoverFront)) + .map(|picture| picture.data()), + Some(cover.as_slice()) + ); + } + + #[test] + fn folder_cover_rejects_invalid_bytes_without_replacing_existing_art() { + let tmp = tempfile::tempdir().unwrap(); + let existing = tmp.path().join("cover.jpg"); + std::fs::write(&existing, b"existing cover").unwrap(); + + assert!(write_folder_front_cover(tmp.path(), b"not an image").is_err()); + assert_eq!(std::fs::read(existing).unwrap(), b"existing cover"); + } +} + +#[cfg(test)] +mod tests { + use super::should_rename_artist; + + #[test] + fn renames_only_the_artist_the_album_actually_had() { + // The ordinary case: one artist across the album, user renames it. + assert!(should_rename_artist( + Some("Seiji Yokoyama"), + Some("Seiji Yokoyama"), + "Yokoyama Seiji" + )); + // A guest track inside an otherwise uniform album keeps its performer. + assert!(!should_rename_artist( + Some("MAKE-UP"), + Some("Seiji Yokoyama"), + "Yokoyama Seiji" + )); + } + + #[test] + fn never_blanks_the_track_artist() { + // The various-artists shape: the editor leaves the field empty because + // the track artists disagree. Writing that empty value over 247 files + // is the data loss this guard exists to stop. + assert!(!should_rename_artist(Some("MAKE-UP"), None, "")); + assert!(!should_rename_artist(Some("MAKE-UP"), Some("MAKE-UP"), "")); + assert!(!should_rename_artist( + Some("MAKE-UP"), + Some("MAKE-UP"), + " " + )); + } + + #[test] + fn no_uniform_prior_artist_means_nothing_is_renamed() { + assert!(!should_rename_artist( + Some("MAKE-UP"), + None, + "Various Artists" + )); + assert!(!should_rename_artist( + Some("MAKE-UP"), + Some(""), + "Various Artists" + )); + assert!(!should_rename_artist( + None, + Some("MAKE-UP"), + "Yokoyama Seiji" + )); + } +} diff --git a/crates/qbz-library/src/thumbnails.rs b/crates/qbz-library/src/thumbnails.rs index 548ba468e..491eea0ea 100644 --- a/crates/qbz-library/src/thumbnails.rs +++ b/crates/qbz-library/src/thumbnails.rs @@ -12,6 +12,14 @@ use crate::LibraryError; /// 500px is a good balance for UI display while keeping file size reasonable const THUMBNAIL_SIZE: u32 = 500; +/// Bound for the ON-DEMAND large art tier (contract +/// `2026-08-15-immersive-completion` 04 §5, ruling 6): the big slots +/// (immersive main art, SPLIT, lightbox) decode the ORIGINAL embedded/folder +/// art bounded at 1600px — that covers the largest immersive slot +/// (~660-1000 CSS px) at 2x DPR headroom without unbounded decodes of 3000px +/// embedded art. Lists keep the 500px tier; nothing here changes their path. +pub const LARGE_ART_PX: u32 = 1600; + /// Get the thumbnails directory path pub fn get_thumbnails_dir() -> Result<PathBuf, LibraryError> { let data_dir = dirs::data_local_dir() @@ -129,6 +137,89 @@ pub fn get_or_generate_thumbnail(artwork_path: &Path) -> Result<PathBuf, Library generate_thumbnail(artwork_path) } +// --------------------------------------------------------------------------- +// Large art tier (on demand — the big immersive/lightbox slots) +// --------------------------------------------------------------------------- + +/// Large-tier filename for a source key (path or cache key): the SAME hasher +/// as the 500px tier, with a `_large` suffix so the two tiers sit side by +/// side in `thumbnails/` and never collide. +fn get_large_filename(source_key: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + source_key.hash(&mut hasher); + format!("{:016x}_large.jpg", hasher.finish()) +} + +/// The large-tier cache path for a source image (may not exist yet). +pub fn get_large_thumbnail_path(source_path: &Path) -> Result<PathBuf, LibraryError> { + let thumbnails_dir = get_thumbnails_dir()?; + let filename = get_large_filename(&source_path.to_string_lossy()); + Ok(thumbnails_dir.join(filename)) +} + +/// Decode `img` into the bounded large tier and cache it as JPEG. If the +/// source fits inside [`LARGE_ART_PX`] it is re-encoded at NATIVE size — +/// never upscaled. Returns the cache path. +fn save_large_tier(img: image::DynamicImage, out_path: &Path) -> Result<PathBuf, LibraryError> { + let bounded = if img.width() > LARGE_ART_PX || img.height() > LARGE_ART_PX { + img.resize(LARGE_ART_PX, LARGE_ART_PX, FilterType::Lanczos3) + } else { + img + }; + bounded + .save(out_path) + .map_err(|e| LibraryError::Other(format!("Failed to save large artwork: {}", e)))?; + Ok(out_path.to_path_buf()) +} + +/// Large-tier art for a cover FILE, on demand. If the original already fits +/// inside [`LARGE_ART_PX`] the ORIGINAL path is returned (serve native — no +/// duplicate bytes, no upscale); otherwise the bounded decode is cached as +/// `{hash}_large.jpg` beside the 500px thumbs. +pub fn get_or_generate_large_thumbnail(source_path: &Path) -> Result<PathBuf, LibraryError> { + let large_path = get_large_thumbnail_path(source_path)?; + if large_path.exists() { + return Ok(large_path); + } + + let img = ImageReader::open(source_path) + .map_err(|e| LibraryError::Other(format!("Failed to open image: {}", e)))? + .decode() + .map_err(|e| LibraryError::Other(format!("Failed to decode image: {}", e)))?; + + if img.width() <= LARGE_ART_PX && img.height() <= LARGE_ART_PX { + return Ok(source_path.to_path_buf()); + } + save_large_tier(img, &large_path) +} + +/// Large-tier art from EMBEDDED picture bytes (the source lives inside an +/// audio file, so "serve native" has no path to hand back — a native-size +/// re-encode is cached instead). `cache_key` is the audio file path, the +/// same key the 500px tier uses, so the two tiers hash to sibling names. +pub fn get_or_generate_large_thumbnail_from_bytes( + bytes: &[u8], + cache_key: &str, +) -> Result<PathBuf, LibraryError> { + let thumbnails_dir = get_thumbnails_dir()?; + let large_path = thumbnails_dir.join(get_large_filename(cache_key)); + if large_path.exists() { + return Ok(large_path); + } + + let cursor = Cursor::new(bytes); + let img = ImageReader::new(cursor) + .with_guessed_format() + .map_err(|e| LibraryError::Other(format!("Failed to guess image format: {}", e)))? + .decode() + .map_err(|e| LibraryError::Other(format!("Failed to decode image: {}", e)))?; + + save_large_tier(img, &large_path) +} + /// Clear all thumbnails (useful for cache cleanup) pub fn clear_thumbnails() -> Result<(), LibraryError> { let thumbnails_dir = get_thumbnails_dir()?; @@ -166,3 +257,55 @@ pub fn get_cache_size() -> Result<u64, LibraryError> { Ok(total_size) } + +#[cfg(test)] +mod tests { + use super::*; + + fn solid_image(w: u32, h: u32) -> image::DynamicImage { + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + w, + h, + image::Rgb([120, 60, 200]), + )) + } + + fn decoded_size(path: &Path) -> (u32, u32) { + let img = ImageReader::open(path).unwrap().decode().unwrap(); + (img.width(), img.height()) + } + + #[test] + fn large_tier_bounds_big_sources_without_distorting() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("large.jpg"); + let saved = save_large_tier(solid_image(3200, 2400), &out).unwrap(); + assert_eq!(saved, out); + // Fit inside 1600, aspect preserved (4:3 -> 1600x1200). + assert_eq!(decoded_size(&saved), (LARGE_ART_PX, 1200)); + } + + #[test] + fn large_tier_never_upscales_small_sources() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("small.jpg"); + let saved = save_large_tier(solid_image(300, 300), &out).unwrap(); + // Native size is kept — a 300px cover stays 300px (the UI probe caps + // the slot at native; nothing invents pixels). + assert_eq!(decoded_size(&saved), (300, 300)); + } + + #[test] + fn large_filename_is_a_sibling_of_the_500px_key() { + // Same hasher, `_large` suffix: one source -> two tiers, no collision. + let key = "/music/Album/cover.jpg"; + let thumb = get_thumbnail_filename(Path::new(key)); + let large = get_large_filename(key); + assert!(thumb.ends_with(".jpg")); + assert!(large.ends_with("_large.jpg")); + assert_eq!( + thumb.strip_suffix(".jpg").unwrap(), + large.strip_suffix("_large.jpg").unwrap() + ); + } +} diff --git a/crates/qbz-library/src/watcher.rs b/crates/qbz-library/src/watcher.rs new file mode 100644 index 000000000..11f854c77 --- /dev/null +++ b/crates/qbz-library/src/watcher.rs @@ -0,0 +1,146 @@ +//! Best-effort local-root watcher. +//! +//! Watch notifications are only acceleration hints. The scheduler always +//! performs periodic generation scans as the source of truth, and network +//! roots are deliberately excluded because recursive watches on NAS mounts are +//! not reliable enough to authorize deletion. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::time::Duration; + +use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; + +use crate::{LibraryError, LibraryFolder}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RootWatchEvent { + Changed(Vec<i64>), + Error(Vec<i64>), + Disconnected, + Timeout, +} + +pub struct LocalRootWatcher { + watcher: RecommendedWatcher, + receiver: Receiver<notify::Result<Event>>, + roots: BTreeMap<i64, PathBuf>, +} + +impl LocalRootWatcher { + pub fn new(folders: &[LibraryFolder]) -> Result<Self, LibraryError> { + let (sender, receiver) = mpsc::channel(); + let watcher = notify::recommended_watcher(move |event| { + let _ = sender.send(event); + }) + .map_err(|error| LibraryError::Other(format!("local root watcher: {error}")))?; + let mut result = Self { + watcher, + receiver, + roots: BTreeMap::new(), + }; + result.rebuild(folders)?; + Ok(result) + } + + pub fn rebuild(&mut self, folders: &[LibraryFolder]) -> Result<(), LibraryError> { + let desired = folders + .iter() + .filter(|folder| folder.enabled && !folder.is_network) + .map(|folder| (folder.id, PathBuf::from(&folder.path))) + .collect::<BTreeMap<_, _>>(); + + for (root_id, path) in self.roots.clone() { + if desired.get(&root_id) == Some(&path) { + continue; + } + let _ = self.watcher.unwatch(&path); + self.roots.remove(&root_id); + } + let mut watch_failures = 0_usize; + for (root_id, path) in &desired { + if self.roots.get(root_id) == Some(path) { + continue; + } + // A missing/newly-unmounted root remains covered by periodic + // reconciliation. Failing to install its hint is not fatal. + if self.watcher.watch(path, RecursiveMode::Recursive).is_ok() { + self.roots.insert(*root_id, path.clone()); + } else { + watch_failures += 1; + } + } + if watch_failures > 0 { + log::warn!( + "[local-scan] watcher unavailable for {watch_failures} local root(s); periodic reconciliation remains active" + ); + } + Ok(()) + } + + pub fn recv_timeout(&self, timeout: Duration) -> RootWatchEvent { + match self.receiver.recv_timeout(timeout) { + Ok(Ok(event)) => RootWatchEvent::Changed(self.roots_for_paths(&event.paths)), + Ok(Err(error)) => RootWatchEvent::Error(self.roots_for_paths(&error.paths)), + Err(RecvTimeoutError::Timeout) => RootWatchEvent::Timeout, + Err(RecvTimeoutError::Disconnected) => RootWatchEvent::Disconnected, + } + } + + fn roots_for_paths(&self, paths: &[PathBuf]) -> Vec<i64> { + let mut roots = BTreeSet::new(); + for path in paths { + for (root_id, root) in &self.roots { + if path.starts_with(root) { + roots.insert(*root_id); + } + } + } + roots.into_iter().collect() + } + + #[cfg(test)] + fn watched_roots(&self) -> usize { + self.roots.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn folder(id: i64, path: &std::path::Path, network: bool) -> LibraryFolder { + LibraryFolder { + id, + path: path.to_string_lossy().into_owned(), + alias: None, + enabled: true, + is_network: network, + network_fs_type: network.then(|| "nfs".to_string()), + user_override_network: network, + last_scan: None, + } + } + + #[test] + fn only_local_roots_are_watched_and_nested_path_queues_every_owner() { + let temp = tempfile::tempdir().unwrap(); + let local = temp.path().join("local"); + let nested = local.join("nested"); + let network = temp.path().join("network"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(&network).unwrap(); + let watcher = LocalRootWatcher::new(&[ + folder(1, &local, false), + folder(2, &nested, false), + folder(3, &network, true), + ]) + .unwrap(); + assert_eq!(watcher.watched_roots(), 2); + assert_eq!( + watcher.roots_for_paths(&[nested.join("track.flac")]), + vec![1, 2] + ); + } +} diff --git a/crates/qbz-local-catalog/Cargo.toml b/crates/qbz-local-catalog/Cargo.toml new file mode 100644 index 000000000..4f0ee1c77 --- /dev/null +++ b/crates/qbz-local-catalog/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "qbz-local-catalog" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +log.workspace = true +rusqlite = { version = "0.40", features = ["backup", "bundled"] } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1", features = ["fs"] } + +[target.'cfg(windows)'.dependencies] +# GetDiskFreeSpaceExW for the free-space preflight; LockFileEx (+ OVERLAPPED, +# hence Win32_System_IO) for the bootstrap lock. 0.61 is already in the +# workspace lock, so this pulls nothing new. +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_IO", +] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/qbz-local-catalog/src/bootstrap.rs b/crates/qbz-local-catalog/src/bootstrap.rs new file mode 100644 index 000000000..7f9601981 --- /dev/null +++ b/crates/qbz-local-catalog/src/bootstrap.rs @@ -0,0 +1,738 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::{Catalog, CatalogError, ProjectedTrack, Result, SourceKey, SCHEMA_VERSION}; + +pub const BOOTSTRAP_BATCH_ROWS: usize = 250; +const CATALOG_BYTES_PER_TRACK: u64 = 1_280; +const SAFETY_FLOOR_BYTES: u64 = 256 * 1024 * 1024; +const MANIFEST_NAME: &str = "local_catalog-v1-manifest.json"; +pub(crate) const LOCK_NAME: &str = "local_catalog-v1-bootstrap.lock"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceProbe { + pub source: SourceKey, + pub source_path: PathBuf, + pub snapshot_version: String, + pub row_count: u64, + pub page_bytes: u64, + pub integrity_ok: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreflightReport { + pub source_count: usize, + pub track_count: u64, + pub source_page_bytes: u64, + pub estimated_catalog_bytes: u64, + pub required_available_bytes: u64, + pub available_bytes: u64, +} + +impl PreflightReport { + pub fn evaluate(probes: &[SourceProbe], available_bytes: u64) -> Result<Self> { + if let Some(probe) = probes.iter().find(|probe| !probe.integrity_ok) { + return Err(CatalogError::InvalidSource(format!( + "{} failed SQLite quick_check", + probe.source_path.display() + ))); + } + let track_count = probes + .iter() + .fold(0_u64, |total, probe| total.saturating_add(probe.row_count)); + let mut source_files = BTreeMap::<&Path, u64>::new(); + for probe in probes { + source_files + .entry(&probe.source_path) + .and_modify(|bytes| *bytes = (*bytes).max(probe.page_bytes)) + .or_insert(probe.page_bytes); + } + let source_page_bytes = source_files + .values() + .fold(0_u64, |total, bytes| total.saturating_add(*bytes)); + let row_estimate = track_count.saturating_mul(CATALOG_BYTES_PER_TRACK); + let estimated_catalog_bytes = source_page_bytes.max(row_estimate); + let margin = estimated_catalog_bytes.saturating_add(3) / 4; + let required_available_bytes = estimated_catalog_bytes + .saturating_add(margin) + .saturating_add(SAFETY_FLOOR_BYTES); + let report = Self { + source_count: probes.len(), + track_count, + source_page_bytes, + estimated_catalog_bytes, + required_available_bytes, + available_bytes, + }; + if available_bytes < required_available_bytes { + return Err(CatalogError::InsufficientSpace { + required_bytes: required_available_bytes, + available_bytes, + }); + } + Ok(report) + } + + pub(crate) fn with_catalog_floor(mut self, catalog_bytes: u64) -> Result<Self> { + if catalog_bytes <= self.estimated_catalog_bytes { + return Ok(self); + } + self.estimated_catalog_bytes = catalog_bytes; + let margin = catalog_bytes.saturating_add(3) / 4; + self.required_available_bytes = catalog_bytes + .saturating_add(margin) + .saturating_add(SAFETY_FLOOR_BYTES); + if self.available_bytes < self.required_available_bytes { + return Err(CatalogError::InsufficientSpace { + required_bytes: self.required_available_bytes, + available_bytes: self.available_bytes, + }); + } + Ok(self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BootstrapManifest { + pub manifest_version: u32, + pub schema_version: u32, + pub active_generation: u64, + pub previous_generation: Option<u64>, + pub activated_at_unix_ms: u64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct GenerationPruneReport { + pub removed_generations: usize, + pub reclaimed_bytes: u64, + pub failed_generations: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FallbackReason { + NoManifest, + InvalidManifest(String), + MissingGeneration(PathBuf), + CatalogRejected(String), +} + +pub enum ActiveCatalog { + Ready { + catalog: Catalog, + manifest: BootstrapManifest, + }, + Fallback(FallbackReason), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceCheckpoint { + pub source: SourceKey, + pub available: bool, + pub checkpoint_cursor: String, + pub checkpoint_rows: u64, + pub checkpoint_version: String, + pub complete_generation: u64, + pub complete: bool, +} + +#[derive(Debug, Clone)] +pub struct BootstrapBatch { + pub source: SourceKey, + pub snapshot_version: String, + pub expected_cursor: String, + pub next_cursor: String, + pub tracks: Vec<ProjectedTrack>, + pub complete: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BootstrapOutcome { + Activated { + generation: u64, + track_count: u64, + resumed_rows: u64, + }, + Paused { + generation: u64, + source: Option<SourceKey>, + committed_rows: u64, + }, + Fallback(FallbackReason), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BootstrapProgress { + pub generation: u64, + pub source: SourceKey, + pub committed_rows: u64, + pub source_rows_total: u64, + pub overall_rows_written: u64, + pub overall_rows_total: u64, + /// One-based position of this source in the current bootstrap. + pub source_index: usize, + pub source_count: usize, + pub checkpoint_cursor: String, + pub source_complete: bool, +} + +#[derive(Debug, Clone)] +pub struct BootstrapLayout { + data_dir: PathBuf, +} + +impl BootstrapLayout { + pub fn new(data_dir: impl Into<PathBuf>) -> Self { + Self { + data_dir: data_dir.into(), + } + } + + pub fn data_dir(&self) -> &Path { + &self.data_dir + } + + pub fn manifest_path(&self) -> PathBuf { + self.data_dir.join(MANIFEST_NAME) + } + + pub fn generation_path(&self, generation: u64) -> PathBuf { + self.data_dir + .join(format!("local_catalog-v1-g{generation}.db")) + } + + pub fn building_path(&self, generation: u64) -> PathBuf { + self.data_dir + .join(format!("local_catalog-v1-g{generation}.db.building")) + } + + pub fn read_manifest(&self) -> std::result::Result<BootstrapManifest, FallbackReason> { + let path = self.manifest_path(); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(FallbackReason::NoManifest) + } + Err(error) => return Err(FallbackReason::InvalidManifest(error.to_string())), + }; + let manifest: BootstrapManifest = serde_json::from_slice(&bytes) + .map_err(|error| FallbackReason::InvalidManifest(error.to_string()))?; + if manifest.manifest_version != 1 || manifest.schema_version != SCHEMA_VERSION { + return Err(FallbackReason::InvalidManifest(format!( + "unsupported manifest/schema {}/{}", + manifest.manifest_version, manifest.schema_version + ))); + } + Ok(manifest) + } + + pub fn open_active(&self) -> ActiveCatalog { + let manifest = match self.read_manifest() { + Ok(manifest) => manifest, + Err(reason) => return ActiveCatalog::Fallback(reason), + }; + let path = self.generation_path(manifest.active_generation); + if !path.is_file() { + return ActiveCatalog::Fallback(FallbackReason::MissingGeneration(path)); + } + match Catalog::open_read_only(&path, manifest.active_generation) { + Ok(catalog) => ActiveCatalog::Ready { catalog, manifest }, + Err(error) => { + ActiveCatalog::Fallback(FallbackReason::CatalogRejected(error.to_string())) + } + } + } + + pub fn available_bytes(&self) -> Result<u64> { + let probe_path = nearest_existing_ancestor(&self.data_dir).ok_or_else(|| { + CatalogError::InvalidInput(format!( + "no existing ancestor for {}", + self.data_dir.display() + )) + })?; + filesystem_available_bytes(probe_path) + } + + pub fn preflight(&self, probes: &[SourceProbe]) -> Result<PreflightReport> { + PreflightReport::evaluate(probes, self.available_bytes()?) + } + + pub fn prepare( + &self, + probes: &[SourceProbe], + available_bytes: Option<u64>, + ) -> Result<(BootstrapSession, PreflightReport)> { + let available = match available_bytes { + Some(bytes) => bytes, + None => self.available_bytes()?, + }; + let preflight = PreflightReport::evaluate(probes, available)?; + fs::create_dir_all(&self.data_dir)?; + let lock = acquire_lock(&self.data_dir.join(LOCK_NAME))?; + let active_generation = self + .read_manifest() + .ok() + .map(|manifest| manifest.active_generation); + let generation = self.next_building_generation(active_generation)?; + let building_path = self.building_path(generation); + let base_generation = active_generation.unwrap_or(0); + let catalog = match Catalog::open(&building_path, generation).and_then(|mut catalog| { + let (phase, base) = catalog.build_phase()?; + if phase.is_empty() { + catalog.set_build_phase("bootstrap", base_generation)?; + Ok(catalog) + } else if phase == "bootstrap" && base == base_generation { + Ok(catalog) + } else { + Err(CatalogError::InvalidInput(format!( + "building generation belongs to {phase:?} base {base}" + ))) + } + }) { + Ok(catalog) => catalog, + Err(error) if building_path.is_file() => { + // Only the exact incomplete derived sidecar is recoverable here. + // Authoritative databases and active generations are never targets. + fs::remove_file(&building_path)?; + remove_sqlite_sidecars(&building_path)?; + let mut catalog = Catalog::open(&building_path, generation).map_err(|retry| { + CatalogError::InvalidSource(format!( + "rebuild after rejected sidecar ({error}) also failed: {retry}" + )) + })?; + catalog.set_build_phase("bootstrap", base_generation)?; + catalog + } + Err(error) => return Err(error), + }; + Ok(( + BootstrapSession { + layout: self.clone(), + catalog, + generation, + building_path, + active_generation, + _lock: lock, + }, + preflight, + )) + } + + pub(crate) fn write_manifest(&self, manifest: &BootstrapManifest) -> Result<()> { + let path = self.manifest_path(); + let temporary = self.data_dir.join(format!("{MANIFEST_NAME}.tmp")); + let bytes = serde_json::to_vec_pretty(manifest)?; + { + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&temporary)?; + file.write_all(&bytes)?; + file.write_all(b"\n")?; + file.sync_all()?; + } + fs::rename(&temporary, &path)?; + sync_directory(&self.data_dir)?; + self.prune_obsolete_generations(manifest); + Ok(()) + } + + /// Retain only the active catalog and its immediate rollback generation. + /// + /// The manifest is the source of truth and is always published before this + /// maintenance runs. Cleanup is deliberately best-effort: Windows may + /// still have an older generation open briefly, and a failed unlink must + /// never make a successfully activated catalog look like a failed one. + /// A later startup or activation will retry any generation left behind. + pub fn prune_obsolete_generations( + &self, + manifest: &BootstrapManifest, + ) -> GenerationPruneReport { + let entries = match fs::read_dir(&self.data_dir) { + Ok(entries) => entries, + Err(error) => { + log::warn!( + "[local-catalog] generation cleanup could not scan {}: {error}", + self.data_dir.display() + ); + return GenerationPruneReport { + failed_generations: 1, + ..GenerationPruneReport::default() + }; + } + }; + let mut generations = BTreeSet::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + let generation = [".db", ".db-wal", ".db-shm"] + .into_iter() + .find_map(|suffix| parse_generation_name(&name, suffix)); + if let Some(generation) = generation { + generations.insert(generation); + } + } + // A schema rebuild may not be able to validate the previous manifest, + // leaving `previous_generation` empty even though the immediately + // preceding finalized database is still the safest rollback artifact. + let previous_generation = manifest.previous_generation.or_else(|| { + generations + .range(..manifest.active_generation) + .next_back() + .copied() + }); + let keep = [Some(manifest.active_generation), previous_generation] + .into_iter() + .flatten() + .collect::<BTreeSet<_>>(); + let stale = generations + .into_iter() + .filter(|generation| !keep.contains(generation)) + .collect::<Vec<_>>(); + + let mut report = GenerationPruneReport::default(); + for generation in stale { + let database = self.generation_path(generation); + let mut failed = false; + let mut removed_any = false; + let database_bytes = fs::metadata(&database) + .map(|metadata| metadata.len()) + .unwrap_or(0); + let database_gone = match fs::remove_file(&database) { + Ok(()) => { + removed_any = true; + report.reclaimed_bytes = report.reclaimed_bytes.saturating_add(database_bytes); + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + failed = true; + log::warn!( + "[local-catalog] generation cleanup could not remove {}: {error}", + database.display() + ); + false + } + }; + // If Windows still has the database open, leave its WAL/SHM pair + // untouched as well. The next maintenance pass retries the trio. + for path in database_gone + .then(|| { + [ + PathBuf::from(format!("{}-wal", database.display())), + PathBuf::from(format!("{}-shm", database.display())), + ] + }) + .into_iter() + .flatten() + { + let bytes = fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + match fs::remove_file(&path) { + Ok(()) => { + removed_any = true; + report.reclaimed_bytes = report.reclaimed_bytes.saturating_add(bytes); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + failed = true; + log::warn!( + "[local-catalog] generation cleanup could not remove {}: {error}", + path.display() + ); + } + } + } + if failed { + report.failed_generations += 1; + } else if removed_any { + report.removed_generations += 1; + } + } + if report.removed_generations > 0 || report.failed_generations > 0 { + log::info!( + "[local-catalog] generation cleanup: removed={} reclaimed_bytes={} failed={} retained_active={} retained_previous={:?}", + report.removed_generations, + report.reclaimed_bytes, + report.failed_generations, + manifest.active_generation, + previous_generation + ); + } + report + } + + pub(crate) fn next_building_generation(&self, active_generation: Option<u64>) -> Result<u64> { + let mut maximum_final = active_generation.unwrap_or(0); + let mut building = Vec::new(); + for entry in fs::read_dir(&self.data_dir)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if let Some(generation) = parse_generation_name(&name, ".db") { + maximum_final = maximum_final.max(generation); + } else if let Some(generation) = parse_generation_name(&name, ".db.building") { + building.push(generation); + } + } + Ok(building + .into_iter() + .filter(|generation| *generation > maximum_final) + .min() + .unwrap_or_else(|| maximum_final.saturating_add(1).max(1))) + } +} + +pub struct BootstrapSession { + layout: BootstrapLayout, + catalog: Catalog, + generation: u64, + building_path: PathBuf, + active_generation: Option<u64>, + _lock: File, +} + +impl BootstrapSession { + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn building_path(&self) -> &Path { + &self.building_path + } + + pub fn checkpoint(&self, source: &SourceKey) -> Result<Option<SourceCheckpoint>> { + self.catalog.source_checkpoint(source) + } + + pub fn restart_changed_source( + &mut self, + source: &SourceKey, + snapshot_version: &str, + ) -> Result<()> { + self.catalog + .restart_projection_source(source, snapshot_version) + } + + pub fn apply_batch(&mut self, batch: &BootstrapBatch) -> Result<SourceCheckpoint> { + self.catalog.apply_bootstrap_batch(batch) + } + + pub fn stats(&self) -> Result<crate::CatalogStats> { + self.catalog.stats() + } + + pub fn activate(mut self, expected: &[SourceProbe]) -> Result<BootstrapManifest> { + self.catalog.rebuild_materialized_views()?; + if !self.catalog.materialized_views_valid()? { + return Err(CatalogError::ActivationNotReady( + "album/artist materializations do not match projected tracks".to_string(), + )); + } + let expected_counts = expected + .iter() + .filter(|probe| probe.row_count > 0) + .map(|probe| (probe.source.clone(), probe.row_count)) + .collect::<BTreeMap<_, _>>(); + let actual_stats = self.catalog.stats()?; + let actual_counts = actual_stats + .source_counts + .iter() + .cloned() + .collect::<BTreeMap<_, _>>(); + if expected_counts != actual_counts { + return Err(CatalogError::ActivationNotReady(format!( + "source counts differ: expected {expected_counts:?}, actual {actual_counts:?}" + ))); + } + for probe in expected { + let checkpoint = self + .catalog + .source_checkpoint(&probe.source)? + .ok_or_else(|| { + CatalogError::ActivationNotReady(format!( + "missing checkpoint for {:?}", + probe.source + )) + })?; + if !checkpoint.complete + || checkpoint.checkpoint_version != probe.snapshot_version + || checkpoint.checkpoint_rows != probe.row_count + { + return Err(CatalogError::ActivationNotReady(format!( + "incomplete checkpoint for {:?}: {checkpoint:?}", + probe.source + ))); + } + } + let integrity = self.catalog.integrity_check()?; + if !integrity.sqlite_ok || integrity.foreign_key_violations != 0 || !integrity.fts_ok { + return Err(CatalogError::ActivationNotReady(format!( + "integrity failed: {integrity:?}" + ))); + } + self.catalog.checkpoint_for_activation()?; + let generation = self.generation; + let final_path = self.layout.generation_path(generation); + drop(self.catalog); + fs::rename(&self.building_path, &final_path)?; + sync_directory(self.layout.data_dir())?; + let manifest = BootstrapManifest { + manifest_version: 1, + schema_version: SCHEMA_VERSION, + active_generation: generation, + previous_generation: self.active_generation, + activated_at_unix_ms: now_unix_ms(), + }; + self.layout.write_manifest(&manifest)?; + Ok(manifest) + } +} + +pub(crate) fn acquire_lock(path: &Path) -> Result<File> { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(path)?; + #[cfg(unix)] + { + use rustix::fs::{flock, FlockOperation}; + flock(&file, FlockOperation::NonBlockingLockExclusive).map_err(|error| { + if error == rustix::io::Errno::WOULDBLOCK { + CatalogError::BootstrapBusy + } else { + CatalogError::Io(std::io::Error::from_raw_os_error(error.raw_os_error())) + } + })?; + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION; + use windows_sys::Win32::Storage::FileSystem::{ + LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, + }; + + let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = + unsafe { std::mem::zeroed() }; + // SAFETY: `file` is open, so its handle is valid for the call; the + // zeroed OVERLAPPED lives for the whole call; locking 0..u64::MAX is + // the documented "lock the entire file" idiom. The lock is released + // when `file` drops and the handle closes, matching flock(). + let ok = unsafe { + LockFileEx( + file.as_raw_handle() as _, + LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, + 0, + u32::MAX, + u32::MAX, + &mut overlapped, + ) + }; + if ok == 0 { + let error = std::io::Error::last_os_error(); + return Err( + if error.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) { + CatalogError::BootstrapBusy + } else { + CatalogError::Io(error) + }, + ); + } + } + Ok(file) +} + +pub(crate) fn remove_sqlite_sidecars(database: &Path) -> Result<()> { + for suffix in ["-wal", "-shm"] { + let path = PathBuf::from(format!("{}{suffix}", database.display())); + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +pub(crate) fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0) +} + +fn nearest_existing_ancestor(path: &Path) -> Option<&Path> { + path.ancestors().find(|candidate| candidate.exists()) +} + +fn parse_generation_name(name: &str, suffix: &str) -> Option<u64> { + name.strip_prefix("local_catalog-v1-g")? + .strip_suffix(suffix)? + .parse() + .ok() +} + +#[cfg(unix)] +fn filesystem_available_bytes(path: &Path) -> Result<u64> { + let status = rustix::fs::statvfs(path) + .map_err(|error| std::io::Error::from_raw_os_error(error.raw_os_error()))?; + Ok(status.f_bavail.saturating_mul(status.f_frsize)) +} + +#[cfg(windows)] +fn filesystem_available_bytes(path: &Path) -> Result<u64> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + + // GetDiskFreeSpaceExW wants a path that exists. `available_bytes()` already + // hands us the nearest existing ancestor, but keep the walk local so a + // future caller cannot turn this into ERROR_PATH_NOT_FOUND on first run. + let probe = nearest_existing_ancestor(path).unwrap_or(path); + let wide: Vec<u16> = probe + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut free_to_caller: u64 = 0; + // SAFETY: `wide` is NUL-terminated and outlives the call; `free_to_caller` + // is a valid u64 slot; the remaining two out-params are documented as + // optional and are passed as null. + let ok = unsafe { + GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut free_to_caller, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(CatalogError::Io(std::io::Error::last_os_error())); + } + Ok(free_to_caller) +} + +#[cfg(not(any(unix, windows)))] +fn filesystem_available_bytes(_path: &Path) -> Result<u64> { + Err(CatalogError::InvalidInput( + "filesystem free-space preflight is unsupported on this platform".to_string(), + )) +} + +#[cfg(unix)] +pub(crate) fn sync_directory(path: &Path) -> Result<()> { + File::open(path)?.sync_all()?; + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn sync_directory(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/crates/qbz-local-catalog/src/bootstrap_tests.rs b/crates/qbz-local-catalog/src/bootstrap_tests.rs new file mode 100644 index 000000000..8386d19f4 --- /dev/null +++ b/crates/qbz-local-catalog/src/bootstrap_tests.rs @@ -0,0 +1,826 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::time::Instant; + +use rusqlite::Connection; +use tempfile::tempdir; + +use crate::{ + bootstrap_legacy_caches, bootstrap_legacy_caches_at_with_progress, ActiveCatalog, + BootstrapBatch, BootstrapLayout, BootstrapManifest, BootstrapOutcome, Catalog, CatalogError, + FallbackReason, LegacyLocations, PreflightReport, ProjectedTrack, ProjectionOutcome, + QueryDescriptor, SourceKey, SourceKind, SourceProbe, TrackRef, BOOTSTRAP_BATCH_ROWS, + SCHEMA_VERSION, +}; + +fn source(kind: SourceKind, instance: &str) -> SourceKey { + SourceKey { + source: kind, + source_instance: instance.to_string(), + } +} + +fn probe(path: &std::path::Path, source: &SourceKey, version: &str, rows: u64) -> SourceProbe { + SourceProbe { + source: source.clone(), + source_path: path.to_path_buf(), + snapshot_version: version.to_string(), + row_count: rows, + page_bytes: rows.saturating_mul(512), + integrity_ok: true, + } +} + +fn track(source: &SourceKey, id: u64) -> ProjectedTrack { + ProjectedTrack { + track_ref: TrackRef { + source: source.source, + source_instance: source.source_instance.clone(), + native_id: id.to_string(), + }, + source_raw: source.source.as_str().to_string(), + local_track_id: (source.source == SourceKind::Local).then_some(id as i64), + local_path: (source.source == SourceKind::Local).then(|| format!("/music/{id}.flac")), + native_album_id: Some(format!("album-{}", id / 20)), + source_copy_id: None, + title: format!("Track {id:06}"), + artist: format!("Artist {}", id % 31), + album_artist: format!("Artist {}", id % 31), + album: format!("Album {}", id % 97), + duration_ms: 180_000 + id, + year: Some(1980 + (id % 44) as u32), + disc_number: Some(1), + track_number: Some((id % 20 + 1) as u32), + format: "flac".to_string(), + bit_depth: Some(24), + sample_rate_hz: Some(96_000), + artwork_token: Some(format!("art-{id}")), + isrc: None, + musicbrainz_recording_id: None, + added_at: id as i64, + available: true, + observed_generation: 0, + credits: Vec::new(), + } +} + +fn apply_range( + session: &mut crate::BootstrapSession, + source: &SourceKey, + version: &str, + start: u64, + end: u64, + total: u64, +) { + let mut cursor = if start == 0 { + String::new() + } else { + start.to_string() + }; + let mut next = start; + while next < end { + let batch_end = (next + BOOTSTRAP_BATCH_ROWS as u64).min(end); + let rows = (next..batch_end) + .map(|id| track(source, id + 1)) + .collect::<Vec<_>>(); + let complete = batch_end == total; + let saved = session + .apply_batch(&BootstrapBatch { + source: source.clone(), + snapshot_version: version.to_string(), + expected_cursor: cursor, + next_cursor: batch_end.to_string(), + tracks: rows, + complete, + }) + .unwrap(); + cursor = saved.checkpoint_cursor; + next = batch_end; + } +} + +#[test] +fn preflight_enforces_margin_before_creating_a_sidecar() { + let temp = tempdir().unwrap(); + let data_dir = temp.path().join("catalog-data"); + let source_path = temp.path().join("library.db"); + fs::write(&source_path, b"authoritative-fixture").unwrap(); + let before = fs::read(&source_path).unwrap(); + let key = source(SourceKind::Local, "library"); + let probe = probe(&source_path, &key, "v1", 10_000); + let layout = BootstrapLayout::new(&data_dir); + + let error = match layout.prepare(&[probe.clone()], Some(0)) { + Ok(_) => panic!("zero free bytes must fail preflight"), + Err(error) => error, + }; + assert!(matches!(error, CatalogError::InsufficientSpace { .. })); + assert!(!data_dir.exists()); + assert_eq!(fs::read(&source_path).unwrap(), before); + + let report = PreflightReport::evaluate(&[probe], u64::MAX).unwrap(); + assert_eq!(report.estimated_catalog_bytes, 12_800_000); + assert_eq!( + report.required_available_bytes, + 12_800_000 + 3_200_000 + 256 * 1024 * 1024 + ); +} + +#[test] +fn page_bytes_are_counted_once_for_multiple_sources_in_one_cache() { + let path = std::path::Path::new("remote_cache.db"); + let probes = [ + SourceProbe { + source: source(SourceKind::Jellyfin, "server"), + source_path: path.to_path_buf(), + snapshot_version: "a".to_string(), + row_count: 1, + page_bytes: 9_000, + integrity_ok: true, + }, + SourceProbe { + source: source(SourceKind::Subsonic, "server"), + source_path: path.to_path_buf(), + snapshot_version: "b".to_string(), + row_count: 1, + page_bytes: 9_000, + integrity_ok: true, + }, + ]; + let report = PreflightReport::evaluate(&probes, u64::MAX).unwrap(); + assert_eq!(report.source_page_bytes, 9_000); +} + +#[test] +fn building_file_is_never_active_and_previous_generation_survives() { + let temp = tempdir().unwrap(); + let layout = BootstrapLayout::new(temp.path()); + let key = source(SourceKind::Local, "library"); + let first_probe = probe(&temp.path().join("library.db"), &key, "v1", 2); + let (mut first, _) = layout + .prepare(&[first_probe.clone()], Some(u64::MAX)) + .unwrap(); + apply_range(&mut first, &key, "v1", 0, 2, 2); + let manifest = first.activate(&[first_probe]).unwrap(); + assert_eq!(manifest.active_generation, 1); + + let next_probe = probe(&temp.path().join("library.db"), &key, "v2", 3); + let (mut second, _) = layout + .prepare(&[next_probe.clone()], Some(u64::MAX)) + .unwrap(); + apply_range(&mut second, &key, "v2", 0, 1, 3); + assert!(second.building_path().is_file()); + match layout.open_active() { + ActiveCatalog::Ready { catalog, manifest } => { + assert_eq!(manifest.active_generation, 1); + assert_eq!(catalog.stats().unwrap().track_count, 2); + } + ActiveCatalog::Fallback(reason) => panic!("unexpected fallback: {reason:?}"), + } + drop(second); + assert_eq!(layout.read_manifest().unwrap().active_generation, 1); +} + +#[test] +fn activation_prunes_generations_older_than_the_rollback_generation() { + let temp = tempdir().unwrap(); + let layout = BootstrapLayout::new(temp.path()); + let key = source(SourceKind::Local, "library"); + + for generation in 1..=3 { + let snapshot = format!("v{generation}"); + let current_probe = probe(&temp.path().join("library.db"), &key, &snapshot, generation); + let (mut session, _) = layout + .prepare(&[current_probe.clone()], Some(u64::MAX)) + .unwrap(); + apply_range(&mut session, &key, &snapshot, 0, generation, generation); + session.activate(&[current_probe]).unwrap(); + + if generation == 2 { + fs::write( + PathBuf::from(format!("{}-wal", layout.generation_path(1).display())), + b"stale wal", + ) + .unwrap(); + fs::write( + PathBuf::from(format!("{}-shm", layout.generation_path(1).display())), + b"stale shm", + ) + .unwrap(); + fs::write( + temp.path().join("local_catalog-v1-g1.db.backup"), + b"keep me", + ) + .unwrap(); + } + } + + let manifest = layout.read_manifest().unwrap(); + assert_eq!(manifest.active_generation, 3); + assert_eq!(manifest.previous_generation, Some(2)); + assert!(!layout.generation_path(1).exists()); + assert!(!PathBuf::from(format!("{}-wal", layout.generation_path(1).display())).exists()); + assert!(!PathBuf::from(format!("{}-shm", layout.generation_path(1).display())).exists()); + assert!(layout.generation_path(2).is_file()); + assert!(layout.generation_path(3).is_file()); + assert!(temp.path().join("local_catalog-v1-g1.db.backup").is_file()); +} + +#[test] +fn interrupted_bootstrap_resumes_at_ten_fifty_and_ninety_nine_percent() { + const TOTAL: u64 = 1_001; + for stopped in [100_u64, 500, 990] { + let temp = tempdir().unwrap(); + let layout = BootstrapLayout::new(temp.path()); + let key = source(SourceKind::Plex, "server-a"); + let probe = probe(&temp.path().join("plex_cache.db"), &key, "stable", TOTAL); + let (mut interrupted, _) = layout.prepare(&[probe.clone()], Some(u64::MAX)).unwrap(); + apply_range(&mut interrupted, &key, "stable", 0, stopped, TOTAL); + drop(interrupted); + + let (mut resumed, _) = layout.prepare(&[probe.clone()], Some(u64::MAX)).unwrap(); + let checkpoint = resumed.checkpoint(&key).unwrap().unwrap(); + assert_eq!(checkpoint.checkpoint_rows, stopped); + assert_eq!(checkpoint.checkpoint_cursor, stopped.to_string()); + assert!(!checkpoint.complete); + apply_range(&mut resumed, &key, "stable", stopped, TOTAL, TOTAL); + resumed.activate(&[probe]).unwrap(); + + let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else { + panic!("resumed catalog was not activated") + }; + let page = catalog + .query_tracks(&QueryDescriptor::tracks(), None, 500) + .unwrap(); + assert_eq!(catalog.stats().unwrap().track_count, TOTAL); + assert_eq!(page.rows.len(), 500); + let distinct = page + .rows + .iter() + .map(|row| row.track_ref.clone()) + .collect::<HashSet<_>>(); + assert_eq!(distinct.len(), 500); + } +} + +#[test] +fn snapshot_change_restarts_only_the_derived_source() { + let temp = tempdir().unwrap(); + let layout = BootstrapLayout::new(temp.path()); + let key = source(SourceKind::Jellyfin, "jelly"); + let v1 = probe(&temp.path().join("remote_cache.db"), &key, "v1", 4); + let (mut session, _) = layout.prepare(&[v1], Some(u64::MAX)).unwrap(); + apply_range(&mut session, &key, "v1", 0, 2, 4); + assert!(matches!( + session.apply_batch(&BootstrapBatch { + source: key.clone(), + snapshot_version: "v2".to_string(), + expected_cursor: "2".to_string(), + next_cursor: "3".to_string(), + tracks: vec![track(&key, 3)], + complete: false, + }), + Err(CatalogError::SourceSnapshotChanged(_)) + )); + session.restart_changed_source(&key, "v2").unwrap(); + assert_eq!(session.stats().unwrap().track_count, 0); + let checkpoint = session.checkpoint(&key).unwrap().unwrap(); + assert_eq!(checkpoint.checkpoint_rows, 0); + assert_eq!(checkpoint.checkpoint_version, "v2"); +} + +#[test] +fn corrupt_building_is_rebuilt_but_unrelated_files_are_preserved() { + let temp = tempdir().unwrap(); + let layout = BootstrapLayout::new(temp.path()); + let source_path = temp.path().join("library.db"); + fs::write(&source_path, b"do-not-touch").unwrap(); + fs::write(layout.building_path(1), b"not sqlite").unwrap(); + let key = source(SourceKind::Local, "library"); + let probe = probe(&source_path, &key, "v1", 0); + let (mut session, _) = layout.prepare(&[probe.clone()], Some(u64::MAX)).unwrap(); + session + .apply_batch(&BootstrapBatch { + source: key, + snapshot_version: "v1".to_string(), + expected_cursor: String::new(), + next_cursor: String::new(), + tracks: Vec::new(), + complete: true, + }) + .unwrap(); + session.activate(&[probe]).unwrap(); + assert_eq!(fs::read(source_path).unwrap(), b"do-not-touch"); +} + +#[test] +fn corrupt_active_catalog_falls_back_without_promoting_a_building_file() { + let temp = tempdir().unwrap(); + let layout = BootstrapLayout::new(temp.path()); + let key = source(SourceKind::Local, "library"); + let probe = probe(&temp.path().join("library.db"), &key, "v1", 1); + let (mut session, _) = layout.prepare(&[probe.clone()], Some(u64::MAX)).unwrap(); + apply_range(&mut session, &key, "v1", 0, 1, 1); + session.activate(&[probe]).unwrap(); + fs::write(layout.generation_path(1), b"corrupt derived data").unwrap(); + Catalog::open(&layout.building_path(2), 2).unwrap(); + + assert!(matches!( + layout.open_active(), + ActiveCatalog::Fallback(FallbackReason::CatalogRejected(_)) + )); +} + +#[test] +fn legacy_fixture_bootstraps_all_sources_read_only_and_is_idempotent() { + let temp = tempdir().unwrap(); + create_local_fixture(&temp.path().join("library.db")); + create_plex_fixture(&temp.path().join("plex_cache.db")); + create_remote_fixture(&temp.path().join("remote_cache.db")); + let source_sizes = ["library.db", "plex_cache.db", "remote_cache.db"].map(|name| { + let path = temp.path().join(name); + (path.clone(), fs::metadata(path).unwrap().len()) + }); + + let started = Instant::now(); + let outcome = bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(); + let elapsed = started.elapsed(); + assert!(matches!( + outcome, + BootstrapOutcome::Activated { + generation: 1, + track_count: 505, + .. + } + )); + let ActiveCatalog::Ready { catalog, .. } = BootstrapLayout::new(temp.path()).open_active() + else { + panic!("legacy fixture did not activate") + }; + let stats = catalog.stats().unwrap(); + let counts = stats.source_counts.into_iter().collect::<HashMap<_, _>>(); + assert_eq!(counts[&source(SourceKind::Local, "library")], 2); + assert_eq!(counts[&source(SourceKind::Plex, "plex-server")], 501); + assert_eq!(counts[&source(SourceKind::Jellyfin, "jf-server")], 1); + assert_eq!(counts[&source(SourceKind::Subsonic, "sub-server")], 1); + let aggregate_counts: (i64, i64, i64) = catalog + .connection() + .query_row( + "SELECT + (SELECT COUNT(*) FROM albums_materialized), + (SELECT COALESCE(SUM(track_count),0) FROM albums_materialized), + (SELECT COUNT(*) FROM artists_materialized)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(aggregate_counts.1, 505); + assert!(aggregate_counts.0 >= 4); + assert!(aggregate_counts.2 >= 5); + let catalog_bytes = fs::metadata(BootstrapLayout::new(temp.path()).generation_path(1)) + .unwrap() + .len(); + println!( + "C_BOOTSTRAP_METRIC rows=505 elapsed_ms={:.3} catalog_bytes={catalog_bytes} albums={} artists={}", + elapsed.as_secs_f64() * 1_000.0, + aggregate_counts.0, + aggregate_counts.2 + ); + for (path, size) in source_sizes { + assert_eq!(fs::metadata(path).unwrap().len(), size); + } + + let second = bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(); + assert!(matches!( + second, + BootstrapOutcome::Activated { + generation: 1, + track_count: 505, + resumed_rows: 0, + } + )); + assert!(!BootstrapLayout::new(temp.path()).building_path(2).exists()); +} + +#[test] +fn plex_album_metadata_art_fills_missing_track_art_and_reconciles() { + let temp = tempdir().unwrap(); + let path = temp.path().join("plex_cache.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE plex_cache_tracks ( + rating_key TEXT PRIMARY KEY, server_id TEXT, title TEXT NOT NULL, + artist TEXT, album TEXT, duration_ms INTEGER, container TEXT, + bit_depth INTEGER, sampling_rate_hz INTEGER, artwork_path TEXT, + collection_artwork_path TEXT, updated_at INTEGER, year INTEGER, + disc_number INTEGER, track_number INTEGER, parent_rating_key TEXT + ); + CREATE TABLE plex_cache_album_artwork ( + server_id TEXT NOT NULL, parent_rating_key TEXT NOT NULL, + artwork_path TEXT, checked_at INTEGER NOT NULL, + PRIMARY KEY(server_id,parent_rating_key) + ); + INSERT INTO plex_cache_tracks VALUES + ('track-1','plex-server','Song','Rammstein','1994',180000,'flac', + 16,44100,NULL,NULL,10,2002,1,1,'60335'); + INSERT INTO plex_cache_album_artwork VALUES + ('plex-server','60335','/library/metadata/60320/thumb/1',20);", + ) + .unwrap(); + drop(conn); + + assert!(matches!( + bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(), + BootstrapOutcome::Activated { track_count: 1, .. } + )); + let track_ref = TrackRef { + source: SourceKind::Plex, + source_instance: "plex-server".to_string(), + native_id: "track-1".to_string(), + }; + let layout = BootstrapLayout::new(temp.path()); + let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else { + panic!("Plex artwork fixture did not activate") + }; + assert_eq!( + catalog + .resolve(&track_ref) + .unwrap() + .unwrap() + .artwork_token + .as_deref(), + Some("/library/metadata/60320/thumb/1") + ); + drop(catalog); + + Connection::open(&path) + .unwrap() + .execute( + "UPDATE plex_cache_album_artwork + SET artwork_path='/library/metadata/60320/thumb/2',checked_at=21 + WHERE server_id='plex-server' AND parent_rating_key='60335'", + [], + ) + .unwrap(); + assert!(matches!( + crate::reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(), + ProjectionOutcome::Activated { + changed_sources: 1, + .. + } + )); + let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else { + panic!("Plex artwork refresh did not activate") + }; + assert_eq!( + catalog + .resolve(&track_ref) + .unwrap() + .unwrap() + .artwork_token + .as_deref(), + Some("/library/metadata/60320/thumb/2") + ); +} + +#[test] +fn one_source_bootstrap_reports_identical_source_and_overall_progress() { + let temp = tempdir().unwrap(); + create_local_fixture(&temp.path().join("library.db")); + let mut progress = Vec::new(); + + let outcome = bootstrap_legacy_caches_at_with_progress( + &LegacyLocations::co_located(temp.path()), + &AtomicBool::new(false), + |event| progress.push(event.clone()), + ) + .unwrap(); + + assert!(matches!(outcome, BootstrapOutcome::Activated { .. })); + assert!(!progress.is_empty()); + assert!(progress.iter().all(|event| { + event.source_index == 1 + && event.source_count == 1 + && event.source_rows_total == event.overall_rows_total + && event.committed_rows == event.overall_rows_written + })); + let final_event = progress.last().unwrap(); + assert!(final_event.source_complete); + assert_eq!(final_event.source_rows_total, 2); + assert_eq!(final_event.overall_rows_total, 2); +} + +/// Explicit recovery-cost gate using the production Rust legacy reader, +/// projector, materialized views, FTS activation check and side-by-side rename. +/// Ordinary unit runs skip it because the 200k case intentionally creates a +/// large temporary catalog and measures wall time rather than correctness only. +#[test] +#[ignore = "explicit 20k/200k end-to-end legacy recovery metric"] +fn legacy_recovery_cost_uses_the_real_projector_at_scale() { + for rows in [20_000_u64, 200_000] { + let temp = tempdir().unwrap(); + let source_path = temp.path().join("plex_cache.db"); + create_plex_scale_fixture(&source_path, rows); + let source_bytes = fs::metadata(&source_path).unwrap().len(); + let source_before = fs::read(&source_path).unwrap(); + + let started = Instant::now(); + let outcome = bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(); + let elapsed = started.elapsed(); + assert!(matches!( + outcome, + BootstrapOutcome::Activated { + generation: 1, + track_count, + .. + } if track_count == rows + )); + let layout = BootstrapLayout::new(temp.path()); + let catalog_bytes = fs::metadata(layout.generation_path(1)).unwrap().len(); + let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else { + panic!("scaled recovery fixture did not activate") + }; + let runtime = catalog.runtime_integrity_check().unwrap(); + assert!(runtime.sqlite_ok && runtime.materialized_views_ok); + assert_eq!(runtime.foreign_key_violations, 0); + assert_eq!(fs::read(&source_path).unwrap(), source_before); + + let source = Connection::open(&source_path).unwrap(); + source + .execute( + "UPDATE plex_cache_tracks + SET title='Changed Track', updated_at=?1 + WHERE rating_key='1'", + [rows as i64 + 1], + ) + .unwrap(); + drop(source); + let changed_source = fs::read(&source_path).unwrap(); + let reconcile_started = Instant::now(); + let projection = + crate::reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(); + let reconcile_elapsed = reconcile_started.elapsed(); + assert!(matches!( + projection, + ProjectionOutcome::Activated { + generation: 2, + track_count, + changed_sources: 1, + .. + } if track_count == rows + )); + assert_eq!(fs::read(&source_path).unwrap(), changed_source); + println!( + "RECOVERY_METRIC rows={rows} bootstrap_ms={:.3} reconcile_ms={:.3} source_bytes={source_bytes} catalog_bytes={catalog_bytes}", + elapsed.as_secs_f64() * 1_000.0, + reconcile_elapsed.as_secs_f64() * 1_000.0, + ); + } +} + +#[test] +fn obsolete_catalog_schema_rebuilds_side_by_side_without_touching_sources() { + let temp = tempdir().unwrap(); + let source_path = temp.path().join("library.db"); + create_local_fixture(&source_path); + let source_bytes = fs::metadata(&source_path).unwrap().len(); + let layout = BootstrapLayout::new(temp.path()); + let obsolete_generation = 4; + let obsolete_bytes = b"obsolete derived catalog"; + fs::write(layout.generation_path(obsolete_generation), obsolete_bytes).unwrap(); + layout + .write_manifest(&BootstrapManifest { + manifest_version: 1, + schema_version: SCHEMA_VERSION - 1, + active_generation: obsolete_generation, + previous_generation: None, + activated_at_unix_ms: 1, + }) + .unwrap(); + + let outcome = bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(); + assert!(matches!( + outcome, + BootstrapOutcome::Activated { + generation: 5, + track_count: 2, + .. + } + )); + assert_eq!(fs::read(layout.generation_path(4)).unwrap(), obsolete_bytes); + assert_eq!(fs::metadata(source_path).unwrap().len(), source_bytes); + let ActiveCatalog::Ready { catalog, manifest } = layout.open_active() else { + panic!("rebuilt catalog did not activate") + }; + assert_eq!(manifest.schema_version, SCHEMA_VERSION); + assert_eq!(catalog.stats().unwrap().track_count, 2); +} + +#[test] +fn split_profile_and_global_cache_locations_build_one_user_catalog() { + let temp = tempdir().unwrap(); + let root = temp.path().join("qbz"); + let user = root.join("users/42"); + fs::create_dir_all(&user).unwrap(); + create_local_fixture(&user.join("library.db")); + create_plex_fixture(&root.join("plex_cache.db")); + create_remote_fixture(&user.join("remote_cache.db")); + let locations = LegacyLocations { + catalog_dir: user.clone(), + local_database: user.join("library.db"), + plex_database: root.join("plex_cache.db"), + remote_database: user.join("remote_cache.db"), + enabled_sources: SourceKind::ALL.into_iter().collect(), + }; + + let outcome = + bootstrap_legacy_caches_at_with_progress(&locations, &AtomicBool::new(false), |_| {}) + .unwrap(); + assert!(matches!( + outcome, + BootstrapOutcome::Activated { + track_count: 505, + .. + } + )); + let ActiveCatalog::Ready { catalog, .. } = BootstrapLayout::new(&user).open_active() else { + panic!("profile catalog did not activate") + }; + assert_eq!(catalog.stats().unwrap().track_count, 505); + assert!(!root.join("local_catalog-v1-manifest.json").exists()); +} + +#[test] +fn disabled_source_is_not_opened_and_is_pruned_from_an_existing_catalog() { + let temp = tempdir().unwrap(); + let root = temp.path().join("qbz"); + let user = root.join("users/42"); + fs::create_dir_all(&user).unwrap(); + create_local_fixture(&user.join("library.db")); + create_plex_fixture(&root.join("plex_cache.db")); + create_remote_fixture(&user.join("remote_cache.db")); + let mut locations = LegacyLocations { + catalog_dir: user.clone(), + local_database: user.join("library.db"), + plex_database: root.join("plex_cache.db"), + remote_database: user.join("remote_cache.db"), + enabled_sources: SourceKind::ALL.into_iter().collect(), + }; + bootstrap_legacy_caches_at_with_progress(&locations, &AtomicBool::new(false), |_| {}) + .unwrap(); + + locations.enabled_sources.remove(&SourceKind::Plex); + let discovered = crate::discover_legacy_sources_at(&locations).unwrap(); + assert!(discovered + .iter() + .all(|source| source.source.source != SourceKind::Plex)); + assert!(matches!( + crate::reconcile_legacy_caches_at_with_progress( + &locations, + &AtomicBool::new(false), + |_| {} + ) + .unwrap(), + ProjectionOutcome::Activated { + track_count: 4, + changed_sources: 1, + .. + } + )); + let ActiveCatalog::Ready { catalog, .. } = BootstrapLayout::new(&user).open_active() else { + panic!("profile catalog did not activate") + }; + assert!(catalog + .stats() + .unwrap() + .source_counts + .iter() + .all(|(source, _)| source.source != SourceKind::Plex)); + drop(catalog); + + locations.enabled_sources.insert(SourceKind::Plex); + assert!(matches!( + crate::reconcile_legacy_caches_at_with_progress( + &locations, + &AtomicBool::new(false), + |_| {} + ) + .unwrap(), + ProjectionOutcome::Activated { + track_count: 505, + changed_sources: 1, + .. + } + )); +} + +fn create_local_fixture(path: &std::path::Path) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE local_tracks ( + id INTEGER PRIMARY KEY, file_path TEXT NOT NULL, title TEXT NOT NULL, + artist TEXT NOT NULL, album_artist TEXT, album TEXT NOT NULL, + duration_secs INTEGER, year INTEGER, disc_number INTEGER, + track_number INTEGER, format TEXT, bit_depth INTEGER, + sample_rate REAL, artwork_path TEXT, indexed_at INTEGER + ); + INSERT INTO local_tracks VALUES + (1,'/music/a.flac','Local A','Ártist','Ártist','Local Album',180,2020,1,1,'flac',24,96000,'/art/a',10), + (2,'/music/b.mp3','Local B','Artist','Artist','Local Album',200,2020,1,2,'mp3',NULL,44100,NULL,11);", + ) + .unwrap(); +} + +fn create_plex_fixture(path: &std::path::Path) { + let mut conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE plex_cache_tracks ( + rating_key TEXT PRIMARY KEY, server_id TEXT, title TEXT NOT NULL, + artist TEXT, album TEXT, duration_ms INTEGER, codec TEXT, + container TEXT, bit_depth INTEGER, sampling_rate_hz INTEGER, + artwork_path TEXT, updated_at INTEGER, year INTEGER, + disc_number INTEGER, track_number INTEGER + );", + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + { + let mut insert = tx + .prepare( + "INSERT INTO plex_cache_tracks VALUES + (?1,'plex-server',?2,'Plex Artist','Plex Album',210000,'flac','flac',24,96000,'plex-art',20,2021,1,?3)", + ) + .unwrap(); + for id in 1..=501 { + insert + .execute(rusqlite::params![ + id.to_string(), + format!("Plex {id:04}"), + id + ]) + .unwrap(); + } + } + tx.commit().unwrap(); +} + +fn create_plex_scale_fixture(path: &std::path::Path, rows: u64) { + let mut conn = Connection::open(path).unwrap(); + conn.execute_batch( + "PRAGMA journal_mode=OFF; + PRAGMA synchronous=OFF; + CREATE TABLE plex_cache_tracks ( + rating_key TEXT PRIMARY KEY, server_id TEXT, title TEXT NOT NULL, + artist TEXT, album TEXT, duration_ms INTEGER, codec TEXT, + container TEXT, bit_depth INTEGER, sampling_rate_hz INTEGER, + artwork_path TEXT, updated_at INTEGER, year INTEGER, + disc_number INTEGER, track_number INTEGER + );", + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + { + let mut insert = tx + .prepare( + "INSERT INTO plex_cache_tracks VALUES + (?1,'scale-server',?2,?3,?4,210000,'flac','flac',24,96000,?5,?6,?7,?8,?9)", + ) + .unwrap(); + for id in 1..=rows { + insert + .execute(rusqlite::params![ + id.to_string(), + format!("Track {id:06}"), + format!("Artist {:05}", id % 10_000), + format!("Album {:05}", id / 12), + format!("art-{:05}", id / 12), + id as i64, + 1980 + (id % 44) as i64, + 1_i64, + (id % 12 + 1) as i64, + ]) + .unwrap(); + } + } + tx.commit().unwrap(); +} + +fn create_remote_fixture(path: &std::path::Path) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE remote_cache_tracks ( + id INTEGER PRIMARY KEY, source TEXT NOT NULL, item_id TEXT NOT NULL, + server_id TEXT, title TEXT, artist TEXT, album_artist TEXT, + album TEXT, duration_ms INTEGER, codec TEXT, container TEXT, + bit_depth INTEGER, sample_rate_hz INTEGER, artwork_token TEXT, + updated_at INTEGER, year INTEGER, disc_number INTEGER, + track_number INTEGER + ); + INSERT INTO remote_cache_tracks VALUES + (1,'jellyfin','same','jf-server','JF Track','JF Artist','JF Album Artist','JF Album',220000,'flac','flac',24,96000,'jf-art',30,2022,1,1), + (2,'subsonic','same','sub-server','Sub Track','Sub Artist','Sub Album Artist','Sub Album',230000,'mp3','mp3',NULL,44100,'sub-art',31,2023,1,1);", + ) + .unwrap(); +} diff --git a/crates/qbz-local-catalog/src/catalog.rs b/crates/qbz-local-catalog/src/catalog.rs new file mode 100644 index 000000000..eb7a5b659 --- /dev/null +++ b/crates/qbz-local-catalog/src/catalog.rs @@ -0,0 +1,2743 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use rusqlite::types::Value; +use rusqlite::{ + params, params_from_iter, Connection, OpenFlags, OptionalExtension, Row, + Statement, +}; + +use crate::bootstrap::{BootstrapBatch, SourceCheckpoint, BOOTSTRAP_BATCH_ROWS}; +use crate::model::{ + AlbumCursor, AlbumPage, AlbumRecord, ArtistCursor, ArtistPage, ArtistRecord, ProjectedTrack, + QueryDescriptor, QuerySurface, SourceKey, SourceKind, TrackCursor, TrackGroup, TrackPage, + TrackRecord, TrackRef, TrackSort, +}; +use crate::projection::ReconciliationBatch; +use crate::schema; +use crate::{CatalogError, Result, SCHEMA_VERSION}; + +const MAX_PAGE_SIZE: usize = 500; +const ALBUM_ARTIST_FAMILY_SEPARATOR: &str = " • "; + +#[derive(Default)] +struct AlbumArtistFamilyEvidence { + display_counts: HashMap<String, u64>, + suffixes: HashSet<String>, +} + +pub struct Catalog { + conn: Connection, + generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogStats { + pub schema_version: u32, + pub generation: u64, + pub track_count: u64, + pub source_counts: Vec<(SourceKey, u64)>, + pub page_size_bytes: u64, + pub page_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntegrityReport { + pub sqlite_ok: bool, + pub foreign_key_violations: u64, + pub fts_ok: bool, +} + +/// Checks that are safe against the read-only active generation. Full FTS5 +/// integrity is verified before activation; SQLite exposes that check through +/// a virtual-table write command, so diagnostics must not run it against the +/// live catalog merely to display health. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeIntegrityReport { + pub sqlite_ok: bool, + pub sqlite_result: String, + pub foreign_key_violations: u64, + pub materialized_views_ok: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QueryMetrics { + pub sql_time: Duration, + pub rows: usize, +} + +struct RowWithCursor { + record: TrackRecord, + cursor: TrackCursor, +} + +impl Catalog { + pub fn open(path: &Path, generation: u64) -> Result<Self> { + let mut conn = Connection::open(path)?; + schema::init(&mut conn, generation)?; + Ok(Self { conn, generation }) + } + + pub fn open_in_memory(generation: u64) -> Result<Self> { + let mut conn = Connection::open_in_memory()?; + schema::init(&mut conn, generation)?; + Ok(Self { conn, generation }) + } + + pub fn open_read_only(path: &Path, generation: u64) -> Result<Self> { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + schema::configure_read_only(&conn)?; + schema::verify(&conn, generation)?; + Ok(Self { conn, generation }) + } + + pub(crate) fn backup_to(&self, path: &Path) -> Result<()> { + self.conn.backup(rusqlite::MAIN_DB, path, None)?; + Ok(()) + } + + pub(crate) fn adopt_generation( + path: &Path, + previous_generation: u64, + generation: u64, + ) -> Result<Self> { + let conn = Connection::open(path)?; + schema::configure(&conn)?; + schema::verify(&conn, previous_generation)?; + conn.execute( + "UPDATE catalog_meta SET value=?1 WHERE key='generation'", + [generation.to_string()], + )?; + let mut catalog = Self { conn, generation }; + catalog.set_build_phase("projection", previous_generation)?; + Ok(catalog) + } + + pub(crate) fn set_build_phase(&mut self, phase: &str, base_generation: u64) -> Result<()> { + let tx = self.conn.transaction()?; + tx.execute( + "INSERT INTO catalog_meta(key,value) VALUES ('build_phase',?1) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + [phase], + )?; + tx.execute( + "INSERT INTO catalog_meta(key,value) VALUES ('base_generation',?1) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + [base_generation.to_string()], + )?; + tx.commit()?; + Ok(()) + } + + pub(crate) fn build_phase(&self) -> Result<(String, u64)> { + let phase = self + .conn + .query_row( + "SELECT value FROM catalog_meta WHERE key='build_phase'", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .unwrap_or_default(); + let base = self + .conn + .query_row( + "SELECT value FROM catalog_meta WHERE key='base_generation'", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + Ok((phase, base)) + } + + pub fn generation(&self) -> u64 { + self.generation + } + + /// Idempotent batch projection. Source-native identity owns the upsert; + /// the internal catalog rowid is retained and never becomes product state. + pub fn upsert_tracks(&mut self, tracks: &[ProjectedTrack]) -> Result<usize> { + let tx = self.conn.transaction()?; + { + let mut upsert = tx.prepare_cached(UPSERT_TRACK_SQL)?; + let mut clear_credits = + tx.prepare_cached("DELETE FROM artist_credits WHERE catalog_id = ?1")?; + let mut insert_credit = tx.prepare_cached( + "INSERT OR IGNORE INTO artist_credits( + catalog_id, artist_key, display_name, role, ordinal + ) VALUES (?1,?2,?3,?4,?5)", + )?; + for track in tracks { + validate_track(track)?; + upsert_track(&mut upsert, &mut clear_credits, &mut insert_credit, track)?; + } + } + tx.commit()?; + Ok(tracks.len()) + } + + pub fn remove_track(&mut self, track_ref: &TrackRef) -> Result<bool> { + validate_ref(track_ref)?; + Ok(self.conn.execute( + "DELETE FROM tracks + WHERE source_kind = ?1 AND source_instance = ?2 AND native_track_id = ?3", + params![ + track_ref.source.as_str(), + track_ref.source_instance, + track_ref.native_id + ], + )? > 0) + } + + pub(crate) fn source_checkpoint(&self, source: &SourceKey) -> Result<Option<SourceCheckpoint>> { + self.conn + .query_row( + "SELECT available, checkpoint_cursor, checkpoint_rows, + checkpoint_version, complete_generation + FROM source_state + WHERE source_kind = ?1 AND source_instance = ?2", + params![source.source.as_str(), source.source_instance], + |row| { + Ok(SourceCheckpoint { + source: source.clone(), + available: row.get::<_, i64>(0)? != 0, + checkpoint_cursor: row.get(1)?, + checkpoint_rows: row.get::<_, i64>(2)?.max(0) as u64, + checkpoint_version: row.get(3)?, + complete_generation: row.get::<_, i64>(4)?.max(0) as u64, + complete: row.get::<_, i64>(4)? == self.generation as i64, + }) + }, + ) + .optional() + .map_err(Into::into) + } + + pub(crate) fn restart_projection_source( + &mut self, + source: &SourceKey, + snapshot_version: &str, + ) -> Result<()> { + if source.source_instance.trim().is_empty() || snapshot_version.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "source instance and snapshot version must not be empty".to_string(), + )); + } + let tx = self.conn.transaction()?; + tx.execute( + "DELETE FROM tracks WHERE source_kind = ?1 AND source_instance = ?2", + params![source.source.as_str(), source.source_instance], + )?; + tx.execute( + "DELETE FROM source_copies WHERE source_kind = ?1 AND source_instance = ?2", + params![source.source.as_str(), source.source_instance], + )?; + tx.execute( + "DELETE FROM editions + WHERE NOT EXISTS ( + SELECT 1 FROM source_copies sc WHERE sc.edition_id=editions.edition_id + )", + [], + )?; + tx.execute( + "DELETE FROM logical_albums + WHERE NOT EXISTS ( + SELECT 1 FROM editions e + WHERE e.logical_album_id=logical_albums.logical_album_id + )", + [], + )?; + tx.execute( + "INSERT INTO source_state( + source_kind, source_instance, available, last_observed_at, + watermark, complete_generation, checkpoint_cursor, + checkpoint_rows, checkpoint_version + ) VALUES (?1,?2,1,0,'',0,'',0,?3) + ON CONFLICT(source_kind, source_instance) DO UPDATE SET + available=1, + last_observed_at=0, + watermark='', + complete_generation=0, + checkpoint_cursor='', + checkpoint_rows=0, + checkpoint_version=excluded.checkpoint_version", + params![ + source.source.as_str(), + source.source_instance, + snapshot_version + ], + )?; + tx.commit()?; + Ok(()) + } + + pub(crate) fn apply_bootstrap_batch( + &mut self, + batch: &BootstrapBatch, + ) -> Result<SourceCheckpoint> { + if batch.tracks.len() > BOOTSTRAP_BATCH_ROWS { + return Err(CatalogError::BatchTooLarge { + found: batch.tracks.len(), + maximum: BOOTSTRAP_BATCH_ROWS, + }); + } + if batch.source.source_instance.trim().is_empty() + || batch.snapshot_version.trim().is_empty() + { + return Err(CatalogError::InvalidInput( + "source instance and snapshot version must not be empty".to_string(), + )); + } + for track in &batch.tracks { + if track.track_ref.source != batch.source.source + || track.track_ref.source_instance != batch.source.source_instance + { + return Err(CatalogError::InvalidInput(format!( + "batch {:?} contains a row from {:?}/{}", + batch.source, track.track_ref.source, track.track_ref.source_instance + ))); + } + } + + let tx = self.conn.transaction()?; + tx.execute( + "INSERT INTO source_state( + source_kind, source_instance, checkpoint_version + ) VALUES (?1,?2,?3) + ON CONFLICT(source_kind, source_instance) DO NOTHING", + params![ + batch.source.source.as_str(), + batch.source.source_instance, + batch.snapshot_version + ], + )?; + let (committed_cursor, committed_rows, committed_version): (String, i64, String) = tx + .query_row( + "SELECT checkpoint_cursor, checkpoint_rows, checkpoint_version + FROM source_state + WHERE source_kind = ?1 AND source_instance = ?2", + params![batch.source.source.as_str(), batch.source.source_instance], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + if committed_version != batch.snapshot_version { + return Err(CatalogError::SourceSnapshotChanged(batch.source.clone())); + } + if committed_cursor != batch.expected_cursor { + return Err(CatalogError::CheckpointMismatch(batch.source.clone())); + } + + { + let mut upsert = tx.prepare_cached(UPSERT_TRACK_SQL)?; + let mut clear_credits = + tx.prepare_cached("DELETE FROM artist_credits WHERE catalog_id = ?1")?; + let mut insert_credit = tx.prepare_cached( + "INSERT OR IGNORE INTO artist_credits( + catalog_id, artist_key, display_name, role, ordinal + ) VALUES (?1,?2,?3,?4,?5)", + )?; + for track in &batch.tracks { + let mut track = track.clone(); + track.observed_generation = self.generation as i64; + track.source_copy_id = Some(ensure_source_copy(&tx, &track, self.generation)?); + validate_track(&track)?; + upsert_track(&mut upsert, &mut clear_credits, &mut insert_credit, &track)?; + } + } + let next_rows = committed_rows.saturating_add(batch.tracks.len() as i64); + tx.execute( + "UPDATE source_state + SET available=1, + last_observed_at=?3, + complete_generation=?4, + checkpoint_cursor=?5, + checkpoint_rows=?6 + WHERE source_kind=?1 AND source_instance=?2", + params![ + batch.source.source.as_str(), + batch.source.source_instance, + now_unix_seconds(), + if batch.complete { + self.generation as i64 + } else { + 0 + }, + batch.next_cursor, + next_rows, + ], + )?; + tx.commit()?; + Ok(SourceCheckpoint { + source: batch.source.clone(), + available: true, + checkpoint_cursor: batch.next_cursor.clone(), + checkpoint_rows: next_rows.max(0) as u64, + checkpoint_version: batch.snapshot_version.clone(), + complete_generation: if batch.complete { self.generation } else { 0 }, + complete: batch.complete, + }) + } + + pub(crate) fn begin_reconciliation( + &mut self, + source: &SourceKey, + snapshot_version: &str, + ) -> Result<()> { + if source.source_instance.trim().is_empty() || snapshot_version.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "source instance and snapshot version must not be empty".to_string(), + )); + } + let tx = self.conn.transaction()?; + // A restarted attempt in the same derived generation must not inherit + // observation marks from a superseded snapshot. + tx.execute( + "UPDATE tracks SET last_observed_generation=0 + WHERE source_kind=?1 AND source_instance=?2", + params![source.source.as_str(), source.source_instance], + )?; + tx.execute( + "INSERT INTO source_state( + source_kind,source_instance,available,last_observed_at,watermark, + complete_generation,checkpoint_cursor,checkpoint_rows,checkpoint_version + ) VALUES (?1,?2,1,0,'',0,'',0,?3) + ON CONFLICT(source_kind,source_instance) DO UPDATE SET + available=1, + complete_generation=0, + checkpoint_cursor='', + checkpoint_rows=0, + checkpoint_version=excluded.checkpoint_version", + params![ + source.source.as_str(), + source.source_instance, + snapshot_version + ], + )?; + tx.commit()?; + Ok(()) + } + + pub(crate) fn apply_reconciliation_batch( + &mut self, + batch: &ReconciliationBatch, + ) -> Result<SourceCheckpoint> { + if batch.tracks.len() > BOOTSTRAP_BATCH_ROWS { + return Err(CatalogError::BatchTooLarge { + found: batch.tracks.len(), + maximum: BOOTSTRAP_BATCH_ROWS, + }); + } + for track in &batch.tracks { + if track.track_ref.source != batch.source.source + || track.track_ref.source_instance != batch.source.source_instance + { + return Err(CatalogError::InvalidInput(format!( + "reconciliation {:?} contains a row from {:?}/{}", + batch.source, track.track_ref.source, track.track_ref.source_instance + ))); + } + } + let tx = self.conn.transaction()?; + let (committed_cursor, committed_rows, committed_version): (String, i64, String) = tx + .query_row( + "SELECT checkpoint_cursor,checkpoint_rows,checkpoint_version + FROM source_state + WHERE source_kind=?1 AND source_instance=?2", + params![batch.source.source.as_str(), batch.source.source_instance], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + if committed_version != batch.snapshot_version { + return Err(CatalogError::SourceSnapshotChanged(batch.source.clone())); + } + if committed_cursor != batch.expected_cursor { + return Err(CatalogError::CheckpointMismatch(batch.source.clone())); + } + { + let mut upsert = tx.prepare_cached(UPSERT_TRACK_SQL)?; + let mut clear_credits = + tx.prepare_cached("DELETE FROM artist_credits WHERE catalog_id=?1")?; + let mut insert_credit = tx.prepare_cached( + "INSERT OR IGNORE INTO artist_credits( + catalog_id,artist_key,display_name,role,ordinal + ) VALUES (?1,?2,?3,?4,?5)", + )?; + for track in &batch.tracks { + let mut track = track.clone(); + track.observed_generation = self.generation as i64; + track.source_copy_id = Some(ensure_source_copy(&tx, &track, self.generation)?); + validate_track(&track)?; + upsert_track(&mut upsert, &mut clear_credits, &mut insert_credit, &track)?; + } + } + let next_rows = committed_rows.saturating_add(batch.tracks.len() as i64); + if batch.complete { + tx.execute( + "DELETE FROM tracks + WHERE source_kind=?1 AND source_instance=?2 + AND last_observed_generation != ?3", + params![ + batch.source.source.as_str(), + batch.source.source_instance, + self.generation as i64 + ], + )?; + tx.execute( + "DELETE FROM source_copies + WHERE source_kind=?1 AND source_instance=?2 + AND NOT EXISTS ( + SELECT 1 FROM tracks t + WHERE t.source_copy_id=source_copies.source_copy_id + )", + params![batch.source.source.as_str(), batch.source.source_instance], + )?; + // Two statements, two calls: rusqlite < 0.32 silently ran only the + // FIRST statement of a multi-statement `execute` (the logical_albums + // prune below never happened); 0.40 refuses it outright. + tx.execute_batch( + "DELETE FROM editions + WHERE NOT EXISTS ( + SELECT 1 FROM source_copies sc + WHERE sc.edition_id=editions.edition_id + ); + DELETE FROM logical_albums + WHERE NOT EXISTS ( + SELECT 1 FROM editions e + WHERE e.logical_album_id=logical_albums.logical_album_id + );", + )?; + } + tx.execute( + "UPDATE source_state + SET available=1,last_observed_at=?3, + watermark=CASE WHEN ?4 THEN ?5 ELSE watermark END, + complete_generation=CASE WHEN ?4 THEN ?6 ELSE 0 END, + checkpoint_cursor=?7,checkpoint_rows=?8 + WHERE source_kind=?1 AND source_instance=?2", + params![ + batch.source.source.as_str(), + batch.source.source_instance, + now_unix_seconds(), + batch.complete, + batch.snapshot_version, + self.generation as i64, + batch.next_cursor, + next_rows, + ], + )?; + tx.commit()?; + Ok(SourceCheckpoint { + source: batch.source.clone(), + available: true, + checkpoint_cursor: batch.next_cursor.clone(), + checkpoint_rows: next_rows.max(0) as u64, + checkpoint_version: batch.snapshot_version.clone(), + complete_generation: if batch.complete { self.generation } else { 0 }, + complete: batch.complete, + }) + } + + pub(crate) fn source_watermark(&self, source: &SourceKey) -> Result<Option<String>> { + self.conn + .query_row( + "SELECT watermark FROM source_state + WHERE source_kind=?1 AND source_instance=?2", + params![source.source.as_str(), source.source_instance], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) + } + + pub(crate) fn checkpoint_for_activation(&mut self) -> Result<()> { + self.conn + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; + Ok(()) + } + + pub(crate) fn rebuild_materialized_views(&mut self) -> Result<()> { + let tx = self.conn.transaction()?; + rebuild_artist_identities(&tx)?; + tx.execute_batch( + "DELETE FROM artists_fts; + DELETE FROM artist_source_stats; + DELETE FROM edition_artists; + DELETE FROM albums_materialized; + DELETE FROM artists_materialized; + + INSERT INTO edition_artists(edition_id,artist_key,role) + SELECT DISTINCT sc.edition_id,ac.artist_key,ac.role + FROM artist_identity_credits ac + JOIN tracks t ON t.catalog_id=ac.catalog_id + JOIN source_copies sc ON sc.source_copy_id=t.source_copy_id; + + INSERT INTO albums_materialized( + edition_id,logical_album_id,title,sort_title,artist,sort_artist, + year,year_missing,year_value,track_count,total_duration_ms, + source_count,available, + source_kind,native_album_id,source_raw,all_artists,format, + bit_depth,sample_rate_hz,quality_tier,directory_path,folder_count, + added_at, + artwork_source,artwork_token + ) + SELECT e.edition_id,e.logical_album_id,e.display_title,la.sort_title, + e.display_artist,la.sort_artist,e.release_year, + CASE WHEN e.release_year IS NULL THEN 1 ELSE 0 END, + COALESCE(e.release_year,0),COUNT(t.catalog_id), + COALESCE(SUM(t.duration_ms),0), + COUNT(DISTINCT sc.source_kind || char(31) || sc.source_instance), + COALESCE(MAX(t.available),0), + COALESCE(( + SELECT scp.source_kind FROM source_copies scp + WHERE scp.edition_id=e.edition_id + ORDER BY scp.available DESC, + CASE scp.source_kind + WHEN 'local' THEN 0 WHEN 'offline' THEN 1 + WHEN 'plex' THEN 2 WHEN 'jellyfin' THEN 3 ELSE 4 END, + scp.source_copy_id + LIMIT 1 + ),'local'), + COALESCE(( + SELECT scp.native_album_id FROM source_copies scp + WHERE scp.edition_id=e.edition_id + ORDER BY scp.available DESC, + CASE scp.source_kind + WHEN 'local' THEN 0 WHEN 'offline' THEN 1 + WHEN 'plex' THEN 2 WHEN 'jellyfin' THEN 3 ELSE 4 END, + scp.source_copy_id + LIMIT 1 + ),''), + COALESCE(( + SELECT t2.source_raw FROM tracks t2 + JOIN source_copies sc2 ON sc2.source_copy_id=t2.source_copy_id + WHERE sc2.edition_id=e.edition_id + ORDER BY t2.available DESC,t2.catalog_id LIMIT 1 + ),''), + COALESCE(( + SELECT group_concat(DISTINCT ac.display_name) + FROM artist_identity_credits ac + JOIN tracks ta ON ta.catalog_id=ac.catalog_id + JOIN source_copies sca ON sca.source_copy_id=ta.source_copy_id + WHERE sca.edition_id=e.edition_id + ),''), + COALESCE(( + SELECT LOWER(t2.format) FROM tracks t2 + JOIN source_copies sc2 ON sc2.source_copy_id=t2.source_copy_id + WHERE sc2.edition_id=e.edition_id + ORDER BY COALESCE(t2.bit_depth,0) DESC, + COALESCE(t2.sample_rate_hz,0) DESC,t2.catalog_id + LIMIT 1 + ),''), + MAX(t.bit_depth),MAX(t.sample_rate_hz), + CASE + WHEN MAX(CASE WHEN LOWER(t.format)='mp3' THEN 0 ELSE 1 END)=0 + THEN 'mp3' + WHEN MAX(CASE WHEN LOWER(t.format) IN ('dsd','dsf','dff') THEN 1 ELSE 0 END)=1 + THEN 'dsd' + WHEN MAX(t.bit_depth)>=24 AND MAX(t.sample_rate_hz)>96000 THEN 'max' + WHEN MAX(t.bit_depth)>=24 THEN 'hires' + WHEN MAX(t.bit_depth) IS NOT NULL THEN 'cd' + WHEN MAX(t.sample_rate_hz)>=44100 THEN 'cd' + ELSE '' + END, + COALESCE(MIN(NULLIF(sc.local_directory,'')),''), + COUNT(DISTINCT NULLIF(sc.local_directory,'')),MAX(t.added_at), + COALESCE(( + SELECT t2.source_kind FROM tracks t2 + WHERE t2.source_copy_id IN ( + SELECT sc2.source_copy_id FROM source_copies sc2 + WHERE sc2.edition_id=e.edition_id + ) AND COALESCE(t2.artwork_token,'') != '' + ORDER BY t2.available DESC,t2.catalog_id LIMIT 1 + ),''), + COALESCE(( + SELECT t2.artwork_token FROM tracks t2 + WHERE t2.source_copy_id IN ( + SELECT sc2.source_copy_id FROM source_copies sc2 + WHERE sc2.edition_id=e.edition_id + ) AND COALESCE(t2.artwork_token,'') != '' + ORDER BY t2.available DESC,t2.catalog_id LIMIT 1 + ),'') + FROM editions e + JOIN logical_albums la ON la.logical_album_id=e.logical_album_id + JOIN source_copies sc ON sc.edition_id=e.edition_id + JOIN tracks t ON t.source_copy_id=sc.source_copy_id + GROUP BY e.edition_id; + + INSERT INTO artists_materialized( + artist_key,display_name,sort_name,album_count,track_count,available, + source_kind,artwork_source,artwork_token + ) + SELECT ac.artist_key,MIN(ac.display_name),ac.artist_key, + COUNT(DISTINCT CASE WHEN t.available=1 THEN sc.edition_id END), + COUNT(DISTINCT CASE WHEN t.available=1 THEN ac.catalog_id END), + COALESCE(MAX(t.available),0), + CASE WHEN COUNT(DISTINCT t.source_kind)>1 THEN 'mixed' + ELSE MIN(t.source_kind) END, + COALESCE(( + SELECT t2.source_kind + FROM artist_identity_credits ac2 + JOIN tracks t2 ON t2.catalog_id=ac2.catalog_id + WHERE ac2.artist_key=ac.artist_key + AND t2.available=1 + AND COALESCE(t2.artwork_token,'') != '' + ORDER BY CASE ac2.role + WHEN 'track_artist' THEN 0 + WHEN 'album_artist' THEN 1 + WHEN 'featured' THEN 2 + WHEN 'performer' THEN 3 ELSE 4 END, + t2.catalog_id LIMIT 1 + ),''), + COALESCE(( + SELECT t2.artwork_token + FROM artist_identity_credits ac2 + JOIN tracks t2 ON t2.catalog_id=ac2.catalog_id + WHERE ac2.artist_key=ac.artist_key + AND t2.available=1 + AND COALESCE(t2.artwork_token,'') != '' + ORDER BY CASE ac2.role + WHEN 'track_artist' THEN 0 + WHEN 'album_artist' THEN 1 + WHEN 'featured' THEN 2 + WHEN 'performer' THEN 3 ELSE 4 END, + t2.catalog_id LIMIT 1 + ),'') + FROM artist_identity_credits ac + JOIN tracks t ON t.catalog_id=ac.catalog_id + LEFT JOIN source_copies sc ON sc.source_copy_id=t.source_copy_id + GROUP BY ac.artist_key; + + INSERT INTO artist_source_stats( + artist_key,source_kind,source_instance,album_count,track_count,available + ) + SELECT ac.artist_key,t.source_kind,t.source_instance, + COUNT(DISTINCT CASE WHEN t.available=1 THEN sc.edition_id END), + COUNT(DISTINCT CASE WHEN t.available=1 THEN ac.catalog_id END), + COALESCE(MAX(t.available),0) + FROM artist_identity_credits ac + JOIN tracks t ON t.catalog_id=ac.catalog_id + LEFT JOIN source_copies sc ON sc.source_copy_id=t.source_copy_id + GROUP BY ac.artist_key,t.source_kind,t.source_instance; + + INSERT INTO artists_fts(artist_key,display_name) + SELECT artist_key,display_name FROM artists_materialized;", + )?; + tx.commit()?; + Ok(()) + } + + pub(crate) fn materialized_views_valid(&self) -> Result<bool> { + let (tracks, album_tracks, unresolved, credit_artists, materialized_artists, missing_stats): + (i64, i64, i64, i64, i64, i64) = self.conn.query_row( + "SELECT + (SELECT COUNT(*) FROM tracks), + (SELECT COALESCE(SUM(track_count),0) FROM albums_materialized), + (SELECT COUNT(*) FROM tracks WHERE source_copy_id IS NULL), + (SELECT COUNT(DISTINCT artist_key) FROM artist_identity_credits), + (SELECT COUNT(*) FROM artists_materialized), + (SELECT COUNT(*) FROM artists_materialized am + WHERE NOT EXISTS (SELECT 1 FROM artist_source_stats ast + WHERE ast.artist_key=am.artist_key))", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, + )?; + Ok(tracks == album_tracks + && unresolved == 0 + && credit_artists == materialized_artists + && missing_stats == 0) + } + + pub fn resolve(&self, track_ref: &TrackRef) -> Result<Option<TrackRecord>> { + validate_ref(track_ref)?; + let sql = format!( + "SELECT {TRACK_COLUMNS} + FROM tracks t + WHERE t.source_kind = ?1 + AND t.source_instance = ?2 + AND t.native_track_id = ?3" + ); + self.conn + .query_row( + &sql, + params![ + track_ref.source.as_str(), + track_ref.source_instance, + track_ref.native_id + ], + map_row, + ) + .optional() + .map(|row| row.map(|value| value.record)) + .map_err(Into::into) + } + + pub fn count_tracks(&self, descriptor: &QueryDescriptor) -> Result<u64> { + validate_tracks_descriptor(descriptor)?; + let (sql, values) = count_query(descriptor)?; + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(values), |row| row.get(0))?; + Ok(count as u64) + } + + pub fn query_tracks( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&TrackCursor>, + page_size: usize, + ) -> Result<TrackPage> { + self.query_tracks_timed(descriptor, cursor, page_size) + .map(|(page, _)| page) + } + + pub fn query_tracks_timed( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&TrackCursor>, + page_size: usize, + ) -> Result<(TrackPage, QueryMetrics)> { + validate_tracks_descriptor(descriptor)?; + let limit = page_size.clamp(1, MAX_PAGE_SIZE); + let (sql, values, sort) = page_query(descriptor, cursor, limit)?; + let descriptor_key = descriptor_key(descriptor); + let started = Instant::now(); + let mut stmt = self.conn.prepare_cached(&sql)?; + let mapped = stmt.query_map(params_from_iter(values), map_row)?; + let mut values = mapped.collect::<rusqlite::Result<Vec<_>>>()?; + for value in &mut values { + value.cursor.sort = sort; + value.cursor.descriptor_key = descriptor_key.clone(); + } + let sql_time = started.elapsed(); + let has_more = values.len() > limit; + values.truncate(limit); + let next_cursor = has_more.then(|| values.last().expect("non-empty page").cursor.clone()); + let rows = values + .into_iter() + .map(|value| value.record) + .collect::<Vec<_>>(); + let metrics = QueryMetrics { + sql_time, + rows: rows.len(), + }; + Ok(( + TrackPage { + rows, + next_cursor, + has_more, + }, + metrics, + )) + } + + pub fn count_albums(&self, descriptor: &QueryDescriptor) -> Result<u64> { + validate_albums_descriptor(descriptor)?; + let parts = album_filter_parts(descriptor, None)?; + let sql = format!( + "SELECT COUNT(*) {} WHERE {}", + parts.from_sql, parts.where_sql + ); + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(parts.params), |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + /// Exact number of visual rows for the current grid/list layout. Group + /// headers count as one row and album chunks never cross a global group. + pub fn count_album_entries(&self, descriptor: &QueryDescriptor, columns: usize) -> Result<u64> { + validate_albums_descriptor(descriptor)?; + let columns = columns.clamp(1, 32) as i64; + let parts = album_filter_parts(descriptor, None)?; + let sql = if descriptor.group() == TrackGroup::Off { + format!( + "SELECT (COUNT(*) + ? - 1) / ? {} WHERE {}", + parts.from_sql, parts.where_sql + ) + } else { + let group = album_group_expression(descriptor.group()); + format!( + "SELECT COALESCE(SUM(1 + (n + ? - 1) / ?),0) + FROM (SELECT {group} AS group_key,COUNT(*) AS n + {} WHERE {} GROUP BY group_key)", + parts.from_sql, parts.where_sql + ) + }; + let mut params = vec![Value::Integer(columns), Value::Integer(columns)]; + params.extend(parts.params); + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(params), |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + pub fn query_albums( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&AlbumCursor>, + page_size: usize, + ) -> Result<AlbumPage> { + self.query_albums_timed(descriptor, cursor, page_size) + .map(|(page, _)| page) + } + + pub fn query_albums_timed( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&AlbumCursor>, + page_size: usize, + ) -> Result<(AlbumPage, QueryMetrics)> { + validate_albums_descriptor(descriptor)?; + let limit = page_size.clamp(1, MAX_PAGE_SIZE); + let parts = album_filter_parts(descriptor, cursor)?; + let order = album_order_fields(descriptor, "am") + .into_iter() + .map(|field| { + format!( + "{} {}", + field.expression, + if field.descending { "DESC" } else { "ASC" } + ) + }) + .chain(std::iter::once("am.edition_id ASC".to_string())) + .collect::<Vec<_>>() + .join(", "); + let sql = format!( + "SELECT {ALBUM_COLUMNS} {} WHERE {} ORDER BY {order} LIMIT {}", + parts.from_sql, + parts.where_sql, + limit + 1 + ); + let key = descriptor_key(descriptor); + let started = Instant::now(); + let mut stmt = self.conn.prepare_cached(&sql)?; + let mapped = stmt.query_map(params_from_iter(parts.params), map_album_row)?; + let mut values = mapped.collect::<rusqlite::Result<Vec<_>>>()?; + for value in &mut values { + value.cursor.descriptor_key = key.clone(); + } + let sql_time = started.elapsed(); + let has_more = values.len() > limit; + values.truncate(limit); + let next_cursor = has_more.then(|| values.last().expect("non-empty page").cursor.clone()); + let rows = values + .into_iter() + .map(|value| value.record) + .collect::<Vec<_>>(); + let metrics = QueryMetrics { + sql_time, + rows: rows.len(), + }; + Ok(( + AlbumPage { + rows, + next_cursor, + has_more, + }, + metrics, + )) + } + + pub fn count_artists(&self, descriptor: &QueryDescriptor) -> Result<u64> { + validate_artists_descriptor(descriptor)?; + let parts = artist_filter_parts(descriptor, None)?; + let sql = format!( + "SELECT COUNT(*) {} WHERE {}", + parts.from_sql, parts.where_sql + ); + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(parts.params), |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + /// Exact visual length of the Artists rail: one row per artist plus one + /// header for every non-empty A-Z/# bucket under the immutable query. + pub fn count_artist_entries(&self, descriptor: &QueryDescriptor) -> Result<u64> { + validate_artists_descriptor(descriptor)?; + let parts = artist_filter_parts(descriptor, None)?; + let sql = format!( + "SELECT COUNT(*) + COUNT(DISTINCT {}) {} WHERE {}", + artist_initial_expression("ar"), + parts.from_sql, + parts.where_sql + ); + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(parts.params), |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + pub fn query_artists( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&ArtistCursor>, + page_size: usize, + ) -> Result<ArtistPage> { + self.query_artists_timed(descriptor, cursor, page_size) + .map(|(page, _)| page) + } + + pub fn query_artists_timed( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&ArtistCursor>, + page_size: usize, + ) -> Result<(ArtistPage, QueryMetrics)> { + validate_artists_descriptor(descriptor)?; + let limit = page_size.clamp(1, MAX_PAGE_SIZE); + let parts = artist_filter_parts(descriptor, cursor)?; + let descending = artist_descending(descriptor); + let order = if descending { "DESC" } else { "ASC" }; + let sql = format!( + "SELECT ar.artist_key,ar.display_name,{},{},ar.artwork_source, + ar.artwork_token,{},ar.sort_name + {} WHERE {} + ORDER BY ar.sort_name {order},ar.artist_key {order} LIMIT {}", + parts.album_count_sql, + parts.track_count_sql, + parts.source_sql, + parts.from_sql, + parts.where_sql, + limit + 1 + ); + let key = descriptor_key(descriptor); + let started = Instant::now(); + let mut stmt = self.conn.prepare_cached(&sql)?; + let mapped = stmt.query_map(params_from_iter(parts.params), |row| { + Ok(( + ArtistRecord { + artist_key: row.get(0)?, + display_name: row.get(1)?, + album_count: row.get::<_, i64>(2)?.max(0) as u32, + track_count: row.get::<_, i64>(3)?.max(0) as u32, + artwork_source: row.get(4)?, + artwork_token: row.get(5)?, + source: row.get(6)?, + }, + row.get::<_, String>(7)?, + )) + })?; + let mut values = mapped.collect::<rusqlite::Result<Vec<_>>>()?; + let sql_time = started.elapsed(); + let has_more = values.len() > limit; + values.truncate(limit); + let next_cursor = has_more.then(|| { + let (record, sort_name) = values.last().expect("non-empty page"); + ArtistCursor { + descriptor_key: key, + sort_name: sort_name.clone(), + artist_key: record.artist_key.clone(), + } + }); + let rows = values + .into_iter() + .map(|(record, _)| record) + .collect::<Vec<_>>(); + let metrics = QueryMetrics { + sql_time, + rows: rows.len(), + }; + Ok(( + ArtistPage { + rows, + next_cursor, + has_more, + }, + metrics, + )) + } + + /// Count the selected artist's albums through the normalized, + /// source-aware `edition_artists` relationship. No album collection is + /// materialized in the frontend to answer this question. + pub fn count_artist_albums(&self, artist_key: &str, sources: &[SourceKey]) -> Result<u64> { + validate_artist_key(artist_key)?; + let (where_sql, values) = artist_album_filter(artist_key, sources, None)?; + let sql = format!("SELECT COUNT(*) FROM albums_materialized am WHERE {where_sql}"); + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(values), |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + pub fn query_artist_albums( + &self, + artist_key: &str, + sources: &[SourceKey], + cursor: Option<&AlbumCursor>, + page_size: usize, + ) -> Result<AlbumPage> { + self.query_artist_albums_timed(artist_key, sources, cursor, page_size) + .map(|(page, _)| page) + } + + pub fn query_artist_albums_timed( + &self, + artist_key: &str, + sources: &[SourceKey], + cursor: Option<&AlbumCursor>, + page_size: usize, + ) -> Result<(AlbumPage, QueryMetrics)> { + validate_artist_key(artist_key)?; + let limit = page_size.clamp(1, MAX_PAGE_SIZE); + let (where_sql, values) = artist_album_filter(artist_key, sources, cursor)?; + let sql = format!( + "SELECT {ALBUM_COLUMNS} FROM albums_materialized am + WHERE {where_sql} + ORDER BY am.sort_title,am.edition_id LIMIT {}", + limit + 1 + ); + let key = artist_relation_key(artist_key, sources); + let started = Instant::now(); + let mut stmt = self.conn.prepare_cached(&sql)?; + let mapped = stmt.query_map(params_from_iter(values), map_album_row)?; + let mut values = mapped.collect::<rusqlite::Result<Vec<_>>>()?; + for value in &mut values { + value.cursor.descriptor_key = key.clone(); + } + let sql_time = started.elapsed(); + let has_more = values.len() > limit; + values.truncate(limit); + let next_cursor = has_more.then(|| values.last().expect("non-empty page").cursor.clone()); + let rows = values + .into_iter() + .map(|value| value.record) + .collect::<Vec<_>>(); + let metrics = QueryMetrics { + sql_time, + rows: rows.len(), + }; + Ok(( + AlbumPage { + rows, + next_cursor, + has_more, + }, + metrics, + )) + } + + pub fn stats(&self) -> Result<CatalogStats> { + let track_count: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM tracks", [], |row| row.get(0))?; + let mut stmt = self.conn.prepare( + "SELECT source_kind, source_instance, COUNT(*) + FROM tracks + GROUP BY source_kind, source_instance + ORDER BY source_kind, source_instance", + )?; + let rows = stmt.query_map([], |row| { + let word: String = row.get(0)?; + Ok(( + SourceKey { + source: SourceKind::from_str(&word).expect("schema source CHECK"), + source_instance: row.get(1)?, + }, + row.get::<_, i64>(2)? as u64, + )) + })?; + let source_counts = rows.collect::<rusqlite::Result<Vec<_>>>()?; + let page_size_bytes: i64 = self + .conn + .query_row("PRAGMA page_size", [], |row| row.get(0))?; + let page_count: i64 = self + .conn + .query_row("PRAGMA page_count", [], |row| row.get(0))?; + Ok(CatalogStats { + schema_version: SCHEMA_VERSION, + generation: self.generation, + track_count: track_count as u64, + source_counts, + page_size_bytes: page_size_bytes as u64, + page_count: page_count as u64, + }) + } + + pub fn integrity_check(&self) -> Result<IntegrityReport> { + let result: String = self + .conn + .query_row("PRAGMA quick_check", [], |row| row.get(0))?; + let foreign_key_violations: i64 = + self.conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + let tracks_fts_ok = self + .conn + .execute( + "INSERT INTO tracks_fts(tracks_fts) VALUES ('integrity-check')", + [], + ) + .is_ok(); + let albums_fts_ok = self + .conn + .execute( + "INSERT INTO albums_fts(albums_fts) VALUES ('integrity-check')", + [], + ) + .is_ok(); + let artists_fts_ok = self + .conn + .execute( + "INSERT INTO artists_fts(artists_fts) VALUES ('integrity-check')", + [], + ) + .is_ok(); + Ok(IntegrityReport { + sqlite_ok: result == "ok", + foreign_key_violations: foreign_key_violations as u64, + fts_ok: tracks_fts_ok && albums_fts_ok && artists_fts_ok, + }) + } + + /// Re-check the active generation without acquiring a write connection. + /// FTS5 consistency remains an activation invariant; this runtime probe + /// covers SQLite pages, foreign keys and every derived aggregate. + pub fn runtime_integrity_check(&self) -> Result<RuntimeIntegrityReport> { + // A database-wide `quick_check` asks FTS5 to validate its inverted + // index, and SQLite implements that virtual-table callback as a write + // operation. Check every ordinary catalog table explicitly so this + // remains valid on the read-only active connection; the full FTS + // integrity command is an activation gate before the file is promoted. + let mut sqlite_ok = true; + let mut failures = Vec::new(); + for table in [ + "catalog_meta", + "source_state", + "logical_albums", + "editions", + "source_copies", + "tracks", + "artist_credits", + "artist_identity_credits", + "albums_materialized", + "artists_materialized", + "artist_source_stats", + "edition_artists", + ] { + let result: String = + self.conn + .query_row(&format!("PRAGMA quick_check('{table}')"), [], |row| { + row.get(0) + })?; + if result != "ok" { + sqlite_ok = false; + failures.push(format!("{table}: {result}")); + } + } + let foreign_key_violations: i64 = + self.conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + Ok(RuntimeIntegrityReport { + sqlite_ok, + sqlite_result: if failures.is_empty() { + "ok".to_string() + } else { + failures.join("; ") + }, + foreign_key_violations: foreign_key_violations as u64, + materialized_views_ok: self.materialized_views_valid()?, + }) + } + + #[cfg(test)] + pub(crate) fn connection(&self) -> &Connection { + &self.conn + } +} + +/// Collapse a repeated `collection • contributor` album-artist family into +/// one collection identity while retaining every track/contributor credit. +/// +/// A single bullet-bearing name is left intact because it can be a deliberate +/// collaboration or stage name. Two distinct suffixes sharing a normalized +/// prefix provide corpus-level evidence that the source encoded a family of +/// album credits rather than separate root artists. Only the derived credit +/// relation is rebuilt; raw credits plus track and album presentation metadata +/// remain byte-for-byte source-authored for the next incremental generation. +fn rebuild_artist_identities(tx: &rusqlite::Transaction<'_>) -> Result<usize> { + let mut families = HashMap::<String, AlbumArtistFamilyEvidence>::new(); + { + let mut statement = tx.prepare( + "SELECT display_name,COUNT(*) + FROM artist_credits + WHERE role='album_artist' AND instr(display_name,?1)>0 + GROUP BY display_name + ORDER BY display_name", + )?; + let rows = statement.query_map(params![ALBUM_ARTIST_FAMILY_SEPARATOR], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?.max(0) as u64, + )) + })?; + for row in rows { + let (display_name, count) = row?; + let Some((prefix, suffix)) = split_album_artist_family(&display_name) else { + continue; + }; + let family_key = normalize_artist_key(prefix); + let suffix_key = normalize_artist_key(suffix); + if family_key.is_empty() || suffix_key.is_empty() { + continue; + } + let evidence = families.entry(family_key).or_default(); + evidence.suffixes.insert(suffix_key); + *evidence + .display_counts + .entry(prefix.to_string()) + .or_default() += count; + } + } + + let mut aliases = Vec::new(); + for (family_key, evidence) in &families { + if evidence.suffixes.len() < 2 { + continue; + } + let canonical_display = { + let mut displays = evidence.display_counts.iter().collect::<Vec<_>>(); + displays.sort_by(|(left_name, left_count), (right_name, right_count)| { + right_count + .cmp(left_count) + .then_with(|| left_name.cmp(right_name)) + }); + displays + .into_iter() + .next() + .map(|(display_name, _)| (*display_name).clone()) + .unwrap_or_default() + }; + for prefix in evidence.display_counts.keys() { + aliases.push(( + prefix.clone(), + family_key.clone(), + canonical_display.clone(), + )); + } + } + aliases.sort(); + + tx.execute("DELETE FROM artist_identity_credits", [])?; + tx.execute( + "INSERT OR IGNORE INTO artist_identity_credits( + catalog_id,artist_key,display_name,role,ordinal + ) SELECT catalog_id,artist_key,display_name,role,ordinal + FROM artist_credits", + [], + )?; + let mut rewritten = 0; + for (prefix, family_key, canonical_display) in aliases { + rewritten += tx.execute( + "DELETE FROM artist_identity_credits + WHERE role='album_artist' AND instr(display_name,?1)>0 + AND trim(substr(display_name,1,instr(display_name,?1)-1))=?2", + params![ALBUM_ARTIST_FAMILY_SEPARATOR, prefix], + )?; + tx.execute( + "INSERT OR IGNORE INTO artist_identity_credits( + catalog_id,artist_key,display_name,role,ordinal + ) SELECT catalog_id,?1,?2,role,ordinal + FROM artist_credits + WHERE role='album_artist' AND instr(display_name,?3)>0 + AND trim(substr(display_name,1,instr(display_name,?3)-1))=?4", + params![ + family_key, + canonical_display, + ALBUM_ARTIST_FAMILY_SEPARATOR, + prefix + ], + )?; + } + Ok(rewritten) +} + +fn split_album_artist_family(display_name: &str) -> Option<(&str, &str)> { + let (prefix, suffix) = display_name.split_once(ALBUM_ARTIST_FAMILY_SEPARATOR)?; + let prefix = prefix.trim(); + let suffix = suffix.trim(); + (!prefix.is_empty() && !suffix.is_empty()).then_some((prefix, suffix)) +} + +fn validate_ref(track_ref: &TrackRef) -> Result<()> { + if track_ref.source_instance.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "source instance must not be empty".to_string(), + )); + } + if track_ref.native_id.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "native track id must not be empty".to_string(), + )); + } + Ok(()) +} + +fn validate_track(track: &ProjectedTrack) -> Result<()> { + validate_ref(&track.track_ref)?; + if track.duration_ms > i64::MAX as u64 { + return Err(CatalogError::InvalidInput( + "duration does not fit SQLite INTEGER".to_string(), + )); + } + Ok(()) +} + +fn ensure_source_copy(conn: &Connection, track: &ProjectedTrack, generation: u64) -> Result<i64> { + let display_artist = if track.album_artist.trim().is_empty() { + track.artist.trim() + } else { + track.album_artist.trim() + }; + let native_album_id = track + .native_album_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let fallback_id; + let (association, evidence, album_id) = if let Some(native) = native_album_id { + ("source_native", native, native) + } else { + fallback_id = stable_parts(&[ + &normalize_sort_key(&track.album), + &normalize_sort_key(display_artist), + &track.year.map(|year| year.to_string()).unwrap_or_default(), + ]); + ("text_fallback", fallback_id.as_str(), fallback_id.as_str()) + }; + let stable_key = stable_parts(&[ + track.track_ref.source.as_str(), + &track.track_ref.source_instance, + album_id, + ]); + let sort_title = normalize_sort_key(&track.album); + let sort_artist = normalize_sort_key(display_artist); + let logical_album_id: i64 = conn.query_row( + "INSERT INTO logical_albums( + stable_key,display_title,sort_title,display_artist,sort_artist, + association_strength,association_evidence + ) VALUES (?1,?2,?3,?4,?5,?6,?7) + ON CONFLICT(stable_key) DO UPDATE SET + display_title=excluded.display_title, + sort_title=excluded.sort_title, + display_artist=excluded.display_artist, + sort_artist=excluded.sort_artist, + association_strength=excluded.association_strength, + association_evidence=excluded.association_evidence + RETURNING logical_album_id", + params![ + stable_key, + track.album, + sort_title, + display_artist, + sort_artist, + association, + evidence, + ], + |row| row.get(0), + )?; + let edition_key = stable_parts(&[&stable_key, "edition"]); + let edition_id: i64 = conn.query_row( + "INSERT INTO editions( + logical_album_id,edition_key,display_title,display_artist,release_year, + provider_release_id,evidence_kind,evidence_value + ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) + ON CONFLICT(edition_key) DO UPDATE SET + display_title=excluded.display_title, + display_artist=excluded.display_artist, + release_year=excluded.release_year, + provider_release_id=excluded.provider_release_id, + evidence_kind=excluded.evidence_kind, + evidence_value=excluded.evidence_value + RETURNING edition_id", + params![ + logical_album_id, + edition_key, + track.album, + display_artist, + track.year.map(i64::from), + native_album_id, + association, + evidence, + ], + |row| row.get(0), + )?; + let local_directory = track.local_path.as_deref().and_then(|path| { + Path::new(path) + .parent() + .map(|parent| parent.to_string_lossy().into_owned()) + }); + let source_copy_id: i64 = conn.query_row( + "INSERT INTO source_copies( + edition_id,source_kind,source_instance,native_album_id,local_directory, + available,last_observed_generation + ) VALUES (?1,?2,?3,?4,?5,?6,?7) + ON CONFLICT(source_kind,source_instance,native_album_id) DO UPDATE SET + edition_id=excluded.edition_id, + local_directory=excluded.local_directory, + available=excluded.available, + last_observed_generation=excluded.last_observed_generation + RETURNING source_copy_id", + params![ + edition_id, + track.track_ref.source.as_str(), + track.track_ref.source_instance, + album_id, + local_directory, + i64::from(track.available), + generation as i64, + ], + |row| row.get(0), + )?; + Ok(source_copy_id) +} + +fn stable_parts(parts: &[&str]) -> String { + let mut key = String::new(); + for part in parts { + key.push_str(&part.len().to_string()); + key.push(':'); + key.push_str(part); + key.push('|'); + } + key +} + +fn validate_tracks_descriptor(descriptor: &QueryDescriptor) -> Result<()> { + if descriptor.surface() != crate::QuerySurface::Tracks { + return Err(CatalogError::InvalidInput( + "a non-Tracks descriptor was passed to a Tracks query".to_string(), + )); + } + Ok(()) +} + +fn upsert_track( + upsert: &mut Statement<'_>, + clear_credits: &mut Statement<'_>, + insert_credit: &mut Statement<'_>, + track: &ProjectedTrack, +) -> Result<i64> { + let sort_title = normalize_sort_key(&track.title); + let sort_artist = normalize_sort_key(if track.album_artist.trim().is_empty() { + &track.artist + } else { + &track.album_artist + }); + let sort_track_artist = normalize_sort_key(&track.artist); + let sort_album = normalize_sort_key(&track.album); + let year_missing = i64::from(track.year.is_none()); + let year_value = track.year.unwrap_or(0) as i64; + let disc_sort = track.disc_number.unwrap_or(0) as i64; + let track_sort = track.track_number.unwrap_or(0) as i64; + let mut seen_credits = HashSet::new(); + let credits = track + .credits + .iter() + .filter_map(|credit| { + let name = credit.display_name.trim(); + (!name.is_empty() && seen_credits.insert(name.to_lowercase())).then(|| name.to_string()) + }) + .collect::<Vec<_>>() + .join(" \u{1f} "); + + let catalog_id: i64 = upsert.query_row( + params![ + track.track_ref.source.as_str(), + track.track_ref.source_instance, + track.track_ref.native_id, + track.source_raw, + track.local_track_id, + track.local_path, + track.source_copy_id, + track.title, + sort_title, + track.artist, + sort_artist, + sort_track_artist, + track.album_artist, + track.album, + sort_album, + credits, + track.duration_ms as i64, + track.year.map(i64::from), + year_missing, + year_value, + track.disc_number.map(i64::from), + disc_sort, + track.track_number.map(i64::from), + track_sort, + track.format.trim().to_ascii_lowercase(), + track.bit_depth.map(i64::from), + track.sample_rate_hz.map(i64::from), + track.artwork_token, + track.isrc, + track.musicbrainz_recording_id, + track.added_at, + i64::from(track.available), + track.observed_generation, + ], + |row| row.get(0), + )?; + + clear_credits.execute(params![catalog_id])?; + for credit in &track.credits { + let artist_key = normalize_artist_key(&credit.display_name); + if artist_key.is_empty() { + continue; + } + insert_credit.execute(params![ + catalog_id, + artist_key, + credit.display_name.trim(), + credit.role.as_str(), + i64::from(credit.ordinal), + ])?; + } + Ok(catalog_id) +} + +const UPSERT_TRACK_SQL: &str = "INSERT INTO tracks ( + source_kind, source_instance, native_track_id, source_raw, local_track_id, + local_path, source_copy_id, title, sort_title, artist, sort_artist, + sort_track_artist, album_artist, album, sort_album, credits, duration_ms, + year, year_missing, year_value, + disc_number, disc_sort, track_number, track_sort, format, bit_depth, + sample_rate_hz, artwork_token, isrc, musicbrainz_recording_id, added_at, + available, last_observed_generation +) VALUES ( + ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18, + ?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33 +) +ON CONFLICT(source_kind, source_instance, native_track_id) DO UPDATE SET + source_raw=excluded.source_raw, + local_track_id=excluded.local_track_id, + local_path=excluded.local_path, + source_copy_id=excluded.source_copy_id, + title=excluded.title, + sort_title=excluded.sort_title, + artist=excluded.artist, + sort_artist=excluded.sort_artist, + sort_track_artist=excluded.sort_track_artist, + album_artist=excluded.album_artist, + album=excluded.album, + sort_album=excluded.sort_album, + credits=excluded.credits, + duration_ms=excluded.duration_ms, + year=excluded.year, + year_missing=excluded.year_missing, + year_value=excluded.year_value, + disc_number=excluded.disc_number, + disc_sort=excluded.disc_sort, + track_number=excluded.track_number, + track_sort=excluded.track_sort, + format=excluded.format, + bit_depth=excluded.bit_depth, + sample_rate_hz=excluded.sample_rate_hz, + artwork_token=excluded.artwork_token, + isrc=excluded.isrc, + musicbrainz_recording_id=excluded.musicbrainz_recording_id, + added_at=excluded.added_at, + available=excluded.available, + last_observed_generation=excluded.last_observed_generation +RETURNING catalog_id"; + +struct QueryParts { + where_sql: String, + params: Vec<Value>, + sort: TrackSort, +} + +fn filter_parts( + descriptor: &QueryDescriptor, + cursor: Option<&TrackCursor>, + alias: &str, +) -> Result<QueryParts> { + let sort = effective_sort(descriptor); + if let Some(cursor) = cursor { + if cursor.sort != sort || cursor.descriptor_key != descriptor_key(descriptor) { + return Err(CatalogError::CursorDescriptorMismatch); + } + } + let mut predicates = Vec::new(); + let mut values = Vec::new(); + if descriptor.available_only() { + predicates.push(format!("{alias}.available = ?")); + values.push(Value::Integer(1)); + } + if !descriptor.sources().is_empty() { + let mut source_predicates = Vec::new(); + for source in descriptor.sources() { + if source.source_instance.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "source filter instance must not be empty".to_string(), + )); + } + source_predicates.push(format!( + "({alias}.source_kind = ? AND {alias}.source_instance = ?)" + )); + values.push(Value::Text(source.source.as_str().to_string())); + values.push(Value::Text(source.source_instance.clone())); + } + predicates.push(format!("({})", source_predicates.join(" OR "))); + } + if !descriptor.source_buckets().is_empty() { + let mut source_arms = Vec::new(); + for bucket in descriptor.source_buckets() { + source_arms.push(match bucket.as_str() { + // A scanned file is owned by the Local catalog source and is + // normally spelled `user` in source_raw. Qobuz downloads can + // share that ownership, so raw provenance must remove them + // from Local and expose them through the Offline chip instead. + "local" => format!( + "({alias}.source_kind='local' AND {alias}.source_raw NOT IN ('qobuz_download','qobuz_purchase'))" + ), + "offline" => format!( + "({alias}.source_kind='offline' OR {alias}.source_raw IN ('qobuz_download','qobuz_purchase'))" + ), + "plex" => format!("{alias}.source_kind='plex'"), + "jellyfin" => format!("{alias}.source_kind='jellyfin'"), + "subsonic" => format!("{alias}.source_kind='subsonic'"), + _ => { + return Err(CatalogError::InvalidInput(format!( + "unknown track source bucket {bucket}" + ))) + } + }); + } + predicates.push(format!("({})", source_arms.join(" OR "))); + } + if !descriptor.formats().is_empty() || descriptor.other_formats() { + let mut format_arms = Vec::new(); + if !descriptor.formats().is_empty() { + format_arms.push(format!( + "{alias}.format IN ({})", + std::iter::repeat("?") + .take(descriptor.formats().len()) + .collect::<Vec<_>>() + .join(",") + )); + values.extend(descriptor.formats().iter().cloned().map(Value::Text)); + } + if descriptor.other_formats() { + format_arms.push(format!( + "{alias}.format NOT IN ('flac','alac','ape','wav','wave','mp3','aac')" + )); + } + predicates.push(format!("({})", format_arms.join(" OR "))); + } + if !descriptor.quality_tiers().is_empty() { + // Keep this expression in lockstep with qbz-qt's `tier_of` and the + // albums_materialized quality_tier projection. Tracks deliberately do + // not materialize a second quality column just for this filter. + let tier = format!( + "CASE \ + WHEN LOWER({alias}.format)='mp3' THEN 'mp3' \ + WHEN LOWER({alias}.format) IN ('dsd','dsf','dff') THEN 'dsd' \ + WHEN {alias}.bit_depth>=24 AND {alias}.sample_rate_hz>96000 THEN 'max' \ + WHEN {alias}.bit_depth>=24 THEN 'hires' \ + WHEN {alias}.bit_depth IS NOT NULL THEN 'cd' \ + WHEN {alias}.sample_rate_hz>=44100 THEN 'cd' \ + ELSE '' END" + ); + let mut quality_arms = Vec::new(); + for quality in descriptor.quality_tiers() { + quality_arms.push(match quality.as_str() { + "dsd" => format!("({tier})='dsd'"), + "hires" => format!("({tier}) IN ('hires','max')"), + "cd" => format!("({tier})='cd'"), + "lossy" => format!("({tier}) IN ('mp3','lossy')"), + _ => { + return Err(CatalogError::InvalidInput(format!( + "unknown track quality tier {quality}" + ))) + } + }); + } + predicates.push(format!("({})", quality_arms.join(" OR "))); + } + + if let Some(cursor) = cursor { + let (predicate, cursor_values) = cursor_predicate(cursor, descriptor.group(), alias); + predicates.push(predicate); + values.extend(cursor_values); + } + if predicates.is_empty() { + predicates.push("1".to_string()); + } + Ok(QueryParts { + where_sql: predicates.join(" AND "), + params: values, + sort, + }) +} + +fn count_query(descriptor: &QueryDescriptor) -> Result<(String, Vec<Value>)> { + if descriptor.search().is_empty() { + let parts = filter_parts(descriptor, None, "t")?; + return Ok(( + format!("SELECT COUNT(*) FROM tracks t WHERE {}", parts.where_sql), + parts.params, + )); + } + let match_value = fts_match_value(descriptor.search())?; + let parts = filter_parts(descriptor, None, "t")?; + let sql = format!( + "SELECT COUNT(*) + FROM tracks_fts + CROSS JOIN tracks t NOT INDEXED + WHERE tracks_fts MATCH ? + AND t.catalog_id = tracks_fts.rowid + AND {}", + parts.where_sql + ); + let mut values = vec![match_value]; + values.extend(parts.params); + Ok((sql, values)) +} + +fn page_query( + descriptor: &QueryDescriptor, + cursor: Option<&TrackCursor>, + limit: usize, +) -> Result<(String, Vec<Value>, TrackSort)> { + if descriptor.search().is_empty() { + let parts = filter_parts(descriptor, cursor, "t")?; + let sql = format!( + "SELECT {TRACK_COLUMNS} + FROM tracks t + WHERE {} + ORDER BY {} + LIMIT {}", + parts.where_sql, + order_clause(parts.sort, descriptor.group(), "t"), + limit + 1 + ); + return Ok((sql, parts.params, parts.sort)); + } + + let match_value = fts_match_value(descriptor.search())?; + let parts = filter_parts(descriptor, cursor, "t")?; + let sql = format!( + "SELECT {TRACK_COLUMNS} + FROM tracks_fts + CROSS JOIN tracks t NOT INDEXED + WHERE tracks_fts MATCH ? + AND t.catalog_id = tracks_fts.rowid + AND {} + ORDER BY {} + LIMIT {}", + parts.where_sql, + order_clause(parts.sort, descriptor.group(), "t"), + limit + 1, + ); + let mut values = vec![match_value]; + values.extend(parts.params); + Ok((sql, values, parts.sort)) +} + +fn fts_match_value(search: &str) -> Result<Value> { + if search.chars().count() < 3 { + return Err(CatalogError::SearchTooShort); + } + let escaped = search.replace('"', "\"\""); + Ok(Value::Text(format!("\"{escaped}\""))) +} + +fn effective_sort(descriptor: &QueryDescriptor) -> TrackSort { + match descriptor.group() { + TrackGroup::Album => TrackSort::Default, + TrackGroup::Artist => TrackSort::ArtistAsc, + TrackGroup::Name => TrackSort::TitleAsc, + TrackGroup::Off => descriptor.sort(), + } +} + +fn order_clause(sort: TrackSort, group: TrackGroup, alias: &str) -> String { + let columns = if group == TrackGroup::Artist { + "sort_track_artist, sort_album, sort_title, catalog_id" + } else { + match sort { + TrackSort::Default => { + "sort_album, sort_artist, disc_sort, track_sort, sort_title, catalog_id" + } + TrackSort::TitleAsc => "sort_title, sort_artist, catalog_id", + TrackSort::TitleDesc => "sort_title DESC, sort_artist, catalog_id", + TrackSort::ArtistAsc => "sort_artist, sort_album, disc_sort, track_sort, catalog_id", + TrackSort::ArtistDesc => { + "sort_artist DESC, sort_album, disc_sort, track_sort, catalog_id" + } + TrackSort::YearAsc => { + "year_missing, year_value, sort_album, disc_sort, track_sort, catalog_id" + } + TrackSort::YearDesc => { + "year_missing, year_value DESC, sort_album, disc_sort, track_sort, catalog_id" + } + TrackSort::AddedDesc => "added_at DESC, sort_album, disc_sort, track_sort, catalog_id", + } + }; + columns + .split(", ") + .map(|column| { + let (name, suffix) = column.split_once(' ').unwrap_or((column, "")); + if suffix.is_empty() { + format!("{alias}.{name}") + } else { + format!("{alias}.{name} {suffix}") + } + }) + .collect::<Vec<_>>() + .join(", ") +} + +fn cursor_predicate(cursor: &TrackCursor, group: TrackGroup, alias: &str) -> (String, Vec<Value>) { + let text = |value: &str| Value::Text(value.to_string()); + let integer = Value::Integer; + let (predicate, values) = if group == TrackGroup::Artist { + ( + "(t.sort_track_artist,t.sort_album,t.sort_title,t.catalog_id) > (?,?,?,?)", + vec![ + text(&cursor.sort_track_artist), + text(&cursor.sort_album), + text(&cursor.sort_title), + integer(cursor.row_id), + ], + ) + } else { + match cursor.sort { + TrackSort::Default => ( + "(t.sort_album,t.sort_artist,t.disc_sort,t.track_sort,t.sort_title,t.catalog_id) > (?,?,?,?,?,?)", + vec![ + text(&cursor.sort_album), + text(&cursor.sort_artist), + integer(cursor.disc_sort), + integer(cursor.track_sort), + text(&cursor.sort_title), + integer(cursor.row_id), + ], + ), + TrackSort::TitleAsc => ( + "(t.sort_title,t.sort_artist,t.catalog_id) > (?,?,?)", + vec![ + text(&cursor.sort_title), + text(&cursor.sort_artist), + integer(cursor.row_id), + ], + ), + TrackSort::TitleDesc => ( + "(t.sort_title < ? OR (t.sort_title = ? AND (t.sort_artist,t.catalog_id) > (?,?)))", + vec![ + text(&cursor.sort_title), + text(&cursor.sort_title), + text(&cursor.sort_artist), + integer(cursor.row_id), + ], + ), + TrackSort::ArtistAsc => ( + "(t.sort_artist,t.sort_album,t.disc_sort,t.track_sort,t.catalog_id) > (?,?,?,?,?)", + vec![ + text(&cursor.sort_artist), + text(&cursor.sort_album), + integer(cursor.disc_sort), + integer(cursor.track_sort), + integer(cursor.row_id), + ], + ), + TrackSort::ArtistDesc => ( + "(t.sort_artist < ? OR (t.sort_artist = ? AND (t.sort_album,t.disc_sort,t.track_sort,t.catalog_id) > (?,?,?,?)))", + vec![ + text(&cursor.sort_artist), + text(&cursor.sort_artist), + text(&cursor.sort_album), + integer(cursor.disc_sort), + integer(cursor.track_sort), + integer(cursor.row_id), + ], + ), + TrackSort::YearAsc => ( + "(t.year_missing,t.year_value,t.sort_album,t.disc_sort,t.track_sort,t.catalog_id) > (?,?,?,?,?,?)", + vec![ + integer(cursor.year_missing), + integer(cursor.year_value), + text(&cursor.sort_album), + integer(cursor.disc_sort), + integer(cursor.track_sort), + integer(cursor.row_id), + ], + ), + TrackSort::YearDesc => ( + "(t.year_missing > ? OR (t.year_missing = ? AND (t.year_value < ? OR (t.year_value = ? AND (t.sort_album,t.disc_sort,t.track_sort,t.catalog_id) > (?,?,?,?)))))", + vec![ + integer(cursor.year_missing), + integer(cursor.year_missing), + integer(cursor.year_value), + integer(cursor.year_value), + text(&cursor.sort_album), + integer(cursor.disc_sort), + integer(cursor.track_sort), + integer(cursor.row_id), + ], + ), + TrackSort::AddedDesc => ( + "(t.added_at < ? OR (t.added_at = ? AND (t.sort_album,t.disc_sort,t.track_sort,t.catalog_id) > (?,?,?,?)))", + vec![ + integer(cursor.added_at), + integer(cursor.added_at), + text(&cursor.sort_album), + integer(cursor.disc_sort), + integer(cursor.track_sort), + integer(cursor.row_id), + ], + ), + } + }; + (predicate.replace("t.", &format!("{alias}.")), values) +} + +fn map_row(row: &Row<'_>) -> rusqlite::Result<RowWithCursor> { + let source_word: String = row.get(0)?; + let source = SourceKind::from_str(&source_word).expect("schema source CHECK"); + let sort_title = row.get(20)?; + let sort_artist = row.get(21)?; + let sort_track_artist = row.get(22)?; + let sort_album = row.get(23)?; + let year_missing = row.get(24)?; + let year_value = row.get(25)?; + let disc_sort = row.get(26)?; + let track_sort = row.get(27)?; + let added_at = row.get(28)?; + let row_id = row.get(29)?; + Ok(RowWithCursor { + record: TrackRecord { + track_ref: TrackRef { + source, + source_instance: row.get(1)?, + native_id: row.get(2)?, + }, + source_raw: row.get(3)?, + local_track_id: row.get(4)?, + local_path: row.get(5)?, + native_album_id: row.get::<_, Option<String>>(6)?, + title: row.get(7)?, + artist: row.get(8)?, + album_artist: row.get(9)?, + album: row.get(10)?, + duration_ms: row.get::<_, i64>(11)?.max(0) as u64, + year: row.get::<_, Option<i64>>(12)?.map(|value| value as u32), + disc_number: row.get::<_, Option<i64>>(13)?.map(|value| value as u32), + track_number: row.get::<_, Option<i64>>(14)?.map(|value| value as u32), + format: row.get(15)?, + bit_depth: row.get::<_, Option<i64>>(16)?.map(|value| value as u32), + sample_rate_hz: row.get::<_, Option<i64>>(17)?.map(|value| value as u32), + artwork_token: row.get(18)?, + available: row.get::<_, i64>(19)? != 0, + }, + cursor: TrackCursor { + // Filled by query_tracks_timed after the effective sort is known. + sort: TrackSort::Default, + descriptor_key: String::new(), + sort_title, + sort_artist, + sort_track_artist, + sort_album, + year_missing, + year_value, + disc_sort, + track_sort, + added_at, + row_id, + }, + }) +} + +const TRACK_COLUMNS: &str = " + t.source_kind, t.source_instance, t.native_track_id, t.source_raw, + t.local_track_id, t.local_path, + (SELECT NULLIF(sc.native_album_id,'') FROM source_copies sc + WHERE sc.source_copy_id=t.source_copy_id), + t.title, t.artist, t.album_artist, t.album, t.duration_ms, t.year, t.disc_number, + t.track_number, t.format, t.bit_depth, t.sample_rate_hz, t.artwork_token, t.available, + t.sort_title, t.sort_artist, t.sort_track_artist, t.sort_album, t.year_missing, + t.year_value, t.disc_sort, t.track_sort, t.added_at, t.catalog_id"; + +struct AlbumRowWithCursor { + record: AlbumRecord, + cursor: AlbumCursor, +} + +struct AlbumQueryParts { + from_sql: String, + where_sql: String, + params: Vec<Value>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AlbumOrderKind { + TitleInitial, + SortTitle, + SortArtist, + YearMissing, + YearValue, + AddedAt, +} + +struct AlbumOrderField { + kind: AlbumOrderKind, + expression: String, + descending: bool, +} + +impl AlbumOrderField { + fn cursor_value(&self, cursor: &AlbumCursor) -> Value { + match self.kind { + AlbumOrderKind::TitleInitial => { + Value::Text(cursor.sort_title.chars().next().unwrap_or('#').to_string()) + } + AlbumOrderKind::SortTitle => Value::Text(cursor.sort_title.clone()), + AlbumOrderKind::SortArtist => Value::Text(cursor.sort_artist.clone()), + AlbumOrderKind::YearMissing => Value::Integer(cursor.year_missing), + AlbumOrderKind::YearValue => Value::Integer(cursor.year_value), + AlbumOrderKind::AddedAt => Value::Integer(cursor.added_at), + } + } +} + +fn validate_albums_descriptor(descriptor: &QueryDescriptor) -> Result<()> { + if descriptor.surface() != QuerySurface::Albums { + return Err(CatalogError::InvalidInput( + "a non-Albums descriptor was passed to an Albums query".to_string(), + )); + } + Ok(()) +} + +fn album_filter_parts( + descriptor: &QueryDescriptor, + cursor: Option<&AlbumCursor>, +) -> Result<AlbumQueryParts> { + if let Some(cursor) = cursor { + if cursor.descriptor_key != descriptor_key(descriptor) { + return Err(CatalogError::CursorDescriptorMismatch); + } + } + let mut predicates = Vec::new(); + let mut params = Vec::new(); + let from_sql = if descriptor.search().is_empty() { + "FROM albums_materialized am".to_string() + } else { + params.push(fts_match_value(descriptor.search())?); + predicates.push("albums_fts MATCH ?".to_string()); + predicates.push("am.edition_id=albums_fts.rowid".to_string()); + "FROM albums_fts CROSS JOIN albums_materialized am NOT INDEXED".to_string() + }; + if descriptor.available_only() { + predicates.push("am.available=1".to_string()); + } + if !descriptor.sources().is_empty() { + let mut source = Vec::new(); + for key in descriptor.sources() { + if key.source_instance.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "source filter instance must not be empty".to_string(), + )); + } + source.push("(scf.source_kind=? AND scf.source_instance=?)"); + params.push(Value::Text(key.source.as_str().to_string())); + params.push(Value::Text(key.source_instance.clone())); + } + predicates.push(format!( + "EXISTS (SELECT 1 FROM source_copies scf WHERE scf.edition_id=am.edition_id AND ({}))", + source.join(" OR ") + )); + } + if !descriptor.formats().is_empty() || descriptor.other_formats() { + let mut format_arms = Vec::new(); + if !descriptor.formats().is_empty() { + format_arms.push(format!( + "am.format IN ({})", + std::iter::repeat("?") + .take(descriptor.formats().len()) + .collect::<Vec<_>>() + .join(",") + )); + } + if descriptor.other_formats() { + format_arms.push( + "am.format NOT IN ('flac','alac','ape','wav','wave','mp3','aac')".to_string(), + ); + } + predicates.push(format!("({})", format_arms.join(" OR "))); + params.extend(descriptor.formats().iter().cloned().map(Value::Text)); + } + if !descriptor.source_buckets().is_empty() { + let mut source_arms = Vec::new(); + for bucket in descriptor.source_buckets() { + source_arms.push(match bucket.as_str() { + "local" => "(am.source_kind='local' AND am.source_raw NOT IN ('qobuz_download','qobuz_purchase'))", + "offline" => "(am.source_kind='offline' OR am.source_raw IN ('qobuz_download','qobuz_purchase'))", + "plex" => "am.source_kind='plex'", + "jellyfin" => "am.source_kind='jellyfin'", + "subsonic" => "am.source_kind='subsonic'", + _ => { + return Err(CatalogError::InvalidInput(format!( + "unknown album source bucket {bucket}" + ))) + } + }); + } + predicates.push(format!("({})", source_arms.join(" OR "))); + } + if !descriptor.quality_tiers().is_empty() { + let mut quality = Vec::new(); + for tier in descriptor.quality_tiers() { + match tier.as_str() { + "dsd" => quality.push("am.quality_tier='dsd'"), + "hires" => quality.push("am.quality_tier IN ('hires','max')"), + "cd" => quality.push("am.quality_tier='cd'"), + "lossy" => quality.push("am.quality_tier IN ('mp3','lossy')"), + _ => { + return Err(CatalogError::InvalidInput(format!( + "unknown album quality tier {tier}" + ))) + } + } + } + predicates.push(format!("({})", quality.join(" OR "))); + } + if let Some(cursor) = cursor { + let (predicate, values) = album_cursor_predicate(descriptor, cursor); + predicates.push(predicate); + params.extend(values); + } + if predicates.is_empty() { + predicates.push("1".to_string()); + } + Ok(AlbumQueryParts { + from_sql, + where_sql: predicates.join(" AND "), + params, + }) +} + +fn album_group_expression(group: TrackGroup) -> &'static str { + match group { + TrackGroup::Artist => "am.sort_artist", + TrackGroup::Name | TrackGroup::Album => "substr(am.sort_title,1,1)", + TrackGroup::Off => "''", + } +} + +fn album_order_fields(descriptor: &QueryDescriptor, alias: &str) -> Vec<AlbumOrderField> { + let expression = |name: &str| format!("{alias}.{name}"); + let mut fields = Vec::new(); + match descriptor.group() { + TrackGroup::Artist => fields.push(AlbumOrderField { + kind: AlbumOrderKind::SortArtist, + expression: expression("sort_artist"), + descending: false, + }), + TrackGroup::Name | TrackGroup::Album => fields.push(AlbumOrderField { + kind: AlbumOrderKind::TitleInitial, + expression: format!("substr({alias}.sort_title,1,1)"), + descending: false, + }), + TrackGroup::Off => {} + } + let mut push = |kind: AlbumOrderKind, name: &str, descending: bool| { + if fields.iter().any(|field| field.kind == kind) { + return; + } + fields.push(AlbumOrderField { + kind, + expression: expression(name), + descending, + }); + }; + match descriptor.sort() { + TrackSort::Default | TrackSort::ArtistAsc => { + push(AlbumOrderKind::SortArtist, "sort_artist", false); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + TrackSort::ArtistDesc => { + push(AlbumOrderKind::SortArtist, "sort_artist", true); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + TrackSort::TitleAsc => { + push(AlbumOrderKind::SortTitle, "sort_title", false); + push(AlbumOrderKind::SortArtist, "sort_artist", false); + } + TrackSort::TitleDesc => { + push(AlbumOrderKind::SortTitle, "sort_title", true); + push(AlbumOrderKind::SortArtist, "sort_artist", false); + } + TrackSort::YearAsc => { + push(AlbumOrderKind::YearMissing, "year_missing", false); + push(AlbumOrderKind::YearValue, "year_value", false); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + TrackSort::YearDesc => { + push(AlbumOrderKind::YearMissing, "year_missing", false); + push(AlbumOrderKind::YearValue, "year_value", true); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + TrackSort::AddedDesc => { + push(AlbumOrderKind::AddedAt, "added_at", true); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + } + fields +} + +fn album_cursor_predicate( + descriptor: &QueryDescriptor, + cursor: &AlbumCursor, +) -> (String, Vec<Value>) { + fn build( + fields: &[AlbumOrderField], + cursor: &AlbumCursor, + index: usize, + params: &mut Vec<Value>, + ) -> String { + if index == fields.len() { + params.push(Value::Integer(cursor.edition_id)); + return "am.edition_id>?".to_string(); + } + let field = &fields[index]; + let comparison = if field.descending { "<" } else { ">" }; + params.push(field.cursor_value(cursor)); + params.push(field.cursor_value(cursor)); + let rest = build(fields, cursor, index + 1, params); + format!( + "({expr}{comparison}? OR ({expr}=? AND {rest}))", + expr = field.expression + ) + } + let fields = album_order_fields(descriptor, "am"); + let mut params = Vec::new(); + let predicate = build(&fields, cursor, 0, &mut params); + (predicate, params) +} + +fn map_album_row(row: &Row<'_>) -> rusqlite::Result<AlbumRowWithCursor> { + let source_word: String = row.get(1)?; + let source = SourceKind::from_str(&source_word).expect("schema source CHECK"); + Ok(AlbumRowWithCursor { + record: AlbumRecord { + edition_id: row.get(0)?, + source, + native_album_id: row.get(2)?, + source_raw: row.get(3)?, + source_words: row + .get::<_, String>(23)? + .split('\u{1f}') + .filter(|word| !word.is_empty()) + .map(str::to_string) + .collect(), + title: row.get(4)?, + artist: row.get(5)?, + all_artists: row.get(6)?, + year: row.get::<_, Option<i64>>(7)?.map(|value| value as u32), + track_count: row.get::<_, i64>(8)?.max(0) as u32, + total_duration_ms: row.get::<_, i64>(9)?.max(0) as u64, + quality_tier: row.get(10)?, + format: row.get(11)?, + bit_depth: row.get::<_, Option<i64>>(12)?.map(|value| value as u32), + sample_rate_hz: row.get::<_, Option<i64>>(13)?.map(|value| value as u32), + artwork_source: row.get(14)?, + artwork_token: row.get(15)?, + directory_path: row.get(16)?, + folder_count: row.get::<_, i64>(17)?.max(0) as u32, + added_at: row.get(18)?, + }, + cursor: AlbumCursor { + descriptor_key: String::new(), + sort_title: row.get(19)?, + sort_artist: row.get(20)?, + year_missing: row.get(21)?, + year_value: row.get(22)?, + added_at: row.get(18)?, + edition_id: row.get(0)?, + }, + }) +} + +const ALBUM_COLUMNS: &str = " + am.edition_id,am.source_kind,am.native_album_id,am.source_raw, + am.title,am.artist,am.all_artists,am.year,am.track_count,am.total_duration_ms, + am.quality_tier,am.format,am.bit_depth,am.sample_rate_hz, + am.artwork_source,am.artwork_token,am.directory_path,am.folder_count,am.added_at, + am.sort_title,am.sort_artist, + CASE WHEN am.year IS NULL THEN 1 ELSE 0 END,COALESCE(am.year,0), + COALESCE(( + SELECT group_concat(source_word,char(31)) + FROM ( + SELECT DISTINCT + CASE WHEN TRIM(t_sources.source_raw) != '' + THEN LOWER(TRIM(t_sources.source_raw)) + ELSE sc_sources.source_kind END AS source_word, + CASE sc_sources.source_kind + WHEN 'local' THEN 0 WHEN 'offline' THEN 1 + WHEN 'plex' THEN 2 WHEN 'jellyfin' THEN 3 ELSE 4 END + AS source_order + FROM source_copies sc_sources INDEXED BY idx_source_copies_edition + CROSS JOIN tracks t_sources INDEXED BY idx_tracks_source_copy + ON t_sources.source_copy_id=sc_sources.source_copy_id + WHERE sc_sources.edition_id=am.edition_id + AND t_sources.available=1 + ORDER BY source_order,source_word + ) + ),'')"; + +struct ArtistQueryParts { + from_sql: String, + where_sql: String, + params: Vec<Value>, + album_count_sql: String, + track_count_sql: String, + source_sql: String, +} + +fn validate_artists_descriptor(descriptor: &QueryDescriptor) -> Result<()> { + if descriptor.surface() != QuerySurface::Artists { + return Err(CatalogError::InvalidInput( + "a non-Artists descriptor was passed to an Artists query".to_string(), + )); + } + if !descriptor.formats().is_empty() + || descriptor.other_formats() + || !descriptor.quality_tiers().is_empty() + || !descriptor.source_buckets().is_empty() + || descriptor.group() != TrackGroup::Off + { + return Err(CatalogError::InvalidInput( + "Artists supports source instances and name search only".to_string(), + )); + } + match descriptor.sort() { + TrackSort::Default | TrackSort::ArtistAsc | TrackSort::ArtistDesc => Ok(()), + _ => Err(CatalogError::InvalidInput( + "Artists supports name ordering only".to_string(), + )), + } +} + +fn artist_descending(descriptor: &QueryDescriptor) -> bool { + descriptor.sort() == TrackSort::ArtistDesc +} + +fn artist_initial_expression(alias: &str) -> String { + format!( + "CASE WHEN UPPER(SUBSTR({alias}.sort_name,1,1)) BETWEEN 'A' AND 'Z' \ + THEN UPPER(SUBSTR({alias}.sort_name,1,1)) ELSE '#' END" + ) +} + +fn artist_filter_parts( + descriptor: &QueryDescriptor, + cursor: Option<&ArtistCursor>, +) -> Result<ArtistQueryParts> { + if let Some(cursor) = cursor { + if cursor.descriptor_key != descriptor_key(descriptor) { + return Err(CatalogError::CursorDescriptorMismatch); + } + } + let mut params = Vec::new(); + let (source_join, album_count_sql, track_count_sql, source_sql, source_filter) = + if descriptor.sources().is_empty() { + ( + "".to_string(), + "ar.album_count".to_string(), + "ar.track_count".to_string(), + "ar.source_kind".to_string(), + None, + ) + } else { + let mut source = Vec::new(); + for key in descriptor.sources() { + if key.source_instance.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "source filter instance must not be empty".to_string(), + )); + } + let first = params.len() + 1; + source.push(format!( + "(ass.source_kind=?{first} AND ass.source_instance=?{})", + first + 1 + )); + params.push(Value::Text(key.source.as_str().to_string())); + params.push(Value::Text(key.source_instance.clone())); + } + let clause = source.join(" OR "); + ( + "".to_string(), + format!( + "(SELECT COALESCE(SUM(ass.album_count),0) FROM artist_source_stats ass \ + WHERE ass.artist_key=ar.artist_key AND ass.available=1 AND ({clause}))" + ), + format!( + "(SELECT COALESCE(SUM(ass.track_count),0) FROM artist_source_stats ass \ + WHERE ass.artist_key=ar.artist_key AND ass.available=1 AND ({clause}))" + ), + format!( + "(SELECT CASE WHEN COUNT(DISTINCT ass.source_kind)>1 THEN 'mixed' \ + ELSE MIN(ass.source_kind) END FROM artist_source_stats ass \ + WHERE ass.artist_key=ar.artist_key AND ass.available=1 AND ({clause}))" + ), + Some(clause), + ) + }; + + let mut predicates = Vec::new(); + let mut search_join = String::new(); + if !descriptor.search().is_empty() { + let search_parameter = params.len() + 1; + if descriptor.search().chars().count() < 3 { + predicates.push(format!("INSTR(ar.sort_name,?{search_parameter})>0")); + params.push(Value::Text(normalize_artist_key(descriptor.search()))); + } else { + search_join = "JOIN artists_fts ON artists_fts.artist_key=ar.artist_key".to_string(); + predicates.push(format!("artists_fts MATCH ?{search_parameter}")); + params.push(fts_match_value(descriptor.search())?); + } + } + if descriptor.available_only() { + predicates.push("ar.available=1".to_string()); + } + if let Some(source) = source_filter { + predicates.push(format!( + "EXISTS (SELECT 1 FROM artist_source_stats ass \ + WHERE ass.artist_key=ar.artist_key AND ass.available=1 AND ({source}))" + )); + } + if let Some(cursor) = cursor { + let comparison = if artist_descending(descriptor) { + "<" + } else { + ">" + }; + predicates.push(format!( + "(ar.sort_name{comparison}? OR (ar.sort_name=? AND ar.artist_key{comparison}?))" + )); + params.push(Value::Text(cursor.sort_name.clone())); + params.push(Value::Text(cursor.sort_name.clone())); + params.push(Value::Text(cursor.artist_key.clone())); + } + if predicates.is_empty() { + predicates.push("1".to_string()); + } + Ok(ArtistQueryParts { + from_sql: format!("FROM artists_materialized ar {source_join} {search_join}"), + where_sql: predicates.join(" AND "), + params, + album_count_sql, + track_count_sql, + source_sql, + }) +} + +fn validate_artist_key(artist_key: &str) -> Result<()> { + if artist_key.trim().is_empty() || normalize_artist_key(artist_key) != artist_key { + return Err(CatalogError::InvalidInput( + "artist relationship requires a normalized artist key".to_string(), + )); + } + Ok(()) +} + +fn artist_relation_key(artist_key: &str, sources: &[SourceKey]) -> String { + let mut sources = sources.to_vec(); + sources.sort(); + sources.dedup(); + let mut key = String::from("artist-albums|"); + push_key_part(&mut key, artist_key); + for source in sources { + push_key_part(&mut key, source.source.as_str()); + push_key_part(&mut key, &source.source_instance); + } + key +} + +fn artist_album_filter( + artist_key: &str, + sources: &[SourceKey], + cursor: Option<&AlbumCursor>, +) -> Result<(String, Vec<Value>)> { + let relation_key = artist_relation_key(artist_key, sources); + if let Some(cursor) = cursor { + if cursor.descriptor_key != relation_key { + return Err(CatalogError::CursorDescriptorMismatch); + } + } + let mut predicates = vec![ + "am.available=1".to_string(), + "EXISTS (SELECT 1 FROM edition_artists ea \ + WHERE ea.edition_id=am.edition_id AND ea.artist_key=?)" + .to_string(), + ]; + let mut values = vec![Value::Text(artist_key.to_string())]; + if !sources.is_empty() { + let mut source = Vec::new(); + for key in sources { + if key.source_instance.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "source filter instance must not be empty".to_string(), + )); + } + source.push("(scf.source_kind=? AND scf.source_instance=?)"); + values.push(Value::Text(key.source.as_str().to_string())); + values.push(Value::Text(key.source_instance.clone())); + } + predicates.push(format!( + "EXISTS (SELECT 1 FROM source_copies scf WHERE scf.edition_id=am.edition_id AND ({}))", + source.join(" OR ") + )); + } + if let Some(cursor) = cursor { + predicates.push("(am.sort_title>? OR (am.sort_title=? AND am.edition_id>?))".to_string()); + values.push(Value::Text(cursor.sort_title.clone())); + values.push(Value::Text(cursor.sort_title.clone())); + values.push(Value::Integer(cursor.edition_id)); + } + Ok((predicates.join(" AND "), values)) +} + +/// Unicode-aware display sort key shared by ingest and query indices. +pub fn normalize_sort_key(value: &str) -> String { + value + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") + .to_lowercase() +} + +/// Stable artist key: lowercased, common Latin diacritics folded, and runs of +/// punctuation collapsed while preserving non-Latin alphanumerics. +pub fn normalize_artist_key(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut pending_space = false; + for character in value.to_lowercase().chars() { + let folded = fold_diacritic(character); + if folded.is_alphanumeric() { + if pending_space && !out.is_empty() { + out.push(' '); + } + out.push(folded); + pending_space = false; + } else { + pending_space = true; + } + } + out +} + +fn fold_diacritic(character: char) -> char { + match character { + 'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' | 'ā' => 'a', + 'é' | 'è' | 'ê' | 'ë' | 'ē' | 'ė' => 'e', + 'í' | 'ì' | 'î' | 'ï' | 'ī' => 'i', + 'ó' | 'ò' | 'ô' | 'ö' | 'õ' | 'ō' | 'ø' => 'o', + 'ú' | 'ù' | 'û' | 'ü' | 'ū' => 'u', + 'ñ' => 'n', + 'ç' => 'c', + 'ý' | 'ÿ' => 'y', + 'ß' => 's', + _ => character, + } +} + +pub(crate) fn descriptor_key(descriptor: &QueryDescriptor) -> String { + let mut key = String::new(); + push_key_part( + &mut key, + match descriptor.surface() { + crate::QuerySurface::Tracks => "tracks", + crate::QuerySurface::Albums => "albums", + crate::QuerySurface::Artists => "artists", + }, + ); + push_key_part(&mut key, descriptor.search()); + push_key_part( + &mut key, + if descriptor.available_only() { + "1" + } else { + "0" + }, + ); + push_key_part(&mut key, sort_word(effective_sort(descriptor))); + push_key_part( + &mut key, + match descriptor.group() { + TrackGroup::Off => "off", + TrackGroup::Album => "album", + TrackGroup::Artist => "artist", + TrackGroup::Name => "name", + }, + ); + for source in descriptor.sources() { + push_key_part(&mut key, source.source.as_str()); + push_key_part(&mut key, &source.source_instance); + } + for bucket in descriptor.source_buckets() { + push_key_part(&mut key, bucket); + } + for format in descriptor.formats() { + push_key_part(&mut key, format); + } + push_key_part( + &mut key, + if descriptor.other_formats() { + "other" + } else { + "" + }, + ); + for tier in descriptor.quality_tiers() { + push_key_part(&mut key, tier); + } + key +} + +fn push_key_part(key: &mut String, value: &str) { + key.push_str(&value.len().to_string()); + key.push(':'); + key.push_str(value); + key.push('|'); +} + +fn sort_word(sort: TrackSort) -> &'static str { + match sort { + TrackSort::Default => "default", + TrackSort::TitleAsc => "title-asc", + TrackSort::TitleDesc => "title-desc", + TrackSort::ArtistAsc => "artist-asc", + TrackSort::ArtistDesc => "artist-desc", + TrackSort::YearAsc => "year-asc", + TrackSort::YearDesc => "year-desc", + TrackSort::AddedDesc => "added-desc", + } +} + +fn now_unix_seconds() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64) + .unwrap_or(0) +} diff --git a/crates/qbz-local-catalog/src/legacy.rs b/crates/qbz-local-catalog/src/legacy.rs new file mode 100644 index 000000000..c997f35b7 --- /dev/null +++ b/crates/qbz-local-catalog/src/legacy.rs @@ -0,0 +1,1167 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use rusqlite::types::ValueRef; +use rusqlite::{params, Connection, OpenFlags, Row}; +use serde::Deserialize; + +use crate::{ + ActiveCatalog, ArtistCredit, BootstrapBatch, BootstrapLayout, BootstrapOutcome, + BootstrapProgress, CatalogError, CreditRole, ProjectedTrack, Result, SourceKey, SourceKind, + SourceProbe, TrackRef, BOOTSTRAP_BATCH_ROWS, +}; +use crate::{ProjectionOutcome, ProjectionProgress, ReconciliationBatch}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LegacyTable { + Local, + Plex, + Remote, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegacySourceSpec { + pub source: SourceKey, + pub database_path: PathBuf, + table: LegacyTable, +} + +/// Physical locations of the authoritative caches for one user. Qt keeps the +/// local and remote mirrors in the user's directory while the existing Plex +/// cache is installation-wide, so assuming co-location would silently build +/// an empty catalog for real profiles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegacyLocations { + pub catalog_dir: PathBuf, + pub local_database: PathBuf, + pub plex_database: PathBuf, + pub remote_database: PathBuf, + /// Sources authorized for this user right now. Disabled or incomplete + /// integrations are neither opened nor retained in the derived catalog. + pub enabled_sources: BTreeSet<SourceKind>, +} + +impl LegacyLocations { + pub fn co_located(data_dir: impl Into<PathBuf>) -> Self { + let catalog_dir = data_dir.into(); + Self { + local_database: catalog_dir.join("library.db"), + plex_database: catalog_dir.join("plex_cache.db"), + remote_database: catalog_dir.join("remote_cache.db"), + enabled_sources: SourceKind::ALL.into_iter().collect(), + catalog_dir, + } + } +} + +pub fn discover_legacy_sources(data_dir: &Path) -> Result<Vec<LegacySourceSpec>> { + discover_legacy_sources_at(&LegacyLocations::co_located(data_dir)) +} + +pub fn discover_legacy_sources_at(locations: &LegacyLocations) -> Result<Vec<LegacySourceSpec>> { + let mut specs = Vec::new(); + if locations.enabled_sources.contains(&SourceKind::Local) + || locations.enabled_sources.contains(&SourceKind::Offline) + { + discover_local(&locations.local_database, &mut specs)?; + } + if locations.enabled_sources.contains(&SourceKind::Plex) { + discover_plex(&locations.plex_database, &mut specs)?; + } + if locations.enabled_sources.contains(&SourceKind::Jellyfin) + || locations.enabled_sources.contains(&SourceKind::Subsonic) + { + discover_remote(&locations.remote_database, &mut specs)?; + specs.retain(|spec| locations.enabled_sources.contains(&spec.source.source)); + } + specs.sort_by(|left, right| left.source.cmp(&right.source)); + specs.dedup_by(|left, right| left.source == right.source); + Ok(specs) +} + +pub fn bootstrap_legacy_caches( + data_dir: &Path, + cancelled: &AtomicBool, +) -> Result<BootstrapOutcome> { + bootstrap_legacy_caches_with_progress(data_dir, cancelled, |_| {}) +} + +pub fn bootstrap_legacy_caches_with_progress( + data_dir: &Path, + cancelled: &AtomicBool, + publish: impl FnMut(&BootstrapProgress), +) -> Result<BootstrapOutcome> { + bootstrap_legacy_caches_at_with_progress( + &LegacyLocations::co_located(data_dir), + cancelled, + publish, + ) +} + +pub fn bootstrap_legacy_caches_at_with_progress( + locations: &LegacyLocations, + cancelled: &AtomicBool, + mut publish: impl FnMut(&BootstrapProgress), +) -> Result<BootstrapOutcome> { + let layout = BootstrapLayout::new(&locations.catalog_dir); + if let ActiveCatalog::Ready { catalog, manifest } = layout.open_active() { + // Also performs the one-time migration for profiles created by builds + // that published generations but never removed obsolete ones. + layout.prune_obsolete_generations(&manifest); + return Ok(BootstrapOutcome::Activated { + generation: manifest.active_generation, + track_count: catalog.stats()?.track_count, + resumed_rows: 0, + }); + } + + let specs = discover_legacy_sources_at(locations)?; + let sidecars = locations.catalog_dir.join("metadata_sidecars.db"); + let mut readers = specs + .into_iter() + .map(|spec| LegacyReader::open(spec, &sidecars)) + .collect::<Result<Vec<_>>>()?; + let probes = readers + .iter() + .map(|reader| reader.probe.clone()) + .collect::<Vec<_>>(); + let (mut session, _) = layout.prepare(&probes, None)?; + let mut resumed_rows = 0_u64; + let mut committed_rows = 0_u64; + let overall_rows_total = probes.iter().map(|probe| probe.row_count).sum::<u64>(); + let source_count = readers.len(); + let mut overall_rows_before_source = 0_u64; + + for (source_offset, reader) in readers.iter_mut().enumerate() { + let mut checkpoint = session.checkpoint(&reader.spec.source)?; + if let Some(saved) = &checkpoint { + if saved.checkpoint_version != reader.probe.snapshot_version + || saved.checkpoint_rows > reader.probe.row_count + { + session + .restart_changed_source(&reader.spec.source, &reader.probe.snapshot_version)?; + checkpoint = None; + } else { + resumed_rows = resumed_rows.saturating_add(saved.checkpoint_rows); + } + } + if checkpoint + .as_ref() + .is_some_and(|saved| saved.complete && saved.checkpoint_rows == reader.probe.row_count) + { + overall_rows_before_source = + overall_rows_before_source.saturating_add(reader.probe.row_count); + continue; + } + + let mut cursor = checkpoint + .map(|saved| saved.checkpoint_cursor) + .unwrap_or_default(); + loop { + if cancelled.load(Ordering::Acquire) { + return Ok(BootstrapOutcome::Paused { + generation: session.generation(), + source: Some(reader.spec.source.clone()), + committed_rows, + }); + } + let batch = reader.read_batch(&cursor)?; + let saved = session.apply_batch(&batch)?; + committed_rows = committed_rows.saturating_add(batch.tracks.len() as u64); + cursor = saved.checkpoint_cursor; + publish(&BootstrapProgress { + generation: session.generation(), + source: reader.spec.source.clone(), + committed_rows: saved.checkpoint_rows, + source_rows_total: reader.probe.row_count, + overall_rows_written: overall_rows_before_source + .saturating_add(saved.checkpoint_rows.min(reader.probe.row_count)), + overall_rows_total, + source_index: source_offset + 1, + source_count, + checkpoint_cursor: cursor.clone(), + source_complete: saved.complete, + }); + if saved.complete { + break; + } + std::thread::yield_now(); + std::thread::sleep(Duration::from_millis(1)); + } + overall_rows_before_source = + overall_rows_before_source.saturating_add(reader.probe.row_count); + } + + if cancelled.load(Ordering::Acquire) { + return Ok(BootstrapOutcome::Paused { + generation: session.generation(), + source: None, + committed_rows, + }); + } + let stats = session.stats()?; + let manifest = session.activate(&probes)?; + Ok(BootstrapOutcome::Activated { + generation: manifest.active_generation, + track_count: stats.track_count, + resumed_rows, + }) +} + +pub fn reconcile_legacy_caches( + data_dir: &Path, + cancelled: &AtomicBool, +) -> Result<ProjectionOutcome> { + reconcile_legacy_caches_with_progress(data_dir, cancelled, |_| {}) +} + +pub fn reconcile_legacy_caches_with_progress( + data_dir: &Path, + cancelled: &AtomicBool, + publish: impl FnMut(&ProjectionProgress), +) -> Result<ProjectionOutcome> { + reconcile_legacy_caches_at_with_progress( + &LegacyLocations::co_located(data_dir), + cancelled, + publish, + ) +} + +pub fn reconcile_legacy_caches_at_with_progress( + locations: &LegacyLocations, + cancelled: &AtomicBool, + mut publish: impl FnMut(&ProjectionProgress), +) -> Result<ProjectionOutcome> { + let layout = BootstrapLayout::new(&locations.catalog_dir); + let (active, active_generation) = match layout.open_active() { + ActiveCatalog::Ready { catalog, manifest } => (catalog, manifest.active_generation), + ActiveCatalog::Fallback(reason) => { + return Err(CatalogError::ActivationNotReady(format!( + "reconciliation fallback: {reason:?}" + ))) + } + }; + let active_sources = active.stats()?.source_counts; + let specs = discover_legacy_sources_at(locations)?; + let sidecars = locations.catalog_dir.join("metadata_sidecars.db"); + let mut readers = specs + .into_iter() + .map(|spec| LegacyReader::open(spec, &sidecars)) + .collect::<Result<Vec<_>>>()?; + let mut probes = readers + .iter() + .map(|reader| reader.probe.clone()) + .collect::<Vec<_>>(); + let disabled_probes = active_sources + .into_iter() + .filter(|(source, rows)| { + *rows > 0 && !locations.enabled_sources.contains(&source.source) + }) + .map(|(source, _)| SourceProbe { + source: source.clone(), + source_path: match source.source { + SourceKind::Local | SourceKind::Offline => locations.local_database.clone(), + SourceKind::Plex => locations.plex_database.clone(), + SourceKind::Jellyfin | SourceKind::Subsonic => locations.remote_database.clone(), + }, + snapshot_version: "disabled:v1".to_string(), + row_count: 0, + page_bytes: 0, + integrity_ok: true, + }) + .collect::<Vec<_>>(); + probes.extend(disabled_probes.iter().cloned()); + let mut changed_keys = readers + .iter() + .filter_map(|reader| { + let checkpoint = active.source_checkpoint(&reader.spec.source).ok().flatten(); + (!checkpoint.is_some_and(|checkpoint| { + checkpoint.complete_generation > 0 + && checkpoint.checkpoint_version == reader.probe.snapshot_version + && checkpoint.checkpoint_rows == reader.probe.row_count + })) + .then(|| reader.spec.source.clone()) + }) + .collect::<HashSet<_>>(); + changed_keys.extend(disabled_probes.iter().map(|probe| probe.source.clone())); + if changed_keys.is_empty() { + return Ok(ProjectionOutcome::UpToDate { + generation: active_generation, + track_count: active.stats()?.track_count, + }); + } + drop(active); + + let changed_probes = probes + .iter() + .filter(|probe| changed_keys.contains(&probe.source)) + .cloned() + .collect::<Vec<_>>(); + let (mut session, _) = layout.prepare_projection(&probes, None)?; + let mut resumed_rows = 0_u64; + let mut committed_rows = 0_u64; + let overall_rows_total = changed_probes + .iter() + .map(|probe| probe.row_count) + .sum::<u64>(); + let source_count = changed_probes.len(); + let mut overall_rows_before_source = 0_u64; + let changed_reader_count = readers + .iter() + .filter(|reader| changed_keys.contains(&reader.spec.source)) + .count(); + for (source_offset, reader) in readers + .iter_mut() + .filter(|reader| changed_keys.contains(&reader.spec.source)) + .enumerate() + { + let checkpoint = session.checkpoint(&reader.spec.source)?; + let mut cursor = if let Some(checkpoint) = checkpoint { + if checkpoint.complete + && checkpoint.checkpoint_version == reader.probe.snapshot_version + && checkpoint.checkpoint_rows == reader.probe.row_count + { + overall_rows_before_source = + overall_rows_before_source.saturating_add(reader.probe.row_count); + continue; + } else if checkpoint.checkpoint_version == reader.probe.snapshot_version + && !checkpoint.complete + && checkpoint.checkpoint_rows <= reader.probe.row_count + { + resumed_rows = resumed_rows.saturating_add(checkpoint.checkpoint_rows); + checkpoint.checkpoint_cursor + } else { + session.begin_source(&reader.spec.source, &reader.probe.snapshot_version)?; + String::new() + } + } else { + session.begin_source(&reader.spec.source, &reader.probe.snapshot_version)?; + String::new() + }; + loop { + if cancelled.load(Ordering::Acquire) { + return Ok(ProjectionOutcome::Paused { + generation: session.generation(), + source: reader.spec.source.clone(), + committed_rows, + }); + } + let batch = reader.read_batch(&cursor)?; + let projection_batch = ReconciliationBatch { + source: batch.source, + snapshot_version: batch.snapshot_version, + expected_cursor: batch.expected_cursor, + next_cursor: batch.next_cursor, + tracks: batch.tracks, + complete: batch.complete, + }; + let saved = session.apply_batch(&projection_batch)?; + committed_rows = committed_rows.saturating_add(projection_batch.tracks.len() as u64); + cursor = saved.checkpoint_cursor; + publish(&ProjectionProgress { + generation: session.generation(), + source: reader.spec.source.clone(), + rows_written: saved.checkpoint_rows, + source_rows_total: reader.probe.row_count, + overall_rows_written: overall_rows_before_source + .saturating_add(saved.checkpoint_rows.min(reader.probe.row_count)), + overall_rows_total, + source_index: source_offset + 1, + source_count, + checkpoint_cursor: cursor.clone(), + source_complete: saved.complete, + prune_authorized: saved.complete, + }); + if saved.complete { + break; + } + std::thread::yield_now(); + std::thread::sleep(Duration::from_millis(1)); + } + overall_rows_before_source = + overall_rows_before_source.saturating_add(reader.probe.row_count); + } + for (disabled_offset, probe) in disabled_probes.iter().enumerate() { + if cancelled.load(Ordering::Acquire) { + return Ok(ProjectionOutcome::Paused { + generation: session.generation(), + source: probe.source.clone(), + committed_rows, + }); + } + session.begin_source(&probe.source, &probe.snapshot_version)?; + let saved = session.apply_batch(&ReconciliationBatch { + source: probe.source.clone(), + snapshot_version: probe.snapshot_version.clone(), + expected_cursor: String::new(), + next_cursor: String::new(), + tracks: Vec::new(), + complete: true, + })?; + publish(&ProjectionProgress { + generation: session.generation(), + source: probe.source.clone(), + rows_written: 0, + source_rows_total: 0, + overall_rows_written: overall_rows_before_source, + overall_rows_total, + source_index: changed_reader_count + disabled_offset + 1, + source_count, + checkpoint_cursor: saved.checkpoint_cursor, + source_complete: true, + prune_authorized: true, + }); + } + let stats = session.stats()?; + let manifest = session.activate(&changed_probes)?; + Ok(ProjectionOutcome::Activated { + generation: manifest.active_generation, + track_count: stats.track_count, + changed_sources: changed_probes.len(), + resumed_rows, + }) +} + +fn discover_local(path: &Path, specs: &mut Vec<LegacySourceSpec>) -> Result<()> { + let Some(conn) = open_if_present(path)? else { + return Ok(()); + }; + if table_exists(&conn, "local_tracks")? { + specs.push(LegacySourceSpec { + source: SourceKey { + source: SourceKind::Local, + source_instance: "library".to_string(), + }, + database_path: path.to_path_buf(), + table: LegacyTable::Local, + }); + } + Ok(()) +} + +fn discover_plex(path: &Path, specs: &mut Vec<LegacySourceSpec>) -> Result<()> { + let Some(conn) = open_if_present(path)? else { + return Ok(()); + }; + if !table_exists(&conn, "plex_cache_tracks")? { + return Ok(()); + } + let mut instances = HashSet::new(); + let mut stmt = conn.prepare( + "SELECT DISTINCT COALESCE(NULLIF(server_id,''),'default') + FROM plex_cache_tracks", + )?; + for value in stmt.query_map([], |row| row.get::<_, String>(0))? { + instances.insert(value?); + } + if table_exists(&conn, "plex_cache_sections")? { + let mut stmt = conn.prepare( + "SELECT DISTINCT COALESCE(NULLIF(server_id,''),'default') + FROM plex_cache_sections", + )?; + for value in stmt.query_map([], |row| row.get::<_, String>(0))? { + instances.insert(value?); + } + } + if instances.is_empty() { + instances.insert("default".to_string()); + } + for source_instance in instances { + specs.push(LegacySourceSpec { + source: SourceKey { + source: SourceKind::Plex, + source_instance, + }, + database_path: path.to_path_buf(), + table: LegacyTable::Plex, + }); + } + Ok(()) +} + +fn discover_remote(path: &Path, specs: &mut Vec<LegacySourceSpec>) -> Result<()> { + let Some(conn) = open_if_present(path)? else { + return Ok(()); + }; + if !table_exists(&conn, "remote_cache_tracks")? { + return Ok(()); + } + let mut keys = HashSet::new(); + let mut stmt = conn.prepare( + "SELECT DISTINCT source, COALESCE(NULLIF(server_id,''),'default') + FROM remote_cache_tracks", + )?; + for value in stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? { + keys.insert(value?); + } + if table_exists(&conn, "remote_cache_libraries")? { + let mut stmt = conn.prepare( + "SELECT DISTINCT source, COALESCE(NULLIF(server_id,''),'default') + FROM remote_cache_libraries", + )?; + for value in stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? { + keys.insert(value?); + } + } + for (word, source_instance) in keys { + let Some(source) = SourceKind::from_str(&word) else { + continue; + }; + if !matches!(source, SourceKind::Jellyfin | SourceKind::Subsonic) { + continue; + } + specs.push(LegacySourceSpec { + source: SourceKey { + source, + source_instance, + }, + database_path: path.to_path_buf(), + table: LegacyTable::Remote, + }); + } + Ok(()) +} + +struct LegacyReader { + spec: LegacySourceSpec, + conn: Connection, + probe: SourceProbe, + overrides: RemoteOverrides, +} + +impl LegacyReader { + fn open(spec: LegacySourceSpec, sidecar_path: &Path) -> Result<Self> { + let conn = open_read_only(&spec.database_path)?; + conn.execute_batch("PRAGMA query_only=ON; BEGIN;")?; + let quick_check: String = conn.query_row("PRAGMA quick_check", [], |row| row.get(0))?; + let page_size: i64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?; + let page_count: i64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?; + let schema_version: i64 = conn.query_row("PRAGMA schema_version", [], |row| row.get(0))?; + let (row_count, maximum_id, maximum_updated) = source_summary(&conn, &spec)?; + let file_epoch = sqlite_file_epoch(&spec.database_path)?; + let overrides = RemoteOverrides::load(sidecar_path, &spec.source)?; + let snapshot_version = format!( + "v2:{schema_version}:{page_count}:{row_count}:{maximum_id}:{maximum_updated}:{file_epoch}:{}", + overrides.revision + ); + let probe = SourceProbe { + source: spec.source.clone(), + source_path: spec.database_path.clone(), + snapshot_version, + row_count, + page_bytes: (page_size.max(0) as u64).saturating_mul(page_count.max(0) as u64), + integrity_ok: quick_check == "ok", + }; + Ok(Self { + spec, + conn, + probe, + overrides, + }) + } + + fn read_batch(&self, cursor: &str) -> Result<BootstrapBatch> { + let after = if cursor.is_empty() { + 0 + } else { + cursor.parse::<i64>().map_err(|_| { + CatalogError::InvalidInput(format!("invalid bootstrap cursor {cursor:?}")) + })? + }; + let mut tracks = match self.spec.table { + LegacyTable::Local => read_local(&self.conn, &self.spec, after)?, + LegacyTable::Plex => read_plex(&self.conn, &self.spec, after)?, + LegacyTable::Remote => read_remote(&self.conn, &self.spec, after)?, + }; + for (_, track) in &mut tracks { + self.overrides.apply(track); + } + let complete = tracks.len() <= BOOTSTRAP_BATCH_ROWS; + tracks.truncate(BOOTSTRAP_BATCH_ROWS); + let next_cursor = tracks + .last() + .map(|track| legacy_cursor(track).to_string()) + .unwrap_or_else(|| cursor.to_string()); + Ok(BootstrapBatch { + source: self.spec.source.clone(), + snapshot_version: self.probe.snapshot_version.clone(), + expected_cursor: cursor.to_string(), + next_cursor, + tracks: tracks.into_iter().map(|(_, track)| track).collect(), + complete, + }) + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SidecarAlbum { + album_title: Option<String>, + album_artist: Option<String>, + year: Option<u32>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SidecarTrack { + file_path: String, + title: Option<String>, + disc_number: Option<u32>, + track_number: Option<u32>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SidecarExtendedAlbum { + artwork_path: Option<String>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SidecarExtendedTrack { + file_path: String, + artist_credit: String, + musicbrainz_recording_id: Option<String>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SidecarDocument { + #[serde(default)] + album: SidecarAlbum, + #[serde(default)] + tracks: Vec<SidecarTrack>, + extended_album: Option<SidecarExtendedAlbum>, + #[serde(default)] + extended_tracks: Vec<SidecarExtendedTrack>, +} + +#[derive(Default)] +struct RemoteOverrides { + by_album: HashMap<String, SidecarDocument>, + revision: String, +} + +impl RemoteOverrides { + fn load(path: &Path, source: &SourceKey) -> Result<Self> { + if matches!(source.source, SourceKind::Local | SourceKind::Offline) || !path.is_file() { + return Ok(Self::default()); + } + let conn = open_read_only(path)?; + if !table_exists(&conn, "remote_album_tag_sidecars")? { + return Ok(Self::default()); + } + let mut stmt = conn.prepare( + "SELECT album_id,payload_json,updated_at + FROM remote_album_tag_sidecars + WHERE source=?1 AND source_instance=?2", + )?; + let rows = stmt.query_map( + params![source.source.as_str(), source.source_instance], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }, + )?; + let mut by_album = HashMap::new(); + let mut count = 0_u64; + let mut maximum_updated = 0_i64; + for row in rows { + let (album_id, json, updated_at) = row?; + count = count.saturating_add(1); + maximum_updated = maximum_updated.max(updated_at); + if let Ok(document) = serde_json::from_str(&json) { + by_album.insert(album_id, document); + } + } + let file_epoch = sqlite_file_epoch(path)?; + Ok(Self { + by_album, + revision: format!("{count}:{maximum_updated}:{file_epoch}"), + }) + } + + fn apply(&self, track: &mut ProjectedTrack) { + let Some(album_id) = track.native_album_id.as_deref() else { + return; + }; + let Some(sidecar) = self.by_album.get(album_id) else { + return; + }; + if let Some(title) = sidecar + .album + .album_title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + { + track.album = title.to_string(); + } + if let Some(artist) = sidecar.album.album_artist.as_deref() { + track.album_artist = artist.trim().to_string(); + } + if let Some(year) = sidecar.album.year { + track.year = (year != 0).then_some(year); + } + if let Some(path) = sidecar + .extended_album + .as_ref() + .and_then(|album| album.artwork_path.as_deref()) + .map(str::trim) + .filter(|path| !path.is_empty()) + { + track.artwork_token = Some(format!("{}{path}", crate::SIDECAR_LOCAL_ART_PREFIX)); + } + if let Some(row) = sidecar + .tracks + .iter() + .find(|row| row.file_path == track.track_ref.native_id) + { + if let Some(title) = row + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + { + track.title = title.to_string(); + } + if let Some(number) = row.disc_number { + track.disc_number = (number != 0).then_some(number); + } + if let Some(number) = row.track_number { + track.track_number = (number != 0).then_some(number); + } + } + if let Some(row) = sidecar + .extended_tracks + .iter() + .find(|row| row.file_path == track.track_ref.native_id) + { + if !row.artist_credit.trim().is_empty() { + track.artist = row.artist_credit.trim().to_string(); + } + track.musicbrainz_recording_id = row.musicbrainz_recording_id.clone(); + } + track.credits = credits(&track.artist, &track.album_artist); + } +} + +type LegacyRow = (i64, ProjectedTrack); + +/// An identity column is either an id or nothing — an empty string must not +/// become a key that joins every untagged row to every other one. +fn nonempty(value: Option<String>) -> Option<String> { + value.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +fn legacy_cursor(row: &LegacyRow) -> i64 { + row.0 +} + +fn read_local(conn: &Connection, spec: &LegacySourceSpec, after: i64) -> Result<Vec<LegacyRow>> { + let columns = table_columns(conn, "local_tracks")?; + let album_id = optional_column(&columns, "album_group_key", "NULL"); + let source_raw = optional_column(&columns, "source", "''"); + // Identity columns post-date the library schema; NULL on a db that has + // not migrated yet (the upsert already binds both). + let isrc = optional_column(&columns, "isrc", "NULL"); + let mbid = optional_column(&columns, "musicbrainz_recording_id", "NULL"); + let sql = format!( + "SELECT id, file_path, title, artist, COALESCE(album_artist,''), album, + duration_secs, year, disc_number, track_number, format, bit_depth, + CAST(sample_rate AS INTEGER), artwork_path, indexed_at, {album_id}, + COALESCE({source_raw},''), {isrc}, {mbid} + FROM local_tracks + WHERE id > ?1 + ORDER BY id + LIMIT ?2" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![after, BOOTSTRAP_BATCH_ROWS as i64 + 1], |row| { + let id: i64 = row.get(0)?; + let artist: String = row.get(3)?; + let album_artist: String = row.get(4)?; + let credits = credits(&artist, &album_artist); + Ok(( + id, + ProjectedTrack { + track_ref: TrackRef { + source: SourceKind::Local, + source_instance: spec.source.source_instance.clone(), + native_id: id.to_string(), + }, + source_raw: row.get(16)?, + local_track_id: Some(id), + local_path: row.get(1)?, + native_album_id: row.get(15)?, + source_copy_id: None, + title: row.get(2)?, + artist, + album_artist, + album: row.get(5)?, + duration_ms: nonnegative(row.get::<_, Option<i64>>(6)?.unwrap_or(0)) + .saturating_mul(1_000), + year: optional_u32(row, 7)?, + disc_number: optional_u32(row, 8)?, + track_number: optional_u32(row, 9)?, + format: row.get::<_, String>(10)?.to_ascii_lowercase(), + bit_depth: optional_u32(row, 11)?, + sample_rate_hz: optional_u32(row, 12)?, + artwork_token: row.get(13)?, + isrc: nonempty(row.get(17)?), + musicbrainz_recording_id: nonempty(row.get(18)?), + added_at: row.get::<_, Option<i64>>(14)?.unwrap_or(0), + available: true, + observed_generation: 0, + credits, + }, + )) + })?; + rows.collect::<rusqlite::Result<Vec<_>>>() + .map_err(Into::into) +} + +fn read_plex(conn: &Connection, spec: &LegacySourceSpec, after: i64) -> Result<Vec<LegacyRow>> { + let columns = table_columns(conn, "plex_cache_tracks")?; + require_columns( + &columns, + &["rating_key", "title", "artist", "album", "duration_ms"], + "plex_cache_tracks", + )?; + let item_art = optional_column(&columns, "artwork_path", "NULL"); + let collection_art = optional_column(&columns, "collection_artwork_path", "NULL"); + let metadata_art = if columns.contains("parent_rating_key") + && table_exists(conn, "plex_cache_album_artwork")? + { + "(SELECT NULLIF(album_art.artwork_path,'') + FROM plex_cache_album_artwork album_art + WHERE album_art.server_id = + COALESCE(NULLIF(plex_cache_tracks.server_id,''),'default') + AND album_art.parent_rating_key = plex_cache_tracks.parent_rating_key)" + .to_string() + } else { + "NULL".to_string() + }; + // Track art keeps its existing precedence on track surfaces. Parent art + // and the album-metadata repair are fallback-only, so filling a missing + // card cannot replace a legitimate disc/track-specific image. + let effective_art = + format!("COALESCE(NULLIF({item_art},''),NULLIF({collection_art},''),{metadata_art})"); + let sql = format!( + "SELECT rowid, rating_key, title, COALESCE(artist,''), COALESCE(album,''), + COALESCE(duration_ms,0), {year}, {disc}, {track}, {format}, {depth}, + {rate}, {art}, {updated}, {album_id}, {mbid} + FROM plex_cache_tracks + WHERE rowid > ?1 + AND COALESCE(NULLIF(server_id,''),'default') = ?2 + ORDER BY rowid + LIMIT ?3", + year = optional_column(&columns, "year", "NULL"), + disc = optional_column(&columns, "disc_number", "NULL"), + track = optional_column(&columns, "track_number", "NULL"), + format = format_expression(&columns), + depth = optional_column(&columns, "bit_depth", "NULL"), + rate = optional_column(&columns, "sampling_rate_hz", "NULL"), + art = effective_art, + updated = optional_column(&columns, "updated_at", "0"), + album_id = if columns.contains("parent_rating_key") { + "parent_rating_key".to_string() + } else { + optional_column(&columns, "album_key", "NULL") + }, + mbid = optional_column(&columns, "recording_mbid", "NULL"), + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + params![ + after, + spec.source.source_instance, + BOOTSTRAP_BATCH_ROWS as i64 + 1 + ], + |row| { + let mut mapped = map_remote_like(row, spec, SourceKind::Plex)?; + mapped.1.native_album_id = row.get(14)?; + mapped.1.musicbrainz_recording_id = nonempty(row.get(15)?); + Ok(mapped) + }, + )?; + rows.collect::<rusqlite::Result<Vec<_>>>() + .map_err(Into::into) +} + +fn read_remote(conn: &Connection, spec: &LegacySourceSpec, after: i64) -> Result<Vec<LegacyRow>> { + let columns = table_columns(conn, "remote_cache_tracks")?; + require_columns( + &columns, + &[ + "id", + "item_id", + "title", + "artist", + "album_artist", + "album", + "duration_ms", + ], + "remote_cache_tracks", + )?; + let sql = format!( + "SELECT id, item_id, title, artist, album, duration_ms, {year}, {disc}, {track}, + {format}, {depth}, {rate}, {art}, {updated}, album_artist, {album_id}, + {isrc}, {mbid} + FROM remote_cache_tracks + WHERE id > ?1 AND source = ?2 + AND COALESCE(NULLIF(server_id,''),'default') = ?3 + ORDER BY id + LIMIT ?4", + year = optional_column(&columns, "year", "NULL"), + disc = optional_column(&columns, "disc_number", "NULL"), + track = optional_column(&columns, "track_number", "NULL"), + format = format_expression(&columns), + depth = optional_column(&columns, "bit_depth", "NULL"), + rate = optional_column(&columns, "sample_rate_hz", "NULL"), + art = optional_column(&columns, "artwork_token", "NULL"), + updated = optional_column(&columns, "updated_at", "0"), + album_id = optional_column(&columns, "album_id", "NULL"), + isrc = optional_column(&columns, "isrc", "NULL"), + mbid = optional_column(&columns, "recording_mbid", "NULL"), + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + params![ + after, + spec.source.source.as_str(), + spec.source.source_instance, + BOOTSTRAP_BATCH_ROWS as i64 + 1 + ], + |row| { + let mut mapped = map_remote_like(row, spec, spec.source.source)?; + mapped.1.album_artist = row.get(14)?; + mapped.1.native_album_id = row.get(15)?; + mapped.1.isrc = nonempty(row.get(16)?); + mapped.1.musicbrainz_recording_id = nonempty(row.get(17)?); + mapped.1.credits = credits(&mapped.1.artist, &mapped.1.album_artist); + Ok(mapped) + }, + )?; + rows.collect::<rusqlite::Result<Vec<_>>>() + .map_err(Into::into) +} + +fn map_remote_like( + row: &Row<'_>, + spec: &LegacySourceSpec, + source: SourceKind, +) -> rusqlite::Result<LegacyRow> { + let cursor: i64 = row.get(0)?; + let artist: String = row.get(3)?; + Ok(( + cursor, + ProjectedTrack { + track_ref: TrackRef { + source, + source_instance: spec.source.source_instance.clone(), + native_id: value_as_string(row, 1)?, + }, + source_raw: source.as_str().to_string(), + local_track_id: None, + local_path: None, + native_album_id: None, + source_copy_id: None, + title: row.get(2)?, + artist: artist.clone(), + album_artist: artist.clone(), + album: row.get(4)?, + duration_ms: nonnegative(row.get::<_, Option<i64>>(5)?.unwrap_or(0)), + year: optional_u32(row, 6)?, + disc_number: optional_u32(row, 7)?, + track_number: optional_u32(row, 8)?, + format: row + .get::<_, Option<String>>(9)? + .unwrap_or_default() + .to_ascii_lowercase(), + bit_depth: optional_u32(row, 10)?, + sample_rate_hz: optional_u32(row, 11)?, + artwork_token: row.get(12)?, + isrc: None, + musicbrainz_recording_id: None, + added_at: row.get::<_, Option<i64>>(13)?.unwrap_or(0), + available: true, + observed_generation: 0, + credits: credits(&artist, &artist), + }, + )) +} + +fn source_summary(conn: &Connection, spec: &LegacySourceSpec) -> Result<(u64, i64, i64)> { + let plex_artwork_updated = + if spec.table == LegacyTable::Plex && table_exists(conn, "plex_cache_album_artwork")? { + "(SELECT COALESCE(MAX(checked_at),0) + FROM plex_cache_album_artwork + WHERE server_id=?1)" + } else { + "0" + }; + let (sql, params) = match spec.table { + LegacyTable::Local => ( + "SELECT COUNT(*), COALESCE(MAX(id),0), COALESCE(MAX(indexed_at),0) + FROM local_tracks" + .to_string(), + Vec::new(), + ), + LegacyTable::Plex => ( + format!( + "SELECT COUNT(*), COALESCE(MAX(rowid),0), + MAX(COALESCE(MAX(updated_at),0),{plex_artwork_updated}) + FROM plex_cache_tracks + WHERE COALESCE(NULLIF(server_id,''),'default') = ?1" + ), + vec![spec.source.source_instance.clone()], + ), + LegacyTable::Remote => ( + "SELECT COUNT(*), COALESCE(MAX(id),0), COALESCE(MAX(updated_at),0) + FROM remote_cache_tracks + WHERE source = ?1 + AND COALESCE(NULLIF(server_id,''),'default') = ?2" + .to_string(), + vec![ + spec.source.source.as_str().to_string(), + spec.source.source_instance.clone(), + ], + ), + }; + let mut stmt = conn.prepare(&sql)?; + let values = params.iter().map(String::as_str).collect::<Vec<_>>(); + let (count, maximum_id, maximum_updated): (i64, i64, i64) = stmt + .query_row(rusqlite::params_from_iter(values), |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + Ok((count.max(0) as u64, maximum_id, maximum_updated)) +} + +fn credits(artist: &str, album_artist: &str) -> Vec<ArtistCredit> { + let mut values = Vec::new(); + if !artist.trim().is_empty() { + values.push(ArtistCredit { + display_name: artist.trim().to_string(), + role: CreditRole::TrackArtist, + ordinal: 0, + }); + } + if !album_artist.trim().is_empty() && !album_artist.eq_ignore_ascii_case(artist) { + values.push(ArtistCredit { + display_name: album_artist.trim().to_string(), + role: CreditRole::AlbumArtist, + ordinal: 0, + }); + } + values +} + +fn nonnegative(value: i64) -> u64 { + value.max(0) as u64 +} + +fn optional_u32(row: &Row<'_>, index: usize) -> rusqlite::Result<Option<u32>> { + Ok(row + .get::<_, Option<i64>>(index)? + .and_then(|value| u32::try_from(value).ok())) +} + +fn value_as_string(row: &Row<'_>, index: usize) -> rusqlite::Result<String> { + match row.get_ref(index)? { + ValueRef::Text(value) => Ok(String::from_utf8_lossy(value).into_owned()), + ValueRef::Integer(value) => Ok(value.to_string()), + _ => row.get(index), + } +} + +fn open_if_present(path: &Path) -> Result<Option<Connection>> { + if !path.is_file() { + return Ok(None); + } + open_read_only(path).map(Some) +} + +fn open_read_only(path: &Path) -> Result<Connection> { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + conn.busy_timeout(Duration::from_millis(2_500))?; + conn.execute_batch("PRAGMA query_only=ON;")?; + Ok(conn) +} + +fn table_exists(conn: &Connection, table: &str) -> Result<bool> { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |row| row.get(0), + )?) +} + +fn table_columns(conn: &Connection, table: &str) -> Result<HashSet<String>> { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; + let rows = stmt.query_map([], |row| row.get::<_, String>(1))?; + Ok(rows.collect::<rusqlite::Result<HashSet<_>>>()?) +} + +fn require_columns(columns: &HashSet<String>, required: &[&str], table: &str) -> Result<()> { + if let Some(column) = required.iter().find(|column| !columns.contains(**column)) { + return Err(CatalogError::InvalidSource(format!( + "{table} is missing required column {column}" + ))); + } + Ok(()) +} + +fn optional_column(columns: &HashSet<String>, column: &str, fallback: &str) -> String { + if columns.contains(column) { + column.to_string() + } else { + fallback.to_string() + } +} + +fn format_expression(columns: &HashSet<String>) -> String { + match (columns.contains("codec"), columns.contains("container")) { + (true, true) => "COALESCE(NULLIF(codec,''),NULLIF(container,''),'')".to_string(), + (true, false) => "COALESCE(codec,'')".to_string(), + (false, true) => "COALESCE(container,'')".to_string(), + (false, false) => "''".to_string(), + } +} + +fn sqlite_file_epoch(path: &Path) -> Result<String> { + let mut parts = Vec::new(); + for candidate in [ + path.to_path_buf(), + PathBuf::from(format!("{}-wal", path.display())), + ] { + match std::fs::metadata(candidate) { + Ok(metadata) => { + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + parts.push(format!("{}:{modified}", metadata.len())); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + parts.push("0:0".to_string()) + } + Err(error) => return Err(error.into()), + } + } + Ok(parts.join(":")) +} diff --git a/crates/qbz-local-catalog/src/lib.rs b/crates/qbz-local-catalog/src/lib.rs new file mode 100644 index 000000000..3ac01edc2 --- /dev/null +++ b/crates/qbz-local-catalog/src/lib.rs @@ -0,0 +1,88 @@ +//! Frontend-agnostic derived catalog for Local Library. +//! +//! Source databases remain authoritative. This crate owns only a rebuildable +//! read projection and query primitives; it has no dependency on Qt, QML, +//! playback, or any source protocol/API. + +mod bootstrap; +mod catalog; +mod legacy; +mod model; +mod projection; +mod schema; + +pub use bootstrap::{ + ActiveCatalog, BootstrapBatch, BootstrapLayout, BootstrapManifest, BootstrapOutcome, + BootstrapProgress, BootstrapSession, FallbackReason, GenerationPruneReport, PreflightReport, + SourceCheckpoint, SourceProbe, BOOTSTRAP_BATCH_ROWS, +}; +pub use catalog::{ + normalize_artist_key, normalize_sort_key, Catalog, CatalogStats, IntegrityReport, QueryMetrics, + RuntimeIntegrityReport, +}; +pub use legacy::{ + bootstrap_legacy_caches, bootstrap_legacy_caches_at_with_progress, + bootstrap_legacy_caches_with_progress, discover_legacy_sources, discover_legacy_sources_at, + reconcile_legacy_caches, reconcile_legacy_caches_at_with_progress, + reconcile_legacy_caches_with_progress, LegacyLocations, LegacySourceSpec, +}; +pub use model::{ + AlbumCursor, AlbumPage, AlbumRecord, ArtistCredit, ArtistCursor, ArtistPage, ArtistRecord, + CreditRole, ProjectedTrack, QueryDescriptor, QuerySurface, SourceKey, SourceKind, TrackCursor, + TrackGroup, TrackPage, TrackRecord, TrackRef, TrackSort, +}; +pub use projection::{ + ProjectionOutcome, ProjectionProgress, ProjectionSession, ReconciliationBatch, +}; +pub use schema::SCHEMA_VERSION; + +/// Artwork stored locally by a media-server metadata sidecar. Qt strips this +/// marker and routes the remainder through the Local artwork provider instead +/// of handing a filesystem path back to Plex/Jellyfin/Subsonic as a token. +pub const SIDECAR_LOCAL_ART_PREFIX: &str = "qbz-local-art:"; + +#[derive(Debug, thiserror::Error)] +pub enum CatalogError { + #[error("catalog sqlite error: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("catalog schema {found} is not supported (expected {expected})")] + UnsupportedSchema { found: u32, expected: u32 }, + #[error("refusing to open a non-catalog SQLite database")] + NotCatalog, + #[error("invalid catalog input: {0}")] + InvalidInput(String), + #[error("search terms shorter than three characters are deferred")] + SearchTooShort, + #[error("a cursor from a different query descriptor cannot be reused")] + CursorDescriptorMismatch, + #[error("catalog I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("catalog manifest error: {0}")] + Manifest(#[from] serde_json::Error), + #[error("bootstrap source {0:?} changed since its saved checkpoint")] + SourceSnapshotChanged(SourceKey), + #[error("bootstrap checkpoint does not match the committed cursor for {0:?}")] + CheckpointMismatch(SourceKey), + #[error("bootstrap batch has {found} rows; the maximum is {maximum}")] + BatchTooLarge { found: usize, maximum: usize }, + #[error("catalog preflight needs {required_bytes} bytes but only {available_bytes} are free")] + InsufficientSpace { + required_bytes: u64, + available_bytes: u64, + }, + #[error("catalog bootstrap source is invalid: {0}")] + InvalidSource(String), + #[error("catalog bootstrap is already running")] + BootstrapBusy, + #[error("catalog activation is not ready: {0}")] + ActivationNotReady(String), +} + +pub type Result<T> = std::result::Result<T, CatalogError>; + +#[cfg(test)] +mod bootstrap_tests; +#[cfg(test)] +mod projection_tests; +#[cfg(test)] +mod tests; diff --git a/crates/qbz-local-catalog/src/model.rs b/crates/qbz-local-catalog/src/model.rs new file mode 100644 index 000000000..ba4e4e0f3 --- /dev/null +++ b/crates/qbz-local-catalog/src/model.rs @@ -0,0 +1,447 @@ +use serde::{Deserialize, Serialize}; + +/// Authoritative origin of a projected row. Wire values are deliberately the +/// same words the existing source/cache layers use. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + Local, + Offline, + Plex, + Jellyfin, + Subsonic, +} + +impl SourceKind { + pub const ALL: [Self; 5] = [ + Self::Local, + Self::Offline, + Self::Plex, + Self::Jellyfin, + Self::Subsonic, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Offline => "offline", + Self::Plex => "plex", + Self::Jellyfin => "jellyfin", + Self::Subsonic => "subsonic", + } + } + + pub fn from_str(value: &str) -> Option<Self> { + match value { + "local" => Some(Self::Local), + "offline" => Some(Self::Offline), + "plex" => Some(Self::Plex), + "jellyfin" => Some(Self::Jellyfin), + "subsonic" => Some(Self::Subsonic), + _ => None, + } + } +} + +/// Stable product identity. The catalog's integer rowid never leaves through +/// this type; playback and user data continue to use source-native identity. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TrackRef { + pub source: SourceKind, + pub source_instance: String, + pub native_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct SourceKey { + pub source: SourceKind, + pub source_instance: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CreditRole { + TrackArtist, + AlbumArtist, + Composer, + Performer, + Featured, +} + +impl CreditRole { + pub const fn as_str(self) -> &'static str { + match self { + Self::TrackArtist => "track_artist", + Self::AlbumArtist => "album_artist", + Self::Composer => "composer", + Self::Performer => "performer", + Self::Featured => "featured", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtistCredit { + pub display_name: String, + pub role: CreditRole, + pub ordinal: u32, +} + +/// Source-neutral write shape. C/H adapters translate authoritative rows into +/// this type; the catalog itself never calls a source API. +#[derive(Debug, Clone)] +pub struct ProjectedTrack { + pub track_ref: TrackRef, + /// Raw presentation provenance from the authoritative row. Local cache + /// rows may be `qobuz_download`/`qobuz_purchase` while their stable + /// ownership remains [`SourceKind::Local`]. + pub source_raw: String, + pub local_track_id: Option<i64>, + pub local_path: Option<String>, + /// Stable album id from the same authoritative source when available. + /// Text-only caches may leave it empty; bootstrap then records a weak, + /// reversible per-source fallback association. + pub native_album_id: Option<String>, + pub source_copy_id: Option<i64>, + pub title: String, + pub artist: String, + pub album_artist: String, + pub album: String, + pub duration_ms: u64, + pub year: Option<u32>, + pub disc_number: Option<u32>, + pub track_number: Option<u32>, + pub format: String, + pub bit_depth: Option<u32>, + pub sample_rate_hz: Option<u32>, + pub artwork_token: Option<String>, + pub isrc: Option<String>, + pub musicbrainz_recording_id: Option<String>, + pub added_at: i64, + pub available: bool, + pub observed_generation: i64, + pub credits: Vec<ArtistCredit>, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TrackSort { + #[default] + Default, + TitleAsc, + TitleDesc, + ArtistAsc, + ArtistDesc, + YearAsc, + YearDesc, + AddedDesc, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TrackGroup { + #[default] + Off, + Album, + Artist, + Name, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuerySurface { + Tracks, + Albums, + Artists, +} + +/// Immutable query snapshot. Builders consume and return a new descriptor so +/// an async request cannot observe later UI mutations through shared state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueryDescriptor { + surface: QuerySurface, + search: String, + sources: Vec<SourceKey>, + source_buckets: Vec<String>, + formats: Vec<String>, + other_formats: bool, + quality_tiers: Vec<String>, + sort: TrackSort, + group: TrackGroup, + available_only: bool, +} + +impl QueryDescriptor { + pub fn tracks() -> Self { + Self::for_surface(QuerySurface::Tracks) + } + + pub fn albums() -> Self { + Self::for_surface(QuerySurface::Albums) + } + + pub fn artists() -> Self { + Self::for_surface(QuerySurface::Artists) + } + + pub fn for_surface(surface: QuerySurface) -> Self { + Self { + surface, + search: String::new(), + sources: Vec::new(), + source_buckets: Vec::new(), + formats: Vec::new(), + other_formats: false, + quality_tiers: Vec::new(), + sort: TrackSort::Default, + group: TrackGroup::Off, + available_only: true, + } + } + + pub fn with_search(mut self, search: impl Into<String>) -> Self { + self.search = search.into().trim().to_string(); + self + } + + pub fn with_sources(mut self, sources: Vec<SourceKey>) -> Self { + self.sources = sources; + self.sources.sort(); + self.sources.dedup(); + self + } + + pub fn with_formats(mut self, formats: Vec<String>) -> Self { + self.formats = formats + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + self.formats.sort(); + self.formats.dedup(); + self + } + + pub fn including_other_formats(mut self, include: bool) -> Self { + self.other_formats = include; + self + } + + pub fn with_source_buckets(mut self, buckets: Vec<String>) -> Self { + self.source_buckets = buckets + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + self.source_buckets.sort(); + self.source_buckets.dedup(); + self + } + + pub fn with_quality_tiers(mut self, tiers: Vec<String>) -> Self { + self.quality_tiers = tiers + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + self.quality_tiers.sort(); + self.quality_tiers.dedup(); + self + } + + pub fn with_sort(mut self, sort: TrackSort) -> Self { + self.sort = sort; + self + } + + pub fn with_group(mut self, group: TrackGroup) -> Self { + self.group = group; + self + } + + pub fn including_unavailable(mut self) -> Self { + self.available_only = false; + self + } + + pub fn surface(&self) -> QuerySurface { + self.surface + } + + pub fn search(&self) -> &str { + &self.search + } + + pub fn sources(&self) -> &[SourceKey] { + &self.sources + } + + pub fn source_buckets(&self) -> &[String] { + &self.source_buckets + } + + pub fn formats(&self) -> &[String] { + &self.formats + } + + pub fn other_formats(&self) -> bool { + self.other_formats + } + + pub fn quality_tiers(&self) -> &[String] { + &self.quality_tiers + } + + pub fn sort(&self) -> TrackSort { + self.sort + } + + pub fn group(&self) -> TrackGroup { + self.group + } + + pub fn available_only(&self) -> bool { + self.available_only + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackRecord { + pub track_ref: TrackRef, + pub source_raw: String, + pub local_track_id: Option<i64>, + pub local_path: Option<String>, + pub native_album_id: Option<String>, + pub title: String, + pub artist: String, + pub album_artist: String, + pub album: String, + pub duration_ms: u64, + pub year: Option<u32>, + pub disc_number: Option<u32>, + pub track_number: Option<u32>, + pub format: String, + pub bit_depth: Option<u32>, + pub sample_rate_hz: Option<u32>, + pub artwork_token: Option<String>, + pub available: bool, +} + +/// Opaque keyset cursor. It carries the exact normalized ORDER BY values plus +/// the catalog rowid tie-breaker, but exposes none as product identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackCursor { + pub(crate) sort: TrackSort, + pub(crate) descriptor_key: String, + pub(crate) sort_title: String, + pub(crate) sort_artist: String, + pub(crate) sort_track_artist: String, + pub(crate) sort_album: String, + pub(crate) year_missing: i64, + pub(crate) year_value: i64, + pub(crate) disc_sort: i64, + pub(crate) track_sort: i64, + pub(crate) added_at: i64, + pub(crate) row_id: i64, +} + +impl TrackCursor { + /// Normalized grouping key of the row immediately before a keyset page. + /// This lets a paged frontend decide whether the first resident row starts + /// a new global group without issuing an overlap query. + pub fn group_key(&self, group: TrackGroup) -> String { + match group { + TrackGroup::Off => String::new(), + TrackGroup::Album => self.sort_album.clone(), + TrackGroup::Artist => self.sort_track_artist.clone(), + TrackGroup::Name => self + .sort_title + .chars() + .next() + .unwrap_or('#') + .to_uppercase() + .collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackPage { + pub rows: Vec<TrackRecord>, + pub next_cursor: Option<TrackCursor>, + pub has_more: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlbumRecord { + pub edition_id: i64, + pub source: SourceKind, + pub native_album_id: String, + pub source_raw: String, + /// Distinct presentation source words contributed by every physical copy + /// of this edition. Unlike `source`, which selects one copy for routing, + /// this list is for multi-source badges and capabilities such as local + /// favorites. Raw words (for example `qobuz_purchase` or a Subsonic + /// brand) are preserved. + pub source_words: Vec<String>, + pub title: String, + pub artist: String, + pub all_artists: String, + pub year: Option<u32>, + pub track_count: u32, + pub total_duration_ms: u64, + pub quality_tier: String, + pub format: String, + pub bit_depth: Option<u32>, + pub sample_rate_hz: Option<u32>, + pub artwork_source: String, + pub artwork_token: String, + pub directory_path: String, + pub folder_count: u32, + pub added_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlbumCursor { + pub(crate) descriptor_key: String, + pub(crate) sort_title: String, + pub(crate) sort_artist: String, + pub(crate) year_missing: i64, + pub(crate) year_value: i64, + pub(crate) added_at: i64, + pub(crate) edition_id: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlbumPage { + pub rows: Vec<AlbumRecord>, + pub next_cursor: Option<AlbumCursor>, + pub has_more: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtistRecord { + pub artist_key: String, + pub display_name: String, + pub album_count: u32, + pub track_count: u32, + pub artwork_source: String, + pub artwork_token: String, + /// `local`, a remote source word, or `mixed` when several source kinds + /// contribute to the aggregate visible under the query descriptor. + pub source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtistCursor { + pub(crate) descriptor_key: String, + pub(crate) sort_name: String, + pub(crate) artist_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtistPage { + pub rows: Vec<ArtistRecord>, + pub next_cursor: Option<ArtistCursor>, + pub has_more: bool, +} diff --git a/crates/qbz-local-catalog/src/projection.rs b/crates/qbz-local-catalog/src/projection.rs new file mode 100644 index 000000000..7b171b571 --- /dev/null +++ b/crates/qbz-local-catalog/src/projection.rs @@ -0,0 +1,241 @@ +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; + +use crate::bootstrap::{ + acquire_lock, now_unix_ms, remove_sqlite_sidecars, sync_directory, ActiveCatalog, + BootstrapLayout, BootstrapManifest, PreflightReport, SourceCheckpoint, SourceProbe, LOCK_NAME, +}; +use crate::{Catalog, CatalogError, ProjectedTrack, Result, SourceKey, SCHEMA_VERSION}; + +#[derive(Debug, Clone)] +pub struct ReconciliationBatch { + pub source: SourceKey, + pub snapshot_version: String, + pub expected_cursor: String, + pub next_cursor: String, + pub tracks: Vec<ProjectedTrack>, + pub complete: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectionProgress { + pub generation: u64, + pub source: SourceKey, + pub rows_written: u64, + pub source_rows_total: u64, + pub overall_rows_written: u64, + pub overall_rows_total: u64, + /// One-based position among sources whose authoritative snapshot changed. + pub source_index: usize, + pub source_count: usize, + pub checkpoint_cursor: String, + pub source_complete: bool, + pub prune_authorized: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProjectionOutcome { + UpToDate { + generation: u64, + track_count: u64, + }, + Activated { + generation: u64, + track_count: u64, + changed_sources: usize, + resumed_rows: u64, + }, + Paused { + generation: u64, + source: SourceKey, + committed_rows: u64, + }, +} + +pub struct ProjectionSession { + layout: BootstrapLayout, + catalog: Catalog, + generation: u64, + building_path: PathBuf, + active_generation: u64, + _lock: File, +} + +impl BootstrapLayout { + pub fn prepare_projection( + &self, + probes: &[SourceProbe], + available_bytes: Option<u64>, + ) -> Result<(ProjectionSession, PreflightReport)> { + let available = match available_bytes { + Some(bytes) => bytes, + None => self.available_bytes()?, + }; + let active_hint = self.read_manifest().map_err(|reason| { + CatalogError::ActivationNotReady(format!( + "cannot preflight projection without a manifest: {reason:?}" + )) + })?; + let active_bytes = fs::metadata(self.generation_path(active_hint.active_generation))?.len(); + let preflight = + PreflightReport::evaluate(probes, available)?.with_catalog_floor(active_bytes)?; + fs::create_dir_all(self.data_dir())?; + let lock = acquire_lock(&self.data_dir().join(LOCK_NAME))?; + let (active, active_manifest) = match self.open_active() { + ActiveCatalog::Ready { catalog, manifest } => (catalog, manifest), + ActiveCatalog::Fallback(reason) => { + return Err(CatalogError::ActivationNotReady(format!( + "cannot project without an active catalog: {reason:?}" + ))) + } + }; + let active_generation = active_manifest.active_generation; + let generation = self.next_building_generation(Some(active_generation))?; + let building_path = self.building_path(generation); + let catalog = if building_path.is_file() { + match Catalog::open(&building_path, generation).and_then(|catalog| { + let (phase, base) = catalog.build_phase()?; + if phase == "projection" && base == active_generation { + Ok(catalog) + } else { + Err(CatalogError::InvalidInput(format!( + "building generation belongs to {phase:?} base {base}" + ))) + } + }) { + Ok(catalog) => catalog, + Err(_) => { + discard_building(&building_path)?; + clone_generation(&active, &building_path, active_generation, generation)? + } + } + } else { + clone_generation(&active, &building_path, active_generation, generation)? + }; + Ok(( + ProjectionSession { + layout: self.clone(), + catalog, + generation, + building_path, + active_generation, + _lock: lock, + }, + preflight, + )) + } +} + +impl ProjectionSession { + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn checkpoint(&self, source: &SourceKey) -> Result<Option<SourceCheckpoint>> { + self.catalog.source_checkpoint(source) + } + + pub fn source_watermark(&self, source: &SourceKey) -> Result<Option<String>> { + self.catalog.source_watermark(source) + } + + pub fn begin_source(&mut self, source: &SourceKey, snapshot_version: &str) -> Result<()> { + self.catalog.begin_reconciliation(source, snapshot_version) + } + + pub fn apply_batch(&mut self, batch: &ReconciliationBatch) -> Result<SourceCheckpoint> { + self.catalog.apply_reconciliation_batch(batch) + } + + pub fn stats(&self) -> Result<crate::CatalogStats> { + self.catalog.stats() + } + + pub fn activate(mut self, changed: &[SourceProbe]) -> Result<BootstrapManifest> { + self.catalog.rebuild_materialized_views()?; + if !self.catalog.materialized_views_valid()? { + return Err(CatalogError::ActivationNotReady( + "projection materializations do not match tracks".to_string(), + )); + } + let actual = self + .catalog + .stats()? + .source_counts + .into_iter() + .collect::<BTreeMap<_, _>>(); + for probe in changed { + let count = actual.get(&probe.source).copied().unwrap_or(0); + if count != probe.row_count { + return Err(CatalogError::ActivationNotReady(format!( + "reconciled count for {:?} is {count}, expected {}", + probe.source, probe.row_count + ))); + } + let checkpoint = self + .catalog + .source_checkpoint(&probe.source)? + .ok_or_else(|| { + CatalogError::ActivationNotReady(format!( + "missing reconciled checkpoint for {:?}", + probe.source + )) + })?; + let watermark = self + .catalog + .source_watermark(&probe.source)? + .unwrap_or_default(); + if !checkpoint.complete + || checkpoint.checkpoint_rows != probe.row_count + || checkpoint.checkpoint_version != probe.snapshot_version + || watermark != probe.snapshot_version + { + return Err(CatalogError::ActivationNotReady(format!( + "watermark/checkpoint is incomplete for {:?}", + probe.source + ))); + } + } + let integrity = self.catalog.integrity_check()?; + if !integrity.sqlite_ok || integrity.foreign_key_violations != 0 || !integrity.fts_ok { + return Err(CatalogError::ActivationNotReady(format!( + "projection integrity failed: {integrity:?}" + ))); + } + self.catalog.checkpoint_for_activation()?; + let final_path = self.layout.generation_path(self.generation); + drop(self.catalog); + fs::rename(&self.building_path, &final_path)?; + sync_directory(self.layout.data_dir())?; + let manifest = BootstrapManifest { + manifest_version: 1, + schema_version: SCHEMA_VERSION, + active_generation: self.generation, + previous_generation: Some(self.active_generation), + activated_at_unix_ms: now_unix_ms(), + }; + self.layout.write_manifest(&manifest)?; + Ok(manifest) + } +} + +fn clone_generation( + active: &Catalog, + building_path: &Path, + active_generation: u64, + generation: u64, +) -> Result<Catalog> { + discard_building(building_path)?; + active.backup_to(building_path)?; + Catalog::adopt_generation(building_path, active_generation, generation) +} + +fn discard_building(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + remove_sqlite_sidecars(path) +} diff --git a/crates/qbz-local-catalog/src/projection_tests.rs b/crates/qbz-local-catalog/src/projection_tests.rs new file mode 100644 index 000000000..3d691759b --- /dev/null +++ b/crates/qbz-local-catalog/src/projection_tests.rs @@ -0,0 +1,592 @@ +use std::collections::HashSet; +use std::fs; +use std::sync::atomic::AtomicBool; +use std::time::Instant; + +use rusqlite::Connection; +use tempfile::tempdir; + +use crate::{ + bootstrap_legacy_caches, reconcile_legacy_caches, ActiveCatalog, BootstrapBatch, + BootstrapLayout, BootstrapOutcome, ProjectedTrack, ProjectionOutcome, QueryDescriptor, + ReconciliationBatch, SourceKey, SourceKind, SourceProbe, TrackRef, BOOTSTRAP_BATCH_ROWS, +}; + +fn key(kind: SourceKind, instance: &str) -> SourceKey { + SourceKey { + source: kind, + source_instance: instance.to_string(), + } +} + +fn probe(source: &SourceKey, version: &str, rows: u64) -> SourceProbe { + SourceProbe { + source: source.clone(), + source_path: std::path::PathBuf::from(format!("{}.db", source.source.as_str())), + snapshot_version: version.to_string(), + row_count: rows, + page_bytes: rows * 512, + integrity_ok: true, + } +} + +fn row(source: &SourceKey, id: u64, title: impl Into<String>) -> ProjectedTrack { + ProjectedTrack { + track_ref: TrackRef { + source: source.source, + source_instance: source.source_instance.clone(), + native_id: id.to_string(), + }, + source_raw: source.source.as_str().to_string(), + local_track_id: (source.source == SourceKind::Local).then_some(id as i64), + local_path: (source.source == SourceKind::Local).then(|| format!("/music/{id}.flac")), + native_album_id: Some(format!("album-{}", id / 10)), + source_copy_id: None, + title: title.into(), + artist: format!("Artist {}", id % 17), + album_artist: format!("Artist {}", id % 17), + album: format!("Album {}", id / 10), + duration_ms: 200_000 + id, + year: Some(2020), + disc_number: Some(1), + track_number: Some((id % 20 + 1) as u32), + format: "flac".to_string(), + bit_depth: Some(24), + sample_rate_hz: Some(96_000), + artwork_token: None, + isrc: None, + musicbrainz_recording_id: None, + added_at: id as i64, + available: true, + observed_generation: 0, + credits: Vec::new(), + } +} + +fn bootstrap_source( + session: &mut crate::BootstrapSession, + source: &SourceKey, + version: &str, + rows: &[ProjectedTrack], +) { + let mut cursor = String::new(); + for (batch_index, chunk) in rows.chunks(BOOTSTRAP_BATCH_ROWS).enumerate() { + let end = (batch_index * BOOTSTRAP_BATCH_ROWS + chunk.len()).to_string(); + let complete = batch_index * BOOTSTRAP_BATCH_ROWS + chunk.len() == rows.len(); + let saved = session + .apply_batch(&BootstrapBatch { + source: source.clone(), + snapshot_version: version.to_string(), + expected_cursor: cursor, + next_cursor: end, + tracks: chunk.to_vec(), + complete, + }) + .unwrap(); + cursor = saved.checkpoint_cursor; + } +} + +fn reconcile_range( + session: &mut crate::ProjectionSession, + source: &SourceKey, + version: &str, + rows: &[ProjectedTrack], + start: usize, + stop: usize, +) { + let mut cursor = if start == 0 { + String::new() + } else { + start.to_string() + }; + let mut offset = start; + while offset < stop { + let end = (offset + BOOTSTRAP_BATCH_ROWS).min(stop); + let saved = session + .apply_batch(&ReconciliationBatch { + source: source.clone(), + snapshot_version: version.to_string(), + expected_cursor: cursor, + next_cursor: end.to_string(), + tracks: rows[offset..end].to_vec(), + complete: end == rows.len(), + }) + .unwrap(); + cursor = saved.checkpoint_cursor; + offset = end; + } +} + +#[test] +fn full_source_reconciliation_resumes_and_prunes_only_on_completion() { + let temp = tempdir().unwrap(); + let layout = BootstrapLayout::new(temp.path()); + let plex = key(SourceKind::Plex, "plex-a"); + let jellyfin = key(SourceKind::Jellyfin, "jf-a"); + let original_plex = (1..=600) + .map(|id| row(&plex, id, format!("Old {id}"))) + .collect::<Vec<_>>(); + let original_jellyfin = (1..=50) + .map(|id| row(&jellyfin, id, format!("Jellyfin {id}"))) + .collect::<Vec<_>>(); + let initial_probes = [probe(&plex, "plex-v1", 600), probe(&jellyfin, "jf-v1", 50)]; + let (mut bootstrap, _) = layout.prepare(&initial_probes, Some(u64::MAX)).unwrap(); + bootstrap_source(&mut bootstrap, &plex, "plex-v1", &original_plex); + bootstrap_source(&mut bootstrap, &jellyfin, "jf-v1", &original_jellyfin); + bootstrap.activate(&initial_probes).unwrap(); + + let mut current_plex = (101..=600) + .map(|id| { + row( + &plex, + id, + if id == 200 { + "Updated 200".to_string() + } else { + format!("Old {id}") + }, + ) + }) + .collect::<Vec<_>>(); + current_plex.extend((1_000..=1_100).map(|id| row(&plex, id, format!("New {id}")))); + let current_probe = probe(&plex, "plex-v2", current_plex.len() as u64); + let all_probes = [current_probe.clone(), initial_probes[1].clone()]; + let projection_started = Instant::now(); + let (mut interrupted, _) = layout + .prepare_projection(&all_probes, Some(u64::MAX)) + .unwrap(); + interrupted.begin_source(&plex, "plex-v2").unwrap(); + reconcile_range(&mut interrupted, &plex, "plex-v2", ¤t_plex, 0, 250); + assert_eq!(interrupted.source_watermark(&plex).unwrap().unwrap(), ""); + + // The active generation remains wholly readable and unpruned while g2 is + // incomplete, even with a reader held across the side-by-side write. + let ActiveCatalog::Ready { + catalog: old_reader, + .. + } = layout.open_active() + else { + panic!("generation one must remain active") + }; + assert_eq!(old_reader.stats().unwrap().track_count, 650); + assert!(old_reader + .resolve(&TrackRef { + source: SourceKind::Plex, + source_instance: "plex-a".to_string(), + native_id: "1".to_string(), + }) + .unwrap() + .is_some()); + drop(interrupted); + + let (mut resumed, _) = layout + .prepare_projection(&all_probes, Some(u64::MAX)) + .unwrap(); + let checkpoint = resumed.checkpoint(&plex).unwrap().unwrap(); + assert_eq!(checkpoint.checkpoint_rows, 250); + reconcile_range( + &mut resumed, + &plex, + "plex-v2", + ¤t_plex, + 250, + current_plex.len(), + ); + assert_eq!(resumed.source_watermark(&plex).unwrap().unwrap(), "plex-v2"); + let manifest = resumed.activate(&[current_probe]).unwrap(); + assert_eq!(manifest.active_generation, 2); + assert_eq!(manifest.previous_generation, Some(1)); + assert!(layout.generation_path(1).is_file()); + + let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else { + panic!("generation two must be active") + }; + assert_eq!(catalog.stats().unwrap().track_count, 651); + assert!(catalog + .resolve(&TrackRef { + source: SourceKind::Plex, + source_instance: "plex-a".to_string(), + native_id: "1".to_string(), + }) + .unwrap() + .is_none()); + assert_eq!( + catalog + .resolve(&TrackRef { + source: SourceKind::Plex, + source_instance: "plex-a".to_string(), + native_id: "200".to_string(), + }) + .unwrap() + .unwrap() + .title, + "Updated 200" + ); + assert_eq!( + catalog.count_tracks(&QueryDescriptor::tracks()).unwrap(), + 651 + ); + let ids = catalog + .query_tracks(&QueryDescriptor::tracks(), None, 500) + .unwrap() + .rows + .into_iter() + .map(|track| track.track_ref) + .collect::<HashSet<_>>(); + assert_eq!(ids.len(), 500); + println!( + "D_PROJECTION_METRIC observed_rows={} pruned_rows={} retained_other_source_rows={} elapsed_ms={:.3} catalog_bytes={}", + current_plex.len(), + 100, + original_jellyfin.len(), + projection_started.elapsed().as_secs_f64() * 1_000.0, + fs::metadata(layout.generation_path(2)).unwrap().len() + ); + drop(old_reader); +} + +#[test] +fn projection_preflight_accounts_for_copying_the_active_generation() { + let temp = tempdir().unwrap(); + let layout = BootstrapLayout::new(temp.path()); + let local = key(SourceKind::Local, "library"); + let initial_probe = probe(&local, "v1", 0); + let (mut bootstrap, _) = layout + .prepare(&[initial_probe.clone()], Some(u64::MAX)) + .unwrap(); + bootstrap + .apply_batch(&BootstrapBatch { + source: local, + snapshot_version: "v1".to_string(), + expected_cursor: String::new(), + next_cursor: String::new(), + tracks: Vec::new(), + complete: true, + }) + .unwrap(); + bootstrap.activate(&[initial_probe.clone()]).unwrap(); + + let error = match layout.prepare_projection(&[initial_probe], Some(256 * 1024 * 1024)) { + Ok(_) => panic!("copying an active catalog needs room above the fixed floor"), + Err(error) => error, + }; + assert!(matches!( + error, + crate::CatalogError::InsufficientSpace { .. } + )); + assert!(!layout.building_path(2).exists()); +} + +#[test] +fn legacy_catch_up_detects_authoritative_updates_and_then_is_a_no_op() { + let temp = tempdir().unwrap(); + let path = temp.path().join("library.db"); + create_local(&path); + assert!(matches!( + bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(), + BootstrapOutcome::Activated { + generation: 1, + track_count: 3, + .. + } + )); + { + let conn = Connection::open(&path).unwrap(); + conn.execute("UPDATE local_tracks SET title='Changed' WHERE id=2", []) + .unwrap(); + conn.execute("DELETE FROM local_tracks WHERE id=1", []) + .unwrap(); + conn.execute( + "INSERT INTO local_tracks VALUES + (4,'/music/d.flac','Added','Artist','Artist','Album',180,2024,1,4, + 'flac',24,96000,NULL,40,'album')", + [], + ) + .unwrap(); + } + let outcome = reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(); + assert!(matches!( + outcome, + ProjectionOutcome::Activated { + generation: 2, + track_count: 3, + changed_sources: 1, + .. + } + )); + let layout = BootstrapLayout::new(temp.path()); + let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else { + panic!("catch-up did not activate") + }; + let local_ref = |id: &str| TrackRef { + source: SourceKind::Local, + source_instance: "library".to_string(), + native_id: id.to_string(), + }; + assert!(catalog.resolve(&local_ref("1")).unwrap().is_none()); + assert_eq!( + catalog.resolve(&local_ref("2")).unwrap().unwrap().title, + "Changed" + ); + assert!(catalog.resolve(&local_ref("4")).unwrap().is_some()); + drop(catalog); + + // A metadata-only edit keeps count, max id and max indexed_at unchanged; + // the SQLite file epoch still makes it visible to catch-up. + Connection::open(&path) + .unwrap() + .execute( + "UPDATE local_tracks SET title='Metadata only' WHERE id=3", + [], + ) + .unwrap(); + assert!(matches!( + reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(), + ProjectionOutcome::Activated { + generation: 3, + track_count: 3, + changed_sources: 1, + .. + } + )); + let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else { + panic!("metadata catch-up did not activate") + }; + assert_eq!( + catalog.resolve(&local_ref("3")).unwrap().unwrap().title, + "Metadata only" + ); + drop(catalog); + + assert!(matches!( + reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(), + ProjectionOutcome::UpToDate { + generation: 3, + track_count: 3, + } + )); + assert!(!layout.building_path(4).exists()); + + fs::write(&path, b"corrupt authoritative fixture").unwrap(); + assert!(reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).is_err()); + let ActiveCatalog::Ready { catalog, manifest } = layout.open_active() else { + panic!("source failure must preserve the last complete generation") + }; + assert_eq!(manifest.active_generation, 3); + assert_eq!(catalog.stats().unwrap().track_count, 3); +} + +#[test] +fn remote_metadata_sidecar_overlays_bootstrap_and_triggers_catch_up() { + let temp = tempdir().unwrap(); + let remote_path = temp.path().join("remote_cache.db"); + Connection::open(&remote_path) + .unwrap() + .execute_batch( + "CREATE TABLE remote_cache_tracks ( + id INTEGER PRIMARY KEY, source TEXT NOT NULL, item_id TEXT NOT NULL, + server_id TEXT, title TEXT, artist TEXT, album_artist TEXT, + album TEXT, duration_ms INTEGER, codec TEXT, container TEXT, + bit_depth INTEGER, sample_rate_hz INTEGER, artwork_token TEXT, + updated_at INTEGER, year INTEGER, disc_number INTEGER, + track_number INTEGER, album_id TEXT + ); + INSERT INTO remote_cache_tracks VALUES + (1,'jellyfin','track-1','server-a','Original title','Original artist', + 'Original album artist','Original album',220000,'flac','flac',24, + 96000,'remote-art',30,2022,1,1,'album-9');", + ) + .unwrap(); + + let sidecar_path = temp.path().join("metadata_sidecars.db"); + let sidecar = Connection::open(&sidecar_path).unwrap(); + sidecar + .execute_batch( + "CREATE TABLE remote_album_tag_sidecars ( + source TEXT NOT NULL, source_instance TEXT NOT NULL, + album_id TEXT NOT NULL, payload_json TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(source,source_instance,album_id) + );", + ) + .unwrap(); + let payload = serde_json::json!({ + "version": 2, + "updatedAt": 100, + "album": { + "albumTitle": "Edited album", + "albumArtist": "Edited album artist", + "year": 2025, + "genre": "Progressive rock", + "catalogNumber": "CAT-9" + }, + "tracks": [{ + "filePath": "track-1", + "cueStartSecs": null, + "title": "Edited title", + "discNumber": 2, + "trackNumber": 7 + }], + "extendedAlbum": { + "albumArtists": ["Edited album artist"], + "compilation": false, + "musicbrainzReleaseId": "release-9", + "musicbrainzReleaseGroupId": "group-9", + "musicbrainzAlbumArtistIds": ["artist-9"], + "discogsReleaseId": "99", + "artworkPath": "/cache/edited-cover.jpg" + }, + "extendedTracks": [{ + "filePath": "track-1", + "cueStartSecs": null, + "artistCredit": "Edited track artist", + "artists": ["Edited track artist"], + "composers": ["Composer"], + "performers": ["Player (guitar)"], + "musicbrainzRecordingId": "recording-1", + "musicbrainzTrackId": "mb-track-1", + "musicbrainzArtistIds": ["artist-1"] + }] + }) + .to_string(); + sidecar + .execute( + "INSERT INTO remote_album_tag_sidecars VALUES + ('jellyfin','server-a','album-9',?1,100)", + [&payload], + ) + .unwrap(); + drop(sidecar); + + assert!(matches!( + bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(), + BootstrapOutcome::Activated { + generation: 1, + track_count: 1, + .. + } + )); + let layout = BootstrapLayout::new(temp.path()); + let track_ref = TrackRef { + source: SourceKind::Jellyfin, + source_instance: "server-a".to_string(), + native_id: "track-1".to_string(), + }; + let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else { + panic!("sidecar bootstrap did not activate") + }; + let projected = catalog.resolve(&track_ref).unwrap().unwrap(); + assert_eq!(projected.title, "Edited title"); + assert_eq!(projected.artist, "Edited track artist"); + assert_eq!(projected.album, "Edited album"); + assert_eq!(projected.album_artist, "Edited album artist"); + assert_eq!(projected.year, Some(2025)); + assert_eq!(projected.disc_number, Some(2)); + assert_eq!(projected.track_number, Some(7)); + assert_eq!( + projected.artwork_token.as_deref(), + Some("qbz-local-art:/cache/edited-cover.jpg") + ); + drop(catalog); + + let changed = payload.replace("Edited title", "Changed from sidecar"); + Connection::open(&sidecar_path) + .unwrap() + .execute( + "UPDATE remote_album_tag_sidecars + SET payload_json=?1,updated_at=101 + WHERE source='jellyfin' AND source_instance='server-a' + AND album_id='album-9'", + [&changed], + ) + .unwrap(); + assert!(matches!( + reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(), + ProjectionOutcome::Activated { + generation: 2, + track_count: 1, + changed_sources: 1, + .. + } + )); + let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else { + panic!("sidecar catch-up did not activate") + }; + assert_eq!( + catalog.resolve(&track_ref).unwrap().unwrap().title, + "Changed from sidecar" + ); +} + +fn create_local(path: &std::path::Path) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE local_tracks ( + id INTEGER PRIMARY KEY,file_path TEXT NOT NULL,title TEXT NOT NULL, + artist TEXT NOT NULL,album_artist TEXT,album TEXT NOT NULL, + duration_secs INTEGER,year INTEGER,disc_number INTEGER, + track_number INTEGER,format TEXT,bit_depth INTEGER,sample_rate REAL, + artwork_path TEXT,indexed_at INTEGER,album_group_key TEXT + ); + INSERT INTO local_tracks VALUES + (1,'/music/a.flac','A','Artist','Artist','Album',180,2024,1,1,'flac',24,96000,NULL,10,'album'), + (2,'/music/b.flac','B','Artist','Artist','Album',180,2024,1,2,'flac',24,96000,NULL,20,'album'), + (3,'/music/c.flac','C','Artist','Artist','Album',180,2024,1,3,'flac',24,96000,NULL,30,'album');", + ) + .unwrap(); +} + +#[test] +fn sacd_virtual_tracks_project_as_stable_local_rows() { + let temp = tempdir().unwrap(); + let path = temp.path().join("library.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE local_tracks ( + id INTEGER PRIMARY KEY,file_path TEXT NOT NULL,title TEXT NOT NULL, + artist TEXT NOT NULL,album_artist TEXT,album TEXT NOT NULL, + duration_secs INTEGER,year INTEGER,disc_number INTEGER, + track_number INTEGER,format TEXT,bit_depth INTEGER,sample_rate REAL, + artwork_path TEXT,indexed_at INTEGER,album_group_key TEXT,source TEXT + ); + INSERT INTO local_tracks VALUES + (41,'sacd:/nas/disc.iso#1','Movement I','Composer','Orchestra', + 'Symphony',720,NULL,1,1,'DSD',1,2822400,'/art/sacd',100, + 'sacd|||sacd-fingerprint','user'), + (42,'sacd:/nas/disc.iso#2','Movement II','Composer','Orchestra', + 'Symphony',680,NULL,1,2,'DSD',1,2822400,'/art/sacd',100, + 'sacd|||sacd-fingerprint','user');", + ) + .unwrap(); + drop(conn); + + assert!(matches!( + bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(), + BootstrapOutcome::Activated { track_count: 2, .. } + )); + let ActiveCatalog::Ready { catalog, .. } = BootstrapLayout::new(temp.path()).open_active() + else { + panic!("SACD fixture did not activate") + }; + let projected = catalog + .resolve(&TrackRef { + source: SourceKind::Local, + source_instance: "library".to_string(), + native_id: "41".to_string(), + }) + .unwrap() + .unwrap(); + assert_eq!(projected.local_track_id, Some(41)); + assert_eq!( + projected.local_path.as_deref(), + Some("sacd:/nas/disc.iso#1") + ); + assert_eq!( + projected.native_album_id.as_deref(), + Some("sacd|||sacd-fingerprint") + ); + assert_eq!(projected.source_raw, "user"); + assert_eq!(projected.format, "dsd"); + assert_eq!(projected.sample_rate_hz, Some(2_822_400)); +} diff --git a/crates/qbz-local-catalog/src/schema.rs b/crates/qbz-local-catalog/src/schema.rs new file mode 100644 index 000000000..2dd0852bf --- /dev/null +++ b/crates/qbz-local-catalog/src/schema.rs @@ -0,0 +1,431 @@ +use rusqlite::{params, Connection}; + +use crate::{CatalogError, Result}; + +// Version 5 changes the derived artist-identity projection. Existing v4 +// catalogs are rebuilt side-by-side from their authoritative caches; no user +// library database is migrated in place. +// Version 6 gives DSD its own materialized album `quality_tier` ('dsd', no +// longer folded into 'hires') so the Local Library DSD chip can read it; the +// same side-by-side rebuild from the caches re-derives every album row. +pub const SCHEMA_VERSION: u32 = 6; +pub const APPLICATION_ID: i64 = 0x5142_5A43; // "QBZC" + +pub(crate) fn configure(conn: &Connection) -> Result<()> { + conn.busy_timeout(std::time::Duration::from_millis(2_500))?; + conn.execute_batch(&format!( + "PRAGMA foreign_keys=ON; + PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA temp_store=MEMORY; + PRAGMA cache_size=-32768; + PRAGMA mmap_size=268435456; + PRAGMA application_id={APPLICATION_ID};" + ))?; + Ok(()) +} + +pub(crate) fn configure_read_only(conn: &Connection) -> Result<()> { + conn.busy_timeout(std::time::Duration::from_millis(2_500))?; + conn.execute_batch( + "PRAGMA foreign_keys=ON; + PRAGMA query_only=ON; + PRAGMA temp_store=MEMORY; + PRAGMA cache_size=-32768; + PRAGMA mmap_size=268435456;", + )?; + Ok(()) +} + +pub(crate) fn verify(conn: &Connection, generation: u64) -> Result<()> { + let application_id: i64 = conn.query_row("PRAGMA application_id", [], |row| row.get(0))?; + if application_id != APPLICATION_ID { + return Err(CatalogError::NotCatalog); + } + let found: u32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if found != SCHEMA_VERSION { + return Err(CatalogError::UnsupportedSchema { + found, + expected: SCHEMA_VERSION, + }); + } + let stored_generation: String = conn.query_row( + "SELECT value FROM catalog_meta WHERE key = 'generation'", + [], + |row| row.get(0), + )?; + if stored_generation != generation.to_string() { + return Err(CatalogError::InvalidInput(format!( + "requested generation {generation}, database contains generation {stored_generation}" + ))); + } + Ok(()) +} + +pub(crate) fn init(conn: &mut Connection, generation: u64) -> Result<()> { + let application_id: i64 = conn.query_row("PRAGMA application_id", [], |row| row.get(0))?; + if application_id != 0 && application_id != APPLICATION_ID { + return Err(CatalogError::NotCatalog); + } + if application_id == 0 { + let existing_tables: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + [], + |row| row.get(0), + )?; + if existing_tables > 0 { + return Err(CatalogError::NotCatalog); + } + } + configure(conn)?; + let found: u32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if found != 0 && found != SCHEMA_VERSION { + return Err(CatalogError::UnsupportedSchema { + found, + expected: SCHEMA_VERSION, + }); + } + + let tx = conn.transaction()?; + tx.execute_batch(SCHEMA_SQL)?; + tx.execute( + "INSERT INTO catalog_meta(key, value) VALUES ('schema_version', ?1) + ON CONFLICT(key) DO NOTHING", + params![SCHEMA_VERSION.to_string()], + )?; + tx.execute( + "INSERT INTO catalog_meta(key, value) VALUES ('generation', ?1) + ON CONFLICT(key) DO NOTHING", + params![generation.to_string()], + )?; + tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; + tx.commit()?; + + let stored_generation: String = conn.query_row( + "SELECT value FROM catalog_meta WHERE key = 'generation'", + [], + |row| row.get(0), + )?; + if stored_generation != generation.to_string() { + return Err(CatalogError::InvalidInput(format!( + "requested generation {generation}, database contains generation {stored_generation}" + ))); + } + Ok(()) +} + +const SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS catalog_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS source_state ( + source_kind TEXT NOT NULL CHECK ( + source_kind IN ('local','offline','plex','jellyfin','subsonic') + ), + source_instance TEXT NOT NULL, + available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), + last_observed_at INTEGER NOT NULL DEFAULT 0, + watermark TEXT NOT NULL DEFAULT '', + complete_generation INTEGER NOT NULL DEFAULT 0, + checkpoint_cursor TEXT NOT NULL DEFAULT '', + checkpoint_rows INTEGER NOT NULL DEFAULT 0, + checkpoint_version TEXT NOT NULL DEFAULT '', + PRIMARY KEY (source_kind, source_instance) +) STRICT; + +CREATE TABLE IF NOT EXISTS logical_albums ( + logical_album_id INTEGER PRIMARY KEY, + stable_key TEXT NOT NULL UNIQUE, + display_title TEXT NOT NULL, + sort_title TEXT NOT NULL, + display_artist TEXT NOT NULL, + sort_artist TEXT NOT NULL, + association_strength TEXT NOT NULL DEFAULT 'source_native' + CHECK (association_strength IN ( + 'source_native','text_fallback','isrc','musicbrainz','manual' + )), + association_evidence TEXT NOT NULL DEFAULT '' +) STRICT; + +CREATE TABLE IF NOT EXISTS editions ( + edition_id INTEGER PRIMARY KEY, + logical_album_id INTEGER NOT NULL REFERENCES logical_albums(logical_album_id), + edition_key TEXT NOT NULL UNIQUE, + display_title TEXT NOT NULL, + display_artist TEXT NOT NULL, + release_year INTEGER, + musicbrainz_release_id TEXT, + provider_release_id TEXT, + evidence_kind TEXT NOT NULL DEFAULT 'source_native', + evidence_value TEXT NOT NULL DEFAULT '' +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_editions_logical + ON editions(logical_album_id, release_year, edition_id); + +CREATE TABLE IF NOT EXISTS source_copies ( + source_copy_id INTEGER PRIMARY KEY, + edition_id INTEGER NOT NULL REFERENCES editions(edition_id), + source_kind TEXT NOT NULL CHECK ( + source_kind IN ('local','offline','plex','jellyfin','subsonic') + ), + source_instance TEXT NOT NULL, + native_album_id TEXT NOT NULL, + local_directory TEXT, + available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), + last_observed_generation INTEGER NOT NULL DEFAULT 0, + UNIQUE (source_kind, source_instance, native_album_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_source_copies_edition + ON source_copies(edition_id, source_kind, source_instance); + +CREATE TABLE IF NOT EXISTS tracks ( + catalog_id INTEGER PRIMARY KEY, + source_kind TEXT NOT NULL CHECK ( + source_kind IN ('local','offline','plex','jellyfin','subsonic') + ), + source_instance TEXT NOT NULL, + native_track_id TEXT NOT NULL, + source_raw TEXT NOT NULL DEFAULT '', + local_track_id INTEGER, + local_path TEXT, + source_copy_id INTEGER REFERENCES source_copies(source_copy_id), + title TEXT NOT NULL, + sort_title TEXT NOT NULL, + artist TEXT NOT NULL, + sort_artist TEXT NOT NULL, + sort_track_artist TEXT NOT NULL, + album_artist TEXT NOT NULL, + album TEXT NOT NULL, + sort_album TEXT NOT NULL, + credits TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0, + year INTEGER, + year_missing INTEGER NOT NULL CHECK (year_missing IN (0,1)), + year_value INTEGER NOT NULL, + disc_number INTEGER, + disc_sort INTEGER NOT NULL, + track_number INTEGER, + track_sort INTEGER NOT NULL, + format TEXT NOT NULL, + bit_depth INTEGER, + sample_rate_hz INTEGER, + artwork_token TEXT, + isrc TEXT, + musicbrainz_recording_id TEXT, + added_at INTEGER NOT NULL DEFAULT 0, + available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), + last_observed_generation INTEGER NOT NULL DEFAULT 0, + UNIQUE (source_kind, source_instance, native_track_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_tracks_source_copy + ON tracks(source_copy_id, disc_sort, track_sort, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_local_id + ON tracks(local_track_id) WHERE local_track_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_tracks_isrc + ON tracks(isrc) WHERE isrc IS NOT NULL AND isrc != ''; +CREATE INDEX IF NOT EXISTS idx_tracks_mbid + ON tracks(musicbrainz_recording_id) + WHERE musicbrainz_recording_id IS NOT NULL AND musicbrainz_recording_id != ''; + +-- Every index begins with the default availability predicate and ends in the +-- same catalog_id tie-breaker used by keyset cursors. Query SQL must not drift +-- from these expressions/collations. +CREATE INDEX IF NOT EXISTS idx_tracks_default + ON tracks(available, sort_album, sort_artist, disc_sort, track_sort, sort_title, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_title_asc + ON tracks(available, sort_title, sort_artist, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_title_desc + ON tracks(available, sort_title DESC, sort_artist, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_artist_asc + ON tracks(available, sort_artist, sort_album, disc_sort, track_sort, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_artist_desc + ON tracks(available, sort_artist DESC, sort_album, disc_sort, track_sort, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_group_artist + ON tracks(available, sort_track_artist, sort_album, sort_title, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_year_asc + ON tracks(available, year_missing, year_value, sort_album, disc_sort, track_sort, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_year_desc + ON tracks(available, year_missing, year_value DESC, sort_album, disc_sort, track_sort, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_added_desc + ON tracks(available, added_at DESC, sort_album, disc_sort, track_sort, catalog_id); + +CREATE TABLE IF NOT EXISTS artist_credits ( + catalog_id INTEGER NOT NULL REFERENCES tracks(catalog_id) ON DELETE CASCADE, + artist_key TEXT NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL CHECK ( + role IN ('track_artist','album_artist','composer','performer','featured') + ), + ordinal INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (catalog_id, role, ordinal, artist_key) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_artist_credits_artist + ON artist_credits(artist_key, role, catalog_id); + +-- Canonical identities are rebuilt from the source-faithful credit relation +-- before every materialization. Keeping both relations lets later projection +-- generations re-evaluate corpus-level aliases instead of losing evidence. +CREATE TABLE IF NOT EXISTS artist_identity_credits ( + catalog_id INTEGER NOT NULL REFERENCES tracks(catalog_id) ON DELETE CASCADE, + artist_key TEXT NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL CHECK ( + role IN ('track_artist','album_artist','composer','performer','featured') + ), + ordinal INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (catalog_id, role, ordinal, artist_key) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_artist_identity_credits_artist + ON artist_identity_credits(artist_key, role, catalog_id); + +CREATE TABLE IF NOT EXISTS albums_materialized ( + edition_id INTEGER PRIMARY KEY REFERENCES editions(edition_id) ON DELETE CASCADE, + logical_album_id INTEGER NOT NULL REFERENCES logical_albums(logical_album_id), + title TEXT NOT NULL, + sort_title TEXT NOT NULL, + artist TEXT NOT NULL, + sort_artist TEXT NOT NULL, + year INTEGER, + year_missing INTEGER NOT NULL DEFAULT 1 CHECK (year_missing IN (0,1)), + year_value INTEGER NOT NULL DEFAULT 0, + track_count INTEGER NOT NULL DEFAULT 0, + total_duration_ms INTEGER NOT NULL DEFAULT 0, + source_count INTEGER NOT NULL DEFAULT 0, + available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), + source_kind TEXT NOT NULL DEFAULT 'local', + native_album_id TEXT NOT NULL DEFAULT '', + source_raw TEXT NOT NULL DEFAULT '', + all_artists TEXT NOT NULL DEFAULT '', + format TEXT NOT NULL DEFAULT '', + bit_depth INTEGER, + sample_rate_hz INTEGER, + quality_tier TEXT NOT NULL DEFAULT '', + directory_path TEXT NOT NULL DEFAULT '', + folder_count INTEGER NOT NULL DEFAULT 0, + added_at INTEGER NOT NULL DEFAULT 0, + artwork_source TEXT NOT NULL DEFAULT '', + artwork_token TEXT NOT NULL DEFAULT '' +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_albums_materialized_artist + ON albums_materialized(available, sort_artist, sort_title, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_artist_desc + ON albums_materialized(available, sort_artist DESC, sort_title, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_title + ON albums_materialized(available, sort_title, sort_artist, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_title_desc + ON albums_materialized(available, sort_title DESC, sort_artist, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_year + ON albums_materialized(available, year_missing, year_value, sort_title, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_year_desc + ON albums_materialized(available, year_missing, year_value DESC, sort_title, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_added + ON albums_materialized(available, added_at DESC, sort_title, edition_id); + +CREATE TABLE IF NOT EXISTS artists_materialized ( + artist_key TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + sort_name TEXT NOT NULL, + album_count INTEGER NOT NULL DEFAULT 0, + track_count INTEGER NOT NULL DEFAULT 0, + available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), + source_kind TEXT NOT NULL DEFAULT 'local', + artwork_source TEXT NOT NULL DEFAULT '', + artwork_token TEXT NOT NULL DEFAULT '' +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_artists_materialized_name + ON artists_materialized(available, sort_name, artist_key); + +CREATE TABLE IF NOT EXISTS artist_source_stats ( + artist_key TEXT NOT NULL REFERENCES artists_materialized(artist_key) ON DELETE CASCADE, + source_kind TEXT NOT NULL CHECK ( + source_kind IN ('local','offline','plex','jellyfin','subsonic') + ), + source_instance TEXT NOT NULL, + album_count INTEGER NOT NULL DEFAULT 0, + track_count INTEGER NOT NULL DEFAULT 0, + available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), + PRIMARY KEY (artist_key, source_kind, source_instance) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_artist_source_stats_source + ON artist_source_stats(source_kind, source_instance, available, artist_key); + +CREATE TABLE IF NOT EXISTS edition_artists ( + edition_id INTEGER NOT NULL REFERENCES editions(edition_id) ON DELETE CASCADE, + artist_key TEXT NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (edition_id, artist_key, role) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_edition_artists_artist + ON edition_artists(artist_key, role, edition_id); + +CREATE VIRTUAL TABLE IF NOT EXISTS tracks_fts USING fts5( + title, + album, + artist, + credits, + content='tracks', + content_rowid='catalog_id', + tokenize='trigram case_sensitive 0' +); + +CREATE TRIGGER IF NOT EXISTS tracks_fts_insert AFTER INSERT ON tracks BEGIN + INSERT INTO tracks_fts(rowid, title, album, artist, credits) + VALUES (new.catalog_id, new.title, new.album, new.artist, new.credits); +END; + +CREATE TRIGGER IF NOT EXISTS tracks_fts_delete AFTER DELETE ON tracks BEGIN + INSERT INTO tracks_fts(tracks_fts, rowid, title, album, artist, credits) + VALUES ('delete', old.catalog_id, old.title, old.album, old.artist, old.credits); +END; + +CREATE TRIGGER IF NOT EXISTS tracks_fts_update AFTER UPDATE ON tracks BEGIN + INSERT INTO tracks_fts(tracks_fts, rowid, title, album, artist, credits) + VALUES ('delete', old.catalog_id, old.title, old.album, old.artist, old.credits); + INSERT INTO tracks_fts(rowid, title, album, artist, credits) + VALUES (new.catalog_id, new.title, new.album, new.artist, new.credits); +END; + +CREATE VIRTUAL TABLE IF NOT EXISTS albums_fts USING fts5( + title, + artist, + all_artists, + content='albums_materialized', + content_rowid='edition_id', + tokenize='trigram case_sensitive 0' +); + +CREATE TRIGGER IF NOT EXISTS albums_fts_insert AFTER INSERT ON albums_materialized BEGIN + INSERT INTO albums_fts(rowid, title, artist, all_artists) + VALUES (new.edition_id, new.title, new.artist, new.all_artists); +END; + +CREATE TRIGGER IF NOT EXISTS albums_fts_delete AFTER DELETE ON albums_materialized BEGIN + INSERT INTO albums_fts(albums_fts, rowid, title, artist, all_artists) + VALUES ('delete', old.edition_id, old.title, old.artist, old.all_artists); +END; + +CREATE TRIGGER IF NOT EXISTS albums_fts_update AFTER UPDATE ON albums_materialized BEGIN + INSERT INTO albums_fts(albums_fts, rowid, title, artist, all_artists) + VALUES ('delete', old.edition_id, old.title, old.artist, old.all_artists); + INSERT INTO albums_fts(rowid, title, artist, all_artists) + VALUES (new.edition_id, new.title, new.artist, new.all_artists); +END; + +CREATE VIRTUAL TABLE IF NOT EXISTS artists_fts USING fts5( + artist_key UNINDEXED, + display_name, + tokenize='trigram case_sensitive 0' +); +"#; diff --git a/crates/qbz-local-catalog/src/tests.rs b/crates/qbz-local-catalog/src/tests.rs new file mode 100644 index 000000000..0ba01974e --- /dev/null +++ b/crates/qbz-local-catalog/src/tests.rs @@ -0,0 +1,1529 @@ +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +use crate::*; + +fn source_for(index: usize) -> SourceKind { + match index % 100 { + 0..=59 => SourceKind::Local, + 60..=79 => SourceKind::Plex, + 80..=89 => SourceKind::Jellyfin, + 90..=97 => SourceKind::Subsonic, + _ => SourceKind::Offline, + } +} + +fn source_instance(source: SourceKind) -> &'static str { + match source { + SourceKind::Local => "local-main", + SourceKind::Offline => "qobuz-user-7", + SourceKind::Plex => "plex-server-a", + SourceKind::Jellyfin => "jellyfin-server-b", + SourceKind::Subsonic => "subsonic-server-c", + } +} + +fn projected(index: usize, source: SourceKind) -> ProjectedTrack { + let artist_number = index % 50_000; + let album_number = index % 80_000; + let format = match index % 10 { + 0..=5 => "flac", + 6..=7 => "mp3", + 8 => "alac", + _ => "dsf", + }; + let artist = format!("Artist {artist_number:05}"); + ProjectedTrack { + track_ref: TrackRef { + source, + source_instance: source_instance(source).to_string(), + native_id: index.to_string(), + }, + source_raw: source.as_str().to_string(), + local_track_id: (source == SourceKind::Local).then_some(index as i64 + 1), + local_path: (source == SourceKind::Local) + .then(|| format!("/fixture/artist-{artist_number:05}/track-{index:07}.{format}")), + native_album_id: Some(format!("album-{album_number:05}")), + source_copy_id: None, + title: format!("Track {index:07} Signal {:03}", index % 997), + artist: artist.clone(), + album_artist: artist.clone(), + album: format!("Album {album_number:05}"), + duration_ms: 120_000 + (index % 480_000) as u64, + year: (index % 23 != 0).then_some(1950 + (index % 77) as u32), + disc_number: Some((index % 3 + 1) as u32), + track_number: Some((index % 24 + 1) as u32), + format: format.to_string(), + bit_depth: (format != "mp3").then_some(if index % 3 == 0 { 24 } else { 16 }), + sample_rate_hz: Some(if index % 5 == 0 { 96_000 } else { 44_100 }), + artwork_token: Some(format!("art-{:05}", album_number)), + isrc: (index % 11 == 0).then(|| format!("ISRC{index:08}")), + musicbrainz_recording_id: (index % 101 == 0).then(|| format!("mbid-{index:08}")), + added_at: 1_700_000_000 + index as i64, + available: index % 97 != 0, + observed_generation: 1, + credits: vec![ArtistCredit { + display_name: artist, + role: CreditRole::TrackArtist, + ordinal: 0, + }], + } +} + +fn insert_fixture(catalog: &mut Catalog, count: usize) { + for start in (0..count).step_by(2_000) { + let end = (start + 2_000).min(count); + let batch = (start..end) + .map(|index| projected(index, source_for(index))) + .collect::<Vec<_>>(); + catalog.upsert_tracks(&batch).unwrap(); + } +} + +fn collect_all(catalog: &Catalog, descriptor: &QueryDescriptor) -> Vec<TrackRecord> { + let mut cursor = None; + let mut rows = Vec::new(); + loop { + let page = catalog + .query_tracks(descriptor, cursor.as_ref(), 137) + .unwrap(); + let done = !page.has_more; + cursor = page.next_cursor; + rows.extend(page.rows); + if done { + break; + } + } + rows +} + +fn collect_all_albums( + catalog: &Catalog, + descriptor: &QueryDescriptor, + page_size: usize, +) -> Vec<AlbumRecord> { + let mut cursor = None; + let mut rows = Vec::new(); + loop { + let page = catalog + .query_albums(descriptor, cursor.as_ref(), page_size) + .unwrap(); + rows.extend(page.rows); + if !page.has_more { + break; + } + cursor = page.next_cursor; + } + rows +} + +fn collect_all_artists( + catalog: &Catalog, + descriptor: &QueryDescriptor, + page_size: usize, +) -> Vec<ArtistRecord> { + let mut cursor = None; + let mut rows = Vec::new(); + loop { + let page = catalog + .query_artists(descriptor, cursor.as_ref(), page_size) + .unwrap(); + rows.extend(page.rows); + if !page.has_more { + break; + } + cursor = page.next_cursor; + } + rows +} + +fn insert_album_fixture(catalog: &mut Catalog, count: usize) { + let mut by_source = std::collections::BTreeMap::<SourceKey, Vec<ProjectedTrack>>::new(); + for index in 0..count { + let source = source_for(index); + let track = projected(index, source); + by_source + .entry(SourceKey { + source: track.track_ref.source, + source_instance: track.track_ref.source_instance.clone(), + }) + .or_default() + .push(track); + } + for (source, rows) in by_source { + let version = format!("fixture-{}", source.source.as_str()); + let mut cursor = String::new(); + let total = rows.len(); + for (batch_index, batch) in rows.chunks(BOOTSTRAP_BATCH_ROWS).enumerate() { + let committed = (batch_index * BOOTSTRAP_BATCH_ROWS + batch.len()).min(total); + let saved = catalog + .apply_bootstrap_batch(&BootstrapBatch { + source: source.clone(), + snapshot_version: version.clone(), + expected_cursor: cursor, + next_cursor: committed.to_string(), + tracks: batch.to_vec(), + complete: committed == total, + }) + .unwrap(); + cursor = saved.checkpoint_cursor; + } + } + catalog.rebuild_materialized_views().unwrap(); +} + +#[test] +fn schema_is_versioned_frontend_agnostic_and_fts5_enabled() { + let catalog = Catalog::open_in_memory(7).unwrap(); + let stats = catalog.stats().unwrap(); + assert_eq!(stats.schema_version, crate::SCHEMA_VERSION); + assert_eq!(stats.generation, 7); + assert_eq!(stats.track_count, 0); + let fts5: i64 = catalog + .connection() + .query_row( + "SELECT sqlite_compileoption_used('ENABLE_FTS5')", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(fts5, 1); + let tables: HashSet<String> = catalog + .connection() + .prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::<rusqlite::Result<_>>() + .unwrap(); + for required in [ + "source_state", + "logical_albums", + "editions", + "source_copies", + "tracks", + "artist_credits", + "artist_identity_credits", + "albums_materialized", + "artists_materialized", + "artist_source_stats", + "edition_artists", + "tracks_fts", + "albums_fts", + "artists_fts", + ] { + assert!(tables.contains(required), "missing {required}"); + } +} + +#[test] +fn artist_keyset_search_and_source_scope_cover_every_remote_only_artist_once() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_album_fixture(&mut catalog, 1_231); + + let all = QueryDescriptor::artists(); + let rows = collect_all_artists(&catalog, &all, 17); + let keys = rows + .iter() + .map(|row| row.artist_key.clone()) + .collect::<HashSet<_>>(); + let expected_available = (0..1_231).filter(|index| index % 97 != 0).count(); + assert_eq!(rows.len(), expected_available); + assert_eq!(keys.len(), rows.len()); + assert_eq!(catalog.count_artists(&all).unwrap(), rows.len() as u64); + assert_eq!( + catalog.count_artist_entries(&all).unwrap(), + rows.len() as u64 + 1 + ); + + let jellyfin_source = SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }; + let jellyfin = QueryDescriptor::artists().with_sources(vec![jellyfin_source.clone()]); + let remote = collect_all_artists(&catalog, &jellyfin, 11); + assert!(!remote.is_empty()); + assert!(remote.iter().all(|row| row.source == "jellyfin")); + assert_eq!( + catalog.count_artists(&jellyfin).unwrap(), + remote.len() as u64 + ); + assert!(remote + .iter() + .all(|row| row.album_count == 1 && row.track_count == 1)); + + let needle = remote[0] + .display_name + .chars() + .skip(7) + .take(3) + .collect::<String>(); + let searched = collect_all_artists( + &catalog, + &QueryDescriptor::artists() + .with_sources(vec![jellyfin_source]) + .with_search(needle), + 7, + ); + assert!(!searched.is_empty()); + assert!(searched + .iter() + .all(|row| row.source == "jellyfin" && keys.contains(&row.artist_key))); + let searched_descriptor = QueryDescriptor::artists() + .with_sources(vec![SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }]) + .with_search( + remote[0] + .display_name + .chars() + .skip(7) + .take(3) + .collect::<String>(), + ); + assert_eq!( + catalog.count_artists(&searched_descriptor).unwrap(), + searched.len() as u64 + ); + assert_eq!( + catalog.count_artist_entries(&searched_descriptor).unwrap(), + searched.len() as u64 + 1 + ); +} + +#[test] +fn artist_credit_counts_and_album_relationship_span_every_source() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let mut local = projected(10, SourceKind::Local); + local.native_album_id = Some("local-edition".to_string()); + local.credits.push(ArtistCredit { + display_name: "Beyoncé".to_string(), + role: CreditRole::Featured, + ordinal: 1, + }); + let mut remote = projected(80, SourceKind::Jellyfin); + remote.native_album_id = Some("remote-edition".to_string()); + remote.credits.push(ArtistCredit { + display_name: "Beyonce".to_string(), + role: CreditRole::Performer, + ordinal: 1, + }); + for track in [local, remote] { + let source = SourceKey { + source: track.track_ref.source, + source_instance: track.track_ref.source_instance.clone(), + }; + catalog + .apply_bootstrap_batch(&BootstrapBatch { + source, + snapshot_version: "artist-credit-v1".to_string(), + expected_cursor: String::new(), + next_cursor: "1".to_string(), + tracks: vec![track], + complete: true, + }) + .unwrap(); + } + catalog.rebuild_materialized_views().unwrap(); + + let key = normalize_artist_key("Beyoncé"); + let row = collect_all_artists(&catalog, &QueryDescriptor::artists(), 5) + .into_iter() + .find(|row| row.artist_key == key) + .expect("credit-only artist is materialized"); + assert_eq!(row.album_count, 2); + assert_eq!(row.track_count, 2); + assert_eq!(row.source, "mixed"); + + let sources = vec![ + SourceKey { + source: SourceKind::Local, + source_instance: source_instance(SourceKind::Local).to_string(), + }, + SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }, + ]; + assert_eq!(catalog.count_artist_albums(&key, &sources).unwrap(), 2); + let page = catalog + .query_artist_albums(&key, &sources, None, 1) + .unwrap(); + assert_eq!(page.rows.len(), 1); + assert!(page.has_more); + let second = catalog + .query_artist_albums(&key, &sources, page.next_cursor.as_ref(), 1) + .unwrap(); + assert_eq!(second.rows.len(), 1); + assert!(!second.has_more); + assert_ne!(page.rows[0].edition_id, second.rows[0].edition_id); + + let jellyfin = &sources[1..]; + assert_eq!(catalog.count_artist_albums(&key, jellyfin).unwrap(), 1); + let remote_row = collect_all_artists( + &catalog, + &QueryDescriptor::artists().with_sources(jellyfin.to_vec()), + 5, + ) + .into_iter() + .find(|row| row.artist_key == key) + .unwrap(); + assert_eq!((remote_row.album_count, remote_row.track_count), (1, 1)); + assert_eq!(remote_row.source, "jellyfin"); +} + +#[test] +fn repeated_album_artist_families_collapse_without_losing_contributors() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let family = "新世紀エヴァンゲリオン"; + let contributors = [ + ("鷺巣詩郎", "Shiro Sagisu"), + ("林原めぐみ", "Megumi Hayashibara"), + ("高橋洋子", "Yoko Takahashi"), + ]; + let mut tracks = contributors + .iter() + .enumerate() + .map(|(index, (artist, romanized))| { + let mut track = projected(20_000 + index, SourceKind::Subsonic); + track.native_album_id = Some(format!("eva-{index}")); + track.artist = (*artist).to_string(); + track.album_artist = format!("{family} • {romanized}"); + track.credits = vec![ + ArtistCredit { + display_name: (*artist).to_string(), + role: CreditRole::TrackArtist, + ordinal: 0, + }, + ArtistCredit { + display_name: track.album_artist.clone(), + role: CreditRole::AlbumArtist, + ordinal: 0, + }, + ]; + track + }) + .collect::<Vec<_>>(); + + let mut collaboration = projected(20_010, SourceKind::Subsonic); + collaboration.native_album_id = Some("one-off-collaboration".to_string()); + collaboration.artist = "Alice".to_string(); + collaboration.album_artist = "Alice • Bob".to_string(); + collaboration.credits = vec![ + ArtistCredit { + display_name: "Alice".to_string(), + role: CreditRole::TrackArtist, + ordinal: 0, + }, + ArtistCredit { + display_name: collaboration.album_artist.clone(), + role: CreditRole::AlbumArtist, + ordinal: 0, + }, + ]; + tracks.push(collaboration); + + let source = SourceKey { + source: SourceKind::Subsonic, + source_instance: source_instance(SourceKind::Subsonic).to_string(), + }; + catalog + .apply_bootstrap_batch(&BootstrapBatch { + source, + snapshot_version: "album-artist-families-v1".to_string(), + expected_cursor: String::new(), + next_cursor: tracks.len().to_string(), + tracks, + complete: true, + }) + .unwrap(); + catalog.rebuild_materialized_views().unwrap(); + + let raw_family_variants: i64 = catalog + .connection() + .query_row( + "SELECT COUNT(DISTINCT display_name) FROM artist_credits + WHERE role='album_artist' AND display_name LIKE ?1", + [format!("{family} • %")], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(raw_family_variants, contributors.len() as i64); + catalog.rebuild_materialized_views().unwrap(); + + let artists = collect_all_artists(&catalog, &QueryDescriptor::artists(), 2); + let keys = artists + .iter() + .map(|row| row.artist_key.as_str()) + .collect::<HashSet<_>>(); + let family_key = normalize_artist_key(family); + let family_row = artists + .iter() + .find(|row| row.artist_key == family_key) + .expect("the repeated album-artist family is one root artist"); + assert_eq!((family_row.album_count, family_row.track_count), (3, 3)); + for (artist, romanized) in contributors { + assert!(keys.contains(normalize_artist_key(artist).as_str())); + assert!(!keys.contains(normalize_artist_key(&format!("{family} • {romanized}")).as_str())); + } + assert!(keys.contains(normalize_artist_key("Alice • Bob").as_str())); +} + +#[test] +fn artist_album_keysets_cross_pages_without_omissions_or_duplicates() { + const ALBUMS: usize = 731; + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let source = SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }; + let mut rows = (0..ALBUMS) + .map(|index| { + let mut track = projected(80_000 + index, SourceKind::Jellyfin); + track.native_album_id = Some(format!("guest-album-{index:04}")); + track.album = format!("Guest Album {index:04}"); + track.credits.push(ArtistCredit { + display_name: "Remote Guest".to_string(), + role: CreditRole::Featured, + ordinal: 1, + }); + track + }) + .collect::<Vec<_>>(); + let expected_available = rows.iter().filter(|track| track.available).count(); + let mut cursor = String::new(); + for (batch_index, batch) in rows.chunks_mut(BOOTSTRAP_BATCH_ROWS).enumerate() { + let committed = (batch_index * BOOTSTRAP_BATCH_ROWS + batch.len()).min(ALBUMS); + let saved = catalog + .apply_bootstrap_batch(&BootstrapBatch { + source: source.clone(), + snapshot_version: "artist-albums-731".to_string(), + expected_cursor: cursor, + next_cursor: committed.to_string(), + tracks: batch.to_vec(), + complete: committed == ALBUMS, + }) + .unwrap(); + cursor = saved.checkpoint_cursor; + } + catalog.rebuild_materialized_views().unwrap(); + + let key = normalize_artist_key("Remote Guest"); + assert_eq!( + catalog + .count_artist_albums(&key, &[source.clone()]) + .unwrap(), + expected_available as u64 + ); + let mut cursor = None; + let mut ids = Vec::new(); + loop { + let page = catalog + .query_artist_albums(&key, std::slice::from_ref(&source), cursor.as_ref(), 37) + .unwrap(); + ids.extend(page.rows.into_iter().map(|row| row.edition_id)); + if !page.has_more { + break; + } + cursor = page.next_cursor; + } + assert_eq!(ids.len(), expected_available); + assert_eq!( + ids.iter().copied().collect::<HashSet<_>>().len(), + expected_available + ); +} + +#[test] +fn album_keyset_pages_cover_every_id_once_for_all_orders_and_groups() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_album_fixture(&mut catalog, 731); + + for group in [TrackGroup::Off, TrackGroup::Name, TrackGroup::Artist] { + for sort in [ + TrackSort::ArtistAsc, + TrackSort::ArtistDesc, + TrackSort::TitleAsc, + TrackSort::TitleDesc, + TrackSort::YearAsc, + TrackSort::YearDesc, + TrackSort::AddedDesc, + ] { + let descriptor = QueryDescriptor::albums().with_group(group).with_sort(sort); + let expected = catalog.count_albums(&descriptor).unwrap(); + let rows = collect_all_albums(&catalog, &descriptor, 17); + let ids = rows + .iter() + .map(|row| row.edition_id) + .collect::<HashSet<_>>(); + assert_eq!(rows.len() as u64, expected, "{group:?} {sort:?}"); + assert_eq!(ids.len(), rows.len(), "{group:?} {sort:?}"); + } + } +} + +#[test] +fn album_entry_counts_include_global_headers_and_never_cross_groups() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_album_fixture(&mut catalog, 257); + + for group in [TrackGroup::Off, TrackGroup::Name, TrackGroup::Artist] { + let descriptor = QueryDescriptor::albums() + .with_group(group) + .with_sort(TrackSort::TitleAsc); + let rows = collect_all_albums(&catalog, &descriptor, 19); + for columns in [1, 3, 7] { + let expected = if group == TrackGroup::Off { + rows.len().div_ceil(columns) + } else { + let mut counts = std::collections::BTreeMap::<String, usize>::new(); + for row in &rows { + let key = if group == TrackGroup::Artist { + normalize_sort_key(&row.artist) + } else { + normalize_sort_key(&row.title) + .chars() + .next() + .unwrap_or('#') + .to_string() + }; + *counts.entry(key).or_default() += 1; + } + counts + .values() + .map(|count| 1 + count.div_ceil(columns)) + .sum() + }; + assert_eq!( + catalog.count_album_entries(&descriptor, columns).unwrap(), + expected as u64, + "{group:?} columns={columns}" + ); + } + } +} + +#[test] +fn album_fts_filters_and_descriptor_bound_cursors_are_exact() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_album_fixture(&mut catalog, 1_200); + + let broad = QueryDescriptor::albums().with_search("Album"); + let broad_rows = collect_all_albums(&catalog, &broad, 23); + assert!(broad_rows.len() > 100); + assert_eq!( + broad_rows.len() as u64, + catalog.count_albums(&broad).unwrap() + ); + + let filter_cases: [(QueryDescriptor, fn(&AlbumRecord) -> bool); 5] = [ + ( + QueryDescriptor::albums().with_source_buckets(vec!["plex".to_string()]), + |row: &AlbumRecord| row.source == SourceKind::Plex, + ), + ( + QueryDescriptor::albums().with_formats(vec!["flac".to_string()]), + |row: &AlbumRecord| row.format == "flac", + ), + ( + QueryDescriptor::albums().including_other_formats(true), + |row: &AlbumRecord| { + !["flac", "alac", "ape", "wav", "wave", "mp3", "aac"].contains(&row.format.as_str()) + }, + ), + ( + QueryDescriptor::albums().with_quality_tiers(vec!["hires".to_string()]), + |row: &AlbumRecord| matches!(row.quality_tier.as_str(), "hires" | "max"), + ), + // DSD is a tier of its own: an album holding a DSD copy is 'dsd', + // never folded into hires. + ( + QueryDescriptor::albums().with_quality_tiers(vec!["dsd".to_string()]), + |row: &AlbumRecord| row.quality_tier == "dsd", + ), + ]; + for (descriptor, predicate) in filter_cases { + let rows = collect_all_albums(&catalog, &descriptor, 29); + assert!(!rows.is_empty()); + assert!(rows.iter().all(predicate)); + assert_eq!( + rows.len() as u64, + catalog.count_albums(&descriptor).unwrap() + ); + } + + let title = QueryDescriptor::albums().with_sort(TrackSort::TitleAsc); + let first = catalog.query_albums(&title, None, 5).unwrap(); + let cursor = first.next_cursor.expect("fixture spans multiple pages"); + let different = QueryDescriptor::albums().with_sort(TrackSort::YearDesc); + assert!(matches!( + catalog.query_albums(&different, Some(&cursor), 5), + Err(CatalogError::CursorDescriptorMismatch) + )); +} + +#[test] +fn album_query_and_broad_search_metric_uses_a_mixed_deterministic_fixture() { + const ALBUMS: usize = 20_000; + let mut catalog = Catalog::open_in_memory(9).unwrap(); + insert_album_fixture(&mut catalog, ALBUMS); + + let descriptor = QueryDescriptor::albums().including_unavailable(); + let count_started = Instant::now(); + let count = catalog.count_albums(&descriptor).unwrap(); + let count_time = count_started.elapsed(); + let (page, query) = catalog.query_albums_timed(&descriptor, None, 100).unwrap(); + let broad = descriptor.with_search("Album"); + let broad_count_started = Instant::now(); + let broad_count = catalog.count_albums(&broad).unwrap(); + let broad_count_time = broad_count_started.elapsed(); + let (broad_page, broad_query) = catalog.query_albums_timed(&broad, None, 100).unwrap(); + let counts = catalog + .stats() + .unwrap() + .source_counts + .into_iter() + .map(|(source, rows)| format!("{}={rows}", source.source.as_str())) + .collect::<Vec<_>>() + .join(","); + + assert_eq!(count, ALBUMS as u64); + assert_eq!(page.rows.len(), 100); + assert!(page.has_more); + assert_eq!(broad_count, ALBUMS as u64); + assert_eq!(broad_page.rows.len(), 100); + println!( + "F1_ALBUMS_QUERY total={ALBUMS} first_albums={} count_ms={:.3} query_ms={:.3} broad_count_ms={:.3} broad_query_ms={:.3} source_counts={counts}", + page.rows.len(), + count_time.as_secs_f64() * 1_000.0, + query.sql_time.as_secs_f64() * 1_000.0, + broad_count_time.as_secs_f64() * 1_000.0, + broad_query.sql_time.as_secs_f64() * 1_000.0, + ); +} + +#[test] +fn artist_query_and_broad_search_metric_uses_a_mixed_deterministic_fixture() { + const ARTISTS: usize = 20_000; + let mut catalog = Catalog::open_in_memory(10).unwrap(); + insert_album_fixture(&mut catalog, ARTISTS); + + let descriptor = QueryDescriptor::artists(); + let expected = (0..ARTISTS).filter(|index| index % 97 != 0).count() as u64; + let count_started = Instant::now(); + let count = catalog.count_artists(&descriptor).unwrap(); + let count_time = count_started.elapsed(); + let (page, query) = catalog.query_artists_timed(&descriptor, None, 100).unwrap(); + let broad = descriptor.clone().with_search("Artist"); + let broad_count_started = Instant::now(); + let broad_count = catalog.count_artists(&broad).unwrap(); + let broad_count_time = broad_count_started.elapsed(); + let (broad_page, broad_query) = catalog.query_artists_timed(&broad, None, 100).unwrap(); + let source_counts = [ + SourceKind::Local, + SourceKind::Offline, + SourceKind::Plex, + SourceKind::Jellyfin, + SourceKind::Subsonic, + ] + .into_iter() + .map(|source| { + let scoped = QueryDescriptor::artists().with_sources(vec![SourceKey { + source, + source_instance: source_instance(source).to_string(), + }]); + format!( + "{}={}", + source.as_str(), + catalog.count_artists(&scoped).unwrap() + ) + }) + .collect::<Vec<_>>() + .join(","); + + assert_eq!(count, expected); + assert_eq!(page.rows.len(), 100); + assert!(page.has_more); + assert_eq!(broad_count, expected); + assert_eq!(broad_page.rows.len(), 100); + assert_eq!( + catalog.count_artist_entries(&descriptor).unwrap(), + expected + 1 + ); + + let plan = catalog + .connection() + .prepare( + "EXPLAIN QUERY PLAN SELECT artist_key FROM artists_materialized + WHERE available=1 ORDER BY sort_name,artist_key LIMIT 101", + ) + .unwrap() + .query_map([], |row| row.get::<_, String>(3)) + .unwrap() + .collect::<rusqlite::Result<Vec<_>>>() + .unwrap(); + assert!( + plan.iter().all(|step| !step.contains("TEMP B-TREE")), + "{plan:?}" + ); + + println!( + "F2_ARTISTS_QUERY total={count} first_artists={} count_ms={:.3} query_ms={:.3} broad_count_ms={:.3} broad_query_ms={:.3} source_counts={source_counts}", + page.rows.len(), + count_time.as_secs_f64() * 1_000.0, + query.sql_time.as_secs_f64() * 1_000.0, + broad_count_time.as_secs_f64() * 1_000.0, + broad_query.sql_time.as_secs_f64() * 1_000.0, + ); +} + +#[test] +fn opening_an_unrelated_sqlite_file_is_refused_without_mutating_it() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("authoritative.db"); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection + .execute("CREATE TABLE authoritative_secret(value TEXT)", []) + .unwrap(); + drop(connection); + + assert!(matches!( + Catalog::open(&path, 1), + Err(CatalogError::NotCatalog) + )); + let connection = rusqlite::Connection::open(&path).unwrap(); + let catalog_tables: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name = 'catalog_meta'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(catalog_tables, 0); +} + +#[test] +fn active_catalog_runtime_integrity_probe_is_read_only_and_complete() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("local_catalog-v1-g1.db"); + let mut building = Catalog::open(&path, 1).unwrap(); + insert_album_fixture(&mut building, 321); + drop(building); + + let active = Catalog::open_read_only(&path, 1).unwrap(); + let report = active.runtime_integrity_check().unwrap(); + assert!(report.sqlite_ok, "{report:?}"); + assert_eq!(report.foreign_key_violations, 0); + assert!(report.materialized_views_ok); +} + +#[test] +fn identical_native_ids_from_distinct_sources_never_collide() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let mut local = projected(42, SourceKind::Local); + let mut plex = projected(43, SourceKind::Plex); + local.source_raw = "qobuz_purchase".to_string(); + local.track_ref.native_id = "same".to_string(); + plex.track_ref.native_id = "same".to_string(); + catalog + .upsert_tracks(&[local.clone(), plex.clone()]) + .unwrap(); + + assert_eq!(catalog.stats().unwrap().track_count, 2); + assert_eq!( + catalog + .resolve(&local.track_ref) + .unwrap() + .unwrap() + .track_ref, + local.track_ref + ); + assert_eq!( + catalog.resolve(&plex.track_ref).unwrap().unwrap().track_ref, + plex.track_ref + ); + let resolved = catalog.resolve(&local.track_ref).unwrap().unwrap(); + assert_eq!(resolved.source_raw, "qobuz_purchase"); +} + +#[test] +fn album_records_preserve_their_physical_source_words() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let mut local = projected(1, SourceKind::Local); + local.source_raw = "user".to_string(); + let mut jellyfin = projected(2, SourceKind::Jellyfin); + jellyfin.source_raw = "jellyfin".to_string(); + for (source, track) in [ + ( + SourceKey { + source: SourceKind::Local, + source_instance: source_instance(SourceKind::Local).to_string(), + }, + local, + ), + ( + SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }, + jellyfin, + ), + ] { + catalog + .apply_bootstrap_batch(&BootstrapBatch { + source, + snapshot_version: "source-words".to_string(), + expected_cursor: String::new(), + next_cursor: "1".to_string(), + tracks: vec![track], + complete: true, + }) + .unwrap(); + } + catalog.rebuild_materialized_views().unwrap(); + + let rows = collect_all_albums(&catalog, &QueryDescriptor::albums(), 20); + assert_eq!(rows.len(), 2); + assert!(rows + .iter() + .any(|row| row.source_words == vec!["user".to_string()])); + assert!(rows + .iter() + .any(|row| row.source_words == vec!["jellyfin".to_string()])); +} + +#[test] +fn upsert_retains_identity_and_fts_tracks_updates_and_deletes() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let mut track = projected(1, SourceKind::Jellyfin); + track.title = "Old Needle".to_string(); + catalog.upsert_tracks(&[track.clone()]).unwrap(); + assert_eq!( + catalog + .count_tracks(&QueryDescriptor::tracks().with_search("Old Needle")) + .unwrap(), + 1 + ); + + track.title = "New Signal".to_string(); + catalog.upsert_tracks(&[track.clone()]).unwrap(); + assert_eq!(catalog.stats().unwrap().track_count, 1); + assert_eq!( + catalog + .count_tracks(&QueryDescriptor::tracks().with_search("Old Needle")) + .unwrap(), + 0 + ); + assert_eq!( + catalog + .count_tracks(&QueryDescriptor::tracks().with_search("New Signal")) + .unwrap(), + 1 + ); + assert!(catalog.remove_track(&track.track_ref).unwrap()); + assert_eq!(catalog.stats().unwrap().track_count, 0); + let integrity = catalog.integrity_check().unwrap(); + assert!(integrity.sqlite_ok && integrity.fts_ok); + assert_eq!(integrity.foreign_key_violations, 0); +} + +#[test] +fn reconciliation_keeps_album_and_artist_fts_in_sync_across_update_and_delete() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let source = SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }; + let mut track = projected(7, SourceKind::Jellyfin); + track.album = "Old Album Needle".to_string(); + track.album_artist = "Old Artist Needle".to_string(); + track.artist = track.album_artist.clone(); + track.credits = vec![ArtistCredit { + display_name: track.artist.clone(), + role: CreditRole::TrackArtist, + ordinal: 0, + }]; + + catalog.begin_reconciliation(&source, "snapshot-1").unwrap(); + catalog + .apply_reconciliation_batch(&ReconciliationBatch { + source: source.clone(), + snapshot_version: "snapshot-1".to_string(), + expected_cursor: String::new(), + next_cursor: "1".to_string(), + tracks: vec![track.clone()], + complete: true, + }) + .unwrap(); + catalog.rebuild_materialized_views().unwrap(); + assert_eq!( + catalog + .count_albums(&QueryDescriptor::albums().with_search("Old Album Needle")) + .unwrap(), + 1 + ); + assert_eq!( + catalog + .count_artists(&QueryDescriptor::artists().with_search("Old Artist Needle")) + .unwrap(), + 1 + ); + + track.album = "New Album Signal".to_string(); + track.album_artist = "New Artist Signal".to_string(); + track.artist = track.album_artist.clone(); + track.credits[0].display_name = track.artist.clone(); + catalog.begin_reconciliation(&source, "snapshot-2").unwrap(); + catalog + .apply_reconciliation_batch(&ReconciliationBatch { + source: source.clone(), + snapshot_version: "snapshot-2".to_string(), + expected_cursor: String::new(), + next_cursor: "1".to_string(), + tracks: vec![track], + complete: true, + }) + .unwrap(); + catalog.rebuild_materialized_views().unwrap(); + assert_eq!( + catalog + .count_albums(&QueryDescriptor::albums().with_search("Old Album Needle")) + .unwrap(), + 0 + ); + assert_eq!( + catalog + .count_artists(&QueryDescriptor::artists().with_search("Old Artist Needle")) + .unwrap(), + 0 + ); + assert_eq!( + catalog + .count_albums(&QueryDescriptor::albums().with_search("New Album Signal")) + .unwrap(), + 1 + ); + assert_eq!( + catalog + .count_artists(&QueryDescriptor::artists().with_search("New Artist Signal")) + .unwrap(), + 1 + ); + + catalog.begin_reconciliation(&source, "snapshot-3").unwrap(); + catalog + .apply_reconciliation_batch(&ReconciliationBatch { + source, + snapshot_version: "snapshot-3".to_string(), + expected_cursor: String::new(), + next_cursor: String::new(), + tracks: Vec::new(), + complete: true, + }) + .unwrap(); + catalog.rebuild_materialized_views().unwrap(); + assert_eq!(catalog.stats().unwrap().track_count, 0); + assert_eq!( + catalog + .count_albums(&QueryDescriptor::albums().with_search("New Album Signal")) + .unwrap(), + 0 + ); + assert_eq!( + catalog + .count_artists(&QueryDescriptor::artists().with_search("New Artist Signal")) + .unwrap(), + 0 + ); + let integrity = catalog.integrity_check().unwrap(); + assert!(integrity.sqlite_ok && integrity.fts_ok); + assert_eq!(integrity.foreign_key_violations, 0); +} + +#[test] +fn artist_keys_preserve_roles_fold_diacritics_and_keep_non_latin_names() { + assert_eq!(normalize_artist_key("Beyoncé & Co."), "beyonce co"); + assert_eq!(normalize_artist_key("宇多田 ヒカル"), "宇多田 ヒカル"); + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let mut track = projected(3, SourceKind::Subsonic); + track.credits = vec![ + ArtistCredit { + display_name: "Beyoncé".to_string(), + role: CreditRole::TrackArtist, + ordinal: 0, + }, + ArtistCredit { + display_name: "Guest".to_string(), + role: CreditRole::Featured, + ordinal: 1, + }, + ]; + catalog.upsert_tracks(&[track]).unwrap(); + let credits: Vec<(String, String)> = catalog + .connection() + .prepare("SELECT artist_key, role FROM artist_credits ORDER BY ordinal") + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::<rusqlite::Result<_>>() + .unwrap(); + assert_eq!( + credits, + vec![ + ("beyonce".to_string(), "track_artist".to_string()), + ("guest".to_string(), "featured".to_string()) + ] + ); +} + +#[test] +fn every_sort_uses_keysets_without_omissions_or_duplicates() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_fixture(&mut catalog, 1_701); + let expected = catalog.count_tracks(&QueryDescriptor::tracks()).unwrap() as usize; + for sort in [ + TrackSort::Default, + TrackSort::TitleAsc, + TrackSort::TitleDesc, + TrackSort::ArtistAsc, + TrackSort::ArtistDesc, + TrackSort::YearAsc, + TrackSort::YearDesc, + TrackSort::AddedDesc, + ] { + let rows = collect_all(&catalog, &QueryDescriptor::tracks().with_sort(sort)); + let ids: HashSet<TrackRef> = rows.iter().map(|row| row.track_ref.clone()).collect(); + assert_eq!(rows.len(), expected, "sort {sort:?}"); + assert_eq!(ids.len(), rows.len(), "sort {sort:?}"); + } +} + +#[test] +fn search_source_format_availability_and_group_are_descriptor_scoped() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_fixture(&mut catalog, 2_500); + let search = QueryDescriptor::tracks().with_search("Signal 042"); + assert!(catalog.count_tracks(&search).unwrap() > 0); + assert!(matches!( + catalog.count_tracks(&QueryDescriptor::tracks().with_search("Si")), + Err(CatalogError::SearchTooShort) + )); + + let plex_flac = QueryDescriptor::tracks() + .with_sources(vec![SourceKey { + source: SourceKind::Plex, + source_instance: source_instance(SourceKind::Plex).to_string(), + }]) + .with_formats(vec!["FLAC".to_string()]); + let rows = collect_all(&catalog, &plex_flac); + assert!(!rows.is_empty()); + assert!(rows.iter().all(|row| { + row.track_ref.source == SourceKind::Plex && row.format == "flac" && row.available + })); + + let available = catalog.count_tracks(&QueryDescriptor::tracks()).unwrap(); + let all = catalog + .count_tracks(&QueryDescriptor::tracks().including_unavailable()) + .unwrap(); + assert!(all > available); + + let grouped = collect_all( + &catalog, + &QueryDescriptor::tracks() + .with_sort(TrackSort::AddedDesc) + .with_group(TrackGroup::Artist), + ); + assert_eq!(grouped.len(), available as usize); + + let first = catalog + .query_tracks(&QueryDescriptor::tracks(), None, 50) + .unwrap(); + let cursor = first.next_cursor.expect("fixture has another page"); + assert!(matches!( + catalog.query_tracks( + &QueryDescriptor::tracks().with_formats(vec!["flac".to_string()]), + Some(&cursor), + 50, + ), + Err(CatalogError::CursorDescriptorMismatch) + )); + assert!(matches!( + catalog.count_tracks(&QueryDescriptor::albums()), + Err(CatalogError::InvalidInput(_)) + )); +} + +#[test] +fn track_source_bucket_quality_and_other_format_filters_match_the_ui_funnel() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_fixture(&mut catalog, 2_500); + + // Downloaded Qobuz copies can be owned by the Local catalog source. The + // UI nevertheless presents them through Offline, based on source_raw. + let mut purchased = projected(9_999, SourceKind::Local); + purchased.track_ref.native_id = "local-qobuz-purchase".to_string(); + purchased.source_raw = "qobuz_purchase".to_string(); + purchased.available = true; + catalog.upsert_tracks(&[purchased]).unwrap(); + + let local = QueryDescriptor::tracks().with_source_buckets(vec!["local".to_string()]); + let local_rows = collect_all(&catalog, &local); + assert!(!local_rows.is_empty()); + assert!(local_rows.iter().all(|row| { + row.track_ref.source == SourceKind::Local + && !matches!(row.source_raw.as_str(), "qobuz_download" | "qobuz_purchase") + })); + assert_eq!( + catalog.count_tracks(&local).unwrap(), + local_rows.len() as u64 + ); + + let offline = QueryDescriptor::tracks().with_source_buckets(vec!["offline".to_string()]); + let offline_rows = collect_all(&catalog, &offline); + assert!(offline_rows + .iter() + .any(|row| row.source_raw == "qobuz_purchase")); + assert!(offline_rows.iter().all(|row| { + row.track_ref.source == SourceKind::Offline + || matches!(row.source_raw.as_str(), "qobuz_download" | "qobuz_purchase") + })); + assert_eq!( + catalog.count_tracks(&offline).unwrap(), + offline_rows.len() as u64 + ); + + let plex_hires_flac = QueryDescriptor::tracks() + .with_source_buckets(vec!["plex".to_string()]) + .with_formats(vec!["flac".to_string()]) + .with_quality_tiers(vec!["hires".to_string()]); + let plex_rows = collect_all(&catalog, &plex_hires_flac); + assert!(!plex_rows.is_empty()); + assert!(plex_rows.iter().all(|row| { + row.track_ref.source == SourceKind::Plex + && row.format == "flac" + && row.bit_depth.is_some_and(|depth| depth >= 24) + })); + assert_eq!( + catalog.count_tracks(&plex_hires_flac).unwrap(), + plex_rows.len() as u64 + ); + + let other = QueryDescriptor::tracks().including_other_formats(true); + let other_rows = collect_all(&catalog, &other); + assert!(!other_rows.is_empty()); + assert!(other_rows.iter().all(|row| row.format == "dsf")); + assert_eq!( + catalog.count_tracks(&other).unwrap(), + other_rows.len() as u64 + ); + + // The DSD quality chip is the fixture's dsf rows exactly, and Hi-Res no + // longer folds them in. + let dsd = QueryDescriptor::tracks().with_quality_tiers(vec!["dsd".to_string()]); + let dsd_rows = collect_all(&catalog, &dsd); + assert_eq!(dsd_rows.len(), other_rows.len()); + assert!(dsd_rows.iter().all(|row| row.format == "dsf")); + let hires = QueryDescriptor::tracks().with_quality_tiers(vec!["hires".to_string()]); + assert!(collect_all(&catalog, &hires) + .iter() + .all(|row| row.format != "dsf")); +} + +#[test] +fn artist_group_uses_track_artist_globally_across_keyset_pages() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let mut zed = projected(1, SourceKind::Local); + zed.artist = "Zed Performer".to_string(); + zed.album_artist = "Alpha Album Artist".to_string(); + let mut alpha = projected(2, SourceKind::Plex); + alpha.artist = "Alpha Performer".to_string(); + alpha.album_artist = "Zed Album Artist".to_string(); + catalog.upsert_tracks(&[zed, alpha]).unwrap(); + + let descriptor = QueryDescriptor::tracks().with_group(TrackGroup::Artist); + let first = catalog.query_tracks(&descriptor, None, 1).unwrap(); + assert_eq!(first.rows[0].artist, "Alpha Performer"); + let cursor = first.next_cursor.expect("second grouped page"); + assert_eq!(cursor.group_key(TrackGroup::Artist), "alpha performer"); + let second = catalog.query_tracks(&descriptor, Some(&cursor), 1).unwrap(); + assert_eq!(second.rows[0].artist, "Zed Performer"); + assert!(!second.has_more); +} + +#[test] +fn album_hierarchy_keeps_weak_same_name_matches_reversible() { + let catalog = Catalog::open_in_memory(1).unwrap(); + catalog + .connection() + .execute_batch( + "INSERT INTO logical_albums + (stable_key,display_title,sort_title,display_artist,sort_artist, + association_strength,association_evidence) + VALUES + ('plex:a','Album','album','Artist','artist','source_native','plex:a'), + ('jellyfin:b','Album','album','Artist','artist','source_native','jellyfin:b'); + INSERT INTO editions + (logical_album_id,edition_key,display_title,display_artist,evidence_kind,evidence_value) + SELECT logical_album_id, stable_key || ':edition', display_title, display_artist, + 'source_native', stable_key + FROM logical_albums; + INSERT INTO source_copies + (edition_id,source_kind,source_instance,native_album_id) + SELECT edition_id, + CASE WHEN edition_key LIKE 'plex:%' THEN 'plex' ELSE 'jellyfin' END, + CASE WHEN edition_key LIKE 'plex:%' THEN 'plex-a' ELSE 'jellyfin-b' END, + edition_key + FROM editions;", + ) + .unwrap(); + let counts: (i64, i64, i64) = catalog + .connection() + .query_row( + "SELECT + (SELECT COUNT(*) FROM logical_albums), + (SELECT COUNT(*) FROM editions), + (SELECT COUNT(*) FROM source_copies)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(counts, (2, 2, 2)); +} + +#[test] +fn sort_indices_match_the_actual_order_by_without_temp_sort() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_fixture(&mut catalog, 100); + for (index, order) in [ + ( + "idx_tracks_default", + "sort_album,sort_artist,disc_sort,track_sort,sort_title,catalog_id", + ), + ("idx_tracks_title_asc", "sort_title,sort_artist,catalog_id"), + ( + "idx_tracks_title_desc", + "sort_title DESC,sort_artist,catalog_id", + ), + ( + "idx_tracks_artist_asc", + "sort_artist,sort_album,disc_sort,track_sort,catalog_id", + ), + ( + "idx_tracks_artist_desc", + "sort_artist DESC,sort_album,disc_sort,track_sort,catalog_id", + ), + ( + "idx_tracks_year_asc", + "year_missing,year_value,sort_album,disc_sort,track_sort,catalog_id", + ), + ( + "idx_tracks_year_desc", + "year_missing,year_value DESC,sort_album,disc_sort,track_sort,catalog_id", + ), + ( + "idx_tracks_added_desc", + "added_at DESC,sort_album,disc_sort,track_sort,catalog_id", + ), + ( + "idx_tracks_group_artist", + "sort_track_artist,sort_album,sort_title,catalog_id", + ), + ] { + let sql = format!( + "EXPLAIN QUERY PLAN SELECT catalog_id FROM tracks + WHERE available=1 ORDER BY {order} LIMIT 250" + ); + let details = catalog + .connection() + .prepare(&sql) + .unwrap() + .query_map([], |row| row.get::<_, String>(3)) + .unwrap() + .collect::<rusqlite::Result<Vec<_>>>() + .unwrap() + .join(" | "); + assert!(details.contains(index), "{details}"); + assert!(!details.contains("TEMP B-TREE"), "{details}"); + } + + let fts_plan = catalog + .connection() + .prepare( + "EXPLAIN QUERY PLAN + SELECT t.catalog_id + FROM tracks_fts + CROSS JOIN tracks t NOT INDEXED + WHERE tracks_fts MATCH '\"Signal 042\"' + AND t.catalog_id=tracks_fts.rowid + AND t.available=1 + ORDER BY t.sort_album,t.sort_artist,t.disc_sort,t.track_sort, + t.sort_title,t.catalog_id + LIMIT 250", + ) + .unwrap() + .query_map([], |row| row.get::<_, String>(3)) + .unwrap() + .collect::<rusqlite::Result<Vec<_>>>() + .unwrap() + .join(" | "); + assert!(fts_plan.contains("VIRTUAL TABLE INDEX"), "{fts_plan}"); + assert!(fts_plan.contains("INTEGER PRIMARY KEY"), "{fts_plan}"); +} + +#[test] +fn album_sort_indices_match_the_paged_order_by_without_temp_sort() { + let catalog = Catalog::open_in_memory(1).unwrap(); + for (index, order) in [ + ( + "idx_albums_materialized_artist", + "sort_artist,sort_title,edition_id", + ), + ( + "idx_albums_materialized_artist_desc", + "sort_artist DESC,sort_title,edition_id", + ), + ( + "idx_albums_materialized_title", + "sort_title,sort_artist,edition_id", + ), + ( + "idx_albums_materialized_title_desc", + "sort_title DESC,sort_artist,edition_id", + ), + ( + "idx_albums_materialized_year", + "year_missing,year_value,sort_title,edition_id", + ), + ( + "idx_albums_materialized_year_desc", + "year_missing,year_value DESC,sort_title,edition_id", + ), + ( + "idx_albums_materialized_added", + "added_at DESC,sort_title,edition_id", + ), + ] { + let sql = format!( + "EXPLAIN QUERY PLAN SELECT edition_id FROM albums_materialized + WHERE available=1 ORDER BY {order} LIMIT 100" + ); + let details = catalog + .connection() + .prepare(&sql) + .unwrap() + .query_map([], |row| row.get::<_, String>(3)) + .unwrap() + .collect::<rusqlite::Result<Vec<_>>>() + .unwrap() + .join(" | "); + assert!(details.contains(index), "{details}"); + assert!(!details.contains("TEMP B-TREE"), "{details}"); + } +} + +/// The contract's reproducible scale gate. It is ignored in ordinary unit +/// runs because it intentionally creates and removes a several-hundred-MiB +/// database; the implementation handoff records an explicit execution. +#[test] +#[ignore = "explicit 1M catalog fixture and query benchmark"] +fn fixture_one_million_tracks_meets_the_query_shape_gate() { + const TRACKS: usize = 1_000_000; + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("local_catalog-v1-g1.db.building"); + let mut catalog = Catalog::open(&path, 1).unwrap(); + let insert_started = Instant::now(); + for start in (0..TRACKS).step_by(5_000) { + let end = (start + 5_000).min(TRACKS); + let batch = (start..end) + .map(|index| projected(index, source_for(index))) + .collect::<Vec<_>>(); + catalog.upsert_tracks(&batch).unwrap(); + if end % 100_000 == 0 { + println!("[catalog-1m] projected={end}"); + } + } + let insert_time = insert_started.elapsed(); + catalog + .connection() + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .unwrap(); + let stats = catalog.stats().unwrap(); + assert_eq!(stats.track_count, TRACKS as u64); + let integrity = catalog.integrity_check().unwrap(); + assert!(integrity.sqlite_ok && integrity.fts_ok); + assert_eq!(integrity.foreign_key_violations, 0); + + let default = QueryDescriptor::tracks(); + let (_, first_warmup) = catalog.query_tracks_timed(&default, None, 250).unwrap(); + let (_, first_warm) = catalog.query_tracks_timed(&default, None, 250).unwrap(); + let broad_fts = QueryDescriptor::tracks().with_search("Signal 731"); + let (broad_page, broad_fts_cold) = catalog.query_tracks_timed(&broad_fts, None, 250).unwrap(); + assert_eq!(broad_page.rows.len(), 250); + let (_, broad_fts_warm) = catalog.query_tracks_timed(&broad_fts, None, 250).unwrap(); + let selective_fts = QueryDescriptor::tracks().with_search("0731042"); + let (selective_page, selective_fts_cold) = catalog + .query_tracks_timed(&selective_fts, None, 250) + .unwrap(); + assert_eq!(selective_page.rows.len(), 1); + let (_, selective_fts_warm) = catalog + .query_tracks_timed(&selective_fts, None, 250) + .unwrap(); + + let deep_cursor = catalog + .connection() + .query_row( + "SELECT sort_title,sort_artist,sort_track_artist,sort_album, + year_missing,year_value,disc_sort,track_sort,added_at,catalog_id + FROM tracks WHERE available=1 + ORDER BY sort_title,sort_artist,catalog_id + LIMIT 1 OFFSET 800000", + [], + |row| { + Ok(TrackCursor { + sort: TrackSort::TitleAsc, + descriptor_key: crate::catalog::descriptor_key( + &QueryDescriptor::tracks().with_sort(TrackSort::TitleAsc), + ), + sort_title: row.get(0)?, + sort_artist: row.get(1)?, + sort_track_artist: row.get(2)?, + sort_album: row.get(3)?, + year_missing: row.get(4)?, + year_value: row.get(5)?, + disc_sort: row.get(6)?, + track_sort: row.get(7)?, + added_at: row.get(8)?, + row_id: row.get(9)?, + }) + }, + ) + .unwrap(); + let title = QueryDescriptor::tracks().with_sort(TrackSort::TitleAsc); + let (_, deep_metrics) = catalog + .query_tracks_timed(&title, Some(&deep_cursor), 250) + .unwrap(); + + drop(catalog); + let reopened = Catalog::open(&path, 1).unwrap(); + let (_, connection_cold) = reopened.query_tracks_timed(&default, None, 250).unwrap(); + let file_bytes = std::fs::metadata(&path).unwrap().len(); + println!( + "[catalog-1m] rows={TRACKS} db_bytes={file_bytes} insert_s={:.3} first_warmup_ms={:.3} first_warm_ms={:.3} broad_fts_cold_ms={:.3} broad_fts_warm_ms={:.3} selective_fts_cold_ms={:.3} selective_fts_warm_ms={:.3} deep_keyset_ms={:.3} connection_cold_ms={:.3}", + insert_time.as_secs_f64(), + first_warmup.sql_time.as_secs_f64() * 1000.0, + first_warm.sql_time.as_secs_f64() * 1000.0, + broad_fts_cold.sql_time.as_secs_f64() * 1000.0, + broad_fts_warm.sql_time.as_secs_f64() * 1000.0, + selective_fts_cold.sql_time.as_secs_f64() * 1000.0, + selective_fts_warm.sql_time.as_secs_f64() * 1000.0, + deep_metrics.sql_time.as_secs_f64() * 1000.0, + connection_cold.sql_time.as_secs_f64() * 1000.0, + ); + + assert!(first_warm.sql_time <= Duration::from_millis(50)); + assert!(broad_fts_warm.sql_time <= Duration::from_millis(250)); + assert!(selective_fts_warm.sql_time <= Duration::from_millis(50)); + assert!(deep_metrics.sql_time <= Duration::from_millis(100)); + assert!(connection_cold.sql_time <= Duration::from_millis(250)); +} diff --git a/crates/qbz-log/src/bundle.rs b/crates/qbz-log/src/bundle.rs index 3ac985252..891cdc38b 100644 --- a/crates/qbz-log/src/bundle.rs +++ b/crates/qbz-log/src/bundle.rs @@ -1,7 +1,7 @@ //! GitHub-ready diagnostics bundle formatter. //! -//! Produces a collapsible `<details>` markdown block (a bold field header + a fenced -//! ```log block of the last N lines) suitable for pasting straight into a GitHub issue. +//! Produces a collapsible `<details>` markdown block (a bold field header + a +//! log-language fenced block of the last N lines) for pasting into a GitHub issue. //! Lines are redacted defensively even though the ring already holds redacted text. use crate::line::LogLine; @@ -20,11 +20,30 @@ pub struct DiagFields<'a> { } /// Format a GitHub-ready diagnostics bundle: a `<details>` wrapper, a bold field header, -/// and a fenced ```log block of the last `max_lines.min(last_lines.len())` lines. +/// and a log-language fenced block of the last `max_lines.min(last_lines.len())` lines. pub fn format_diagnostics_bundle( f: &DiagFields, last_lines: &[LogLine], max_lines: usize, +) -> String { + format_diagnostics_bundle_with_report(f, "", last_lines, max_lines) +} + +/// [`format_diagnostics_bundle`] with a full diagnostics REPORT injected between +/// the eight-field header and the log block. +/// +/// This exists because the eight fields above are all a frontend can produce on +/// its own, and a bug report wants the ~70 the Diagnostics panel gathers (kernel, +/// distro, install method, GPU, exclusive-mode / DAC / sample-rate state, +/// playback context). The caller renders that markdown — this only wraps it, so +/// the `<details><summary>` collapsible that makes the paste readable on GitHub +/// survives. Pass `""` for no report; [`format_diagnostics_bundle`] is exactly +/// that call. +pub fn format_diagnostics_bundle_with_report( + f: &DiagFields, + report: &str, + last_lines: &[LogLine], + max_lines: usize, ) -> String { let n = max_lines.min(last_lines.len()); let start = last_lines.len() - n; @@ -42,6 +61,11 @@ pub fn format_diagnostics_bundle( "**Locale:** {} **Log level:** {}\n", f.locale, f.log_level )); + if !report.trim().is_empty() { + out.push('\n'); + out.push_str(report.trim_end()); + out.push('\n'); + } out.push_str("\n```log\n"); for line in tail { out.push_str(&format!( @@ -111,4 +135,39 @@ mod tests { assert!(out.contains("line 7")); assert!(!out.contains("line 6")); } + + #[test] + fn report_lands_between_header_and_log_block() { + let lines = vec![LogLine { + ts: 1, + level: Level::Info, + target: "t".into(), + message: "hello".into(), + }]; + let report = "# qbz diagnostics\n\n- **Kernel:** 7.1.6\n"; + let out = format_diagnostics_bundle_with_report(&fields(), report, &lines, 200); + + let header = out.find("**Audio backend:**").expect("header"); + let body = out.find("- **Kernel:**").expect("report body"); + let block = out.find("```log").expect("log block"); + assert!(header < body && body < block, "wrong order: {out}"); + // The collapsible survives — that is the whole reason this wraps + // rather than replaces. + assert!(out.contains("<details><summary>qbz diagnostics</summary>")); + assert!(out.trim_end().ends_with("</details>")); + } + + #[test] + fn empty_report_is_byte_identical_to_the_plain_bundle() { + let lines = vec![LogLine { + ts: 1, + level: Level::Info, + target: "t".into(), + message: "hello".into(), + }]; + assert_eq!( + format_diagnostics_bundle(&fields(), &lines, 200), + format_diagnostics_bundle_with_report(&fields(), " \n ", &lines, 200) + ); + } } diff --git a/crates/qbz-log/src/install.rs b/crates/qbz-log/src/install.rs index 8941b3f3a..eb14a9099 100644 --- a/crates/qbz-log/src/install.rs +++ b/crates/qbz-log/src/install.rs @@ -4,7 +4,6 @@ use std::fs::File; use std::io::BufWriter; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Mutex; use crate::tee::TeeLogger; @@ -29,15 +28,16 @@ pub fn install(default_level: &str) { // in #555 logs) — and each suppressed record now costs nothing, since // `log!` checks the filter before formatting. An explicit RUST_LOG still // replaces the whole default, so full zbus tracing stays one env var away. - let inner = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or( - format!("{default_level},zbus=warn,tracing=warn"), - )) + let inner = env_logger::Builder::from_env( + env_logger::Env::default() + .default_filter_or(format!("{default_level},zbus=warn,tracing=warn")), + ) .build(); let level = inner.filter(); let file = open_log_file(); // Ignore the Err if a logger was somehow already set elsewhere. - if log::set_boxed_logger(Box::new(TeeLogger { inner, file })).is_ok() { + if log::set_boxed_logger(Box::new(TeeLogger::new(inner, file))).is_ok() { log::set_max_level(level); } } @@ -54,7 +54,7 @@ pub fn log_file_path() -> Option<PathBuf> { /// Open the log file for this run, rotating any previous one to `qbz.log.prev`. /// Returns `None` (file sink disabled, gracefully) on any filesystem error. -fn open_log_file() -> Option<Mutex<BufWriter<File>>> { +fn open_log_file() -> Option<BufWriter<File>> { let path = log_file_path()?; let dir = path.parent()?; std::fs::create_dir_all(dir).ok()?; @@ -66,5 +66,5 @@ fn open_log_file() -> Option<Mutex<BufWriter<File>>> { } let file = File::create(&path).ok()?; - Some(Mutex::new(BufWriter::new(file))) + Some(BufWriter::new(file)) } diff --git a/crates/qbz-log/src/lib.rs b/crates/qbz-log/src/lib.rs index 12d3571ee..23d83e4d6 100644 --- a/crates/qbz-log/src/lib.rs +++ b/crates/qbz-log/src/lib.rs @@ -1,13 +1,17 @@ //! `qbz-log` — frontend-agnostic logging core for the qbz desktop client. //! //! It owns a composite [`log::Log`] implementation ([`tee::TeeLogger`]) that wraps -//! `env_logger`'s built `Logger` and fans every record out to three sinks: +//! `env_logger`'s built `Logger` and fans visible records out to three sinks: //! 1. **stderr** (redacted text, same line format as the file sink), //! 2. a bounded **in-memory ring** ([`ring`], cap [`ring::RING_CAP`]), and //! 3. an **on-disk file** (`~/.local/share/qbz/logs/qbz.log`, prev-rotated at startup). //! //! Secret **redaction** ([`redact`]) is applied once at the single write choke point, //! so every downstream consumer (stderr, ring, file, clipboard, paste upload) gets clean text. +//! Consecutive identical redacted records retain their first line and periodic +//! count/duration summaries; any different visible record ends the run. All three +//! sinks receive the same serialized sequence. Call `log::logger().flush()` before +//! normal exit to emit a final pending summary and flush the file. //! //! This crate is network-free and UI-free: no `reqwest`, no `tokio`, no `slint`. @@ -15,6 +19,7 @@ pub mod bundle; pub mod install; pub mod line; pub mod redact; +mod repeat; pub mod ring; pub mod tee; diff --git a/crates/qbz-log/src/redact.rs b/crates/qbz-log/src/redact.rs index 7dff22648..043691d92 100644 --- a/crates/qbz-log/src/redact.rs +++ b/crates/qbz-log/src/redact.rs @@ -78,6 +78,40 @@ fn has_redaction_candidate(lower: &str) -> bool { NEEDLES.iter().any(|n| lower.contains(n)) } +/// IPv4 octet (0-255) — the range guard is what keeps version strings +/// ("1.16.1", "8.2.0") and timestamps out of the mask. +const OCTET: &str = r"(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])"; + +/// IP-masking patterns (owner 2026-07-24): ANY address — LAN or public — is +/// masked to its LAST segment only (`192.168.178.211` → `*.*.*.211`), so +/// uploaded logs stop leaking the user's network topology. The port survives. +/// +/// IPv6 groups must START with a 4-hex-digit group — that is what keeps time +/// strings ("11:41:48.054") and MAC addresses out of the mask (regex has no +/// lookaround, so the width anchor does the disambiguation). +fn ip_patterns() -> &'static Vec<Regex> { + static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new(); + PATTERNS.get_or_init(|| { + [ + // IPv4: mask the first three octets, keep the last (capture 1). + format!(r"\b(?:{OCTET}\.){{3}}({OCTET})\b"), + // IPv6 full form (first group 4 hex wide — see the doc above). + r"\b[0-9a-fA-F]{4}:(?:[0-9a-fA-F]{1,4}:){1,6}([0-9a-fA-F]{1,4})\b".to_string(), + // IPv6 `::`-compressed form (same 4-wide anchor). + r"\b[0-9a-fA-F]{4}(?::[0-9a-fA-F]{0,4})*::(?:[0-9a-fA-F]{1,4}:)*([0-9a-fA-F]{1,4})\b".to_string(), + ] + .iter() + .filter_map(|p| Regex::new(p).ok()) + .collect() + }) +} + +/// Cheap pre-check for the IP layer: a dot or colon and a digit, O(n). (The +/// colon is required — IPv6 has no dots.) +fn has_ip_candidate(line: &str) -> bool { + (line.contains('.') || line.contains(':')) && line.bytes().any(|b| b.is_ascii_digit()) +} + /// Redact secrets from a single log line. Literal live-secret layer first, then regex. pub fn redact(line: &str) -> String { let mut out = line.to_string(); @@ -103,6 +137,16 @@ pub fn redact(line: &str) -> String { } } + // Layer 3 — IP masking (`*.*.*.<last>` for v4, `*:*:<last>` for v6). + if has_ip_candidate(&out) { + for (i, re) in ip_patterns().iter().enumerate() { + if re.is_match(&out) { + let replacement = if i == 0 { "*.*.*.${1}" } else { "*:*:${1}" }; + out = re.replace_all(&out, replacement).into_owned(); + } + } + } + out } @@ -141,4 +185,38 @@ mod tests { let r = redact("value abc here"); assert!(r.contains("abc"), "short value should not be scrubbed: {r}"); } + + #[test] + fn masks_ipv4_keep_last_octet() { + let r = redact("DLNA: Set URI to http://192.168.178.211:9876/audio/***/235314822"); + assert_eq!( + r, + "DLNA: Set URI to http://*.*.*.211:9876/audio/***/235314822" + ); + // Public addresses get the same treatment. + let r = redact("dial 203.0.113.9 failed"); + assert!(r.contains("*.*.*.9"), "public IPv4 survived: {r}"); + assert!(!r.contains("203.0.113"), "public IPv4 leaked: {r}"); + } + + #[test] + fn ip_mask_leaves_versions_and_timestamps_alone() { + for s in [ + "2026-07-24 11:41:48.054 INFO qbz", + "bundle 8.2.0-b034", + "slint 1.16.1", + "kernel 6.17.0-40-generic", + "rate 44.1kHz 24-bit / 192 kHz", + ] { + assert_eq!(redact(s), s, "non-IP was mangled: {s}"); + } + } + + #[test] + fn masks_ipv6() { + let r = redact("bind fe80:0000:0000:0000:0224:a4ff:fe12:3456 up"); + assert!(r.contains("*:*:3456"), "full IPv6 survived: {r}"); + let r2 = redact("bind fe80::224:a4ff:fe12:3456 up"); + assert!(r2.contains("*:*:3456"), "compressed IPv6 survived: {r2}"); + } } diff --git a/crates/qbz-log/src/repeat.rs b/crates/qbz-log/src/repeat.rs new file mode 100644 index 000000000..8e32fa4d6 --- /dev/null +++ b/crates/qbz-log/src/repeat.rs @@ -0,0 +1,263 @@ +//! One bounded run of consecutive, already-redacted log records. + +use std::time::{Duration, Instant}; + +use crate::line::LogLine; + +/// Continuing bursts remain visible without printing every duplicate. +const SUMMARY_INTERVAL: Duration = Duration::from_secs(5); + +#[derive(Default)] +pub(crate) struct ConsecutiveRecords { + current: Option<RepeatRun>, +} + +struct RepeatRun { + line: LogLine, + period_started: Instant, + last_seen: Instant, + repeats: u64, +} + +impl RepeatRun { + fn matches(&self, line: &LogLine) -> bool { + self.line.level == line.level + && self.line.target == line.target + && self.line.message == line.message + } + + fn summary(&self) -> Option<LogLine> { + (self.repeats > 0).then(|| LogLine { + ts: self.line.ts, + level: self.line.level, + target: self.line.target.clone(), + message: format!( + "[log] previous message repeated {} additional times over {} ms: {}", + self.repeats, + self.last_seen + .saturating_duration_since(self.period_started) + .as_millis(), + self.line.message, + ), + }) + } +} + +impl ConsecutiveRecords { + /// Accept only visible, already-redacted records. The caller serializes this + /// state transition AND its output to every sink under the same lock. + /// Output is at most the previous summary followed by the new record. + pub(crate) fn push(&mut self, line: LogLine, now: Instant) -> [Option<LogLine>; 2] { + if let Some(run) = self.current.as_mut().filter(|run| run.matches(&line)) { + run.line.ts = line.ts; + run.last_seen = now; + run.repeats = run.repeats.saturating_add(1); + if now.saturating_duration_since(run.period_started) >= SUMMARY_INTERVAL { + let summary = run.summary(); + run.repeats = 0; + run.period_started = now; + return [summary, None]; + } + return [None, None]; + } + + let summary = self.flush(); + self.current = Some(RepeatRun { + line: line.clone(), + period_started: now, + last_seen: now, + repeats: 0, + }); + [summary, Some(line)] + } + + /// Flush ends the run, so the next record is always visible in full. + pub(crate) fn flush(&mut self) -> Option<LogLine> { + self.current.take().and_then(|run| run.summary()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use log::Level; + + fn line(ts: i64, level: Level, target: &str, message: &str) -> LogLine { + LogLine { + ts, + level, + target: target.into(), + message: message.into(), + } + } + + fn info(ts: i64, message: &str) -> LogLine { + line(ts, Level::Info, "qbz", message) + } + + fn messages(lines: [Option<LogLine>; 2]) -> Vec<String> { + lines + .into_iter() + .flatten() + .map(|line| line.message) + .collect() + } + + #[test] + fn first_record_is_visible_and_consecutive_duplicates_are_counted() { + let mut records = ConsecutiveRecords::default(); + let start = Instant::now(); + assert_eq!( + messages(records.push(info(0, "loop mode"), start)), + ["loop mode"] + ); + for ms in [90, 180, 270] { + assert!(messages(records.push( + info(ms, "loop mode"), + start + Duration::from_millis(ms as u64), + )) + .is_empty()); + } + let output = records.push( + info(300, "renderer changed"), + start + Duration::from_millis(300), + ); + assert_eq!(output[0].as_ref().unwrap().ts, 270); + assert_eq!( + messages(output), + [ + "[log] previous message repeated 3 additional times over 270 ms: loop mode", + "renderer changed", + ] + ); + assert_eq!( + messages(records.push(info(400, "loop mode"), start + Duration::from_millis(400))), + ["loop mode"] + ); + } + + #[test] + fn level_and_target_changes_end_the_run_even_with_identical_text() { + for (level, target) in [(Level::Warn, "qbz"), (Level::Info, "qconnect")] { + let mut records = ConsecutiveRecords::default(); + let start = Instant::now(); + records.push(info(0, "same"), start); + records.push(info(1, "same"), start + Duration::from_millis(1)); + let output = records.push( + line(2, level, target, "same"), + start + Duration::from_millis(2), + ); + let summary = output[0].as_ref().unwrap(); + assert_eq!(summary.level, Level::Info); + assert_eq!(summary.target, "qbz"); + let changed = output[1].as_ref().unwrap(); + assert_eq!(changed.level, level); + assert_eq!(changed.target, target); + assert_eq!(changed.message, "same"); + } + } + + #[test] + fn warning_and_error_runs_preserve_severity_and_counts() { + for level in [Level::Warn, Level::Error] { + let mut records = ConsecutiveRecords::default(); + let start = Instant::now(); + let output = records.push(line(0, level, "qbz", "failed"), start); + assert_eq!(output[1].as_ref().unwrap().level, level); + records.push( + line(10, level, "qbz", "failed"), + start + Duration::from_millis(10), + ); + let summary = records.flush().unwrap(); + assert_eq!(summary.level, level); + assert_eq!( + summary.message, + "[log] previous message repeated 1 additional times over 10 ms: failed" + ); + } + } + + #[test] + fn periodic_summaries_count_only_new_repetitions_and_retain_the_key() { + let mut records = ConsecutiveRecords::default(); + let start = Instant::now(); + records.push(info(0, "busy"), start); + for second in 1..=11 { + let output = messages(records.push( + info(second * 1000, "busy"), + start + Duration::from_secs(second as u64), + )); + if second % 5 == 0 { + assert_eq!( + output, + ["[log] previous message repeated 5 additional times over 5000 ms: busy"] + ); + } else { + assert!(output.is_empty()); + } + } + assert_eq!( + records.flush().unwrap().message, + "[log] previous message repeated 1 additional times over 1000 ms: busy" + ); + assert!(records.flush().is_none()); + } + + #[test] + fn flush_resets_the_run_and_does_not_invent_duplicates() { + let mut records = ConsecutiveRecords::default(); + let start = Instant::now(); + assert!(records.flush().is_none()); + records.push(info(0, "a"), start); + assert!(records.flush().is_none()); + assert_eq!(messages(records.push(info(1, "a"), start)), ["a"]); + records.push(info(2, "a"), start); + assert!(records.flush().is_some()); + assert_eq!(messages(records.push(info(3, "a"), start)), ["a"]); + assert_eq!(messages(records.push(info(4, "b"), start)), ["b"]); + } + + #[test] + fn different_record_after_periodic_summary_does_not_repeat_the_count() { + let mut records = ConsecutiveRecords::default(); + let start = Instant::now(); + records.push(info(0, "a"), start); + let output = records.push(info(5000, "a"), start + Duration::from_secs(5)); + assert_eq!(messages(output).len(), 1); + assert_eq!( + messages(records.push(info(5001, "b"), start + Duration::from_millis(5001))), + ["b"] + ); + assert!(records.flush().is_none()); + } + + #[test] + fn duration_is_monotonic_and_ends_at_last_duplicate_not_later_message() { + let mut records = ConsecutiveRecords::default(); + let start = Instant::now(); + records.push(info(1000, "a"), start); + // A wall-clock correction must not produce a negative or giant interval. + records.push(info(500, "a"), start + Duration::from_millis(90)); + let output = records.push(info(9000, "b"), start + Duration::from_secs(10)); + let summary = output[0].as_ref().unwrap(); + assert_eq!(summary.ts, 500); + assert_eq!( + summary.message, + "[log] previous message repeated 1 additional times over 90 ms: a" + ); + } + + #[test] + fn redacted_records_share_a_key_without_retaining_raw_secrets() { + let mut records = ConsecutiveRecords::default(); + let start = Instant::now(); + let first = crate::redact("token=SEKRET_ONE"); + let second = crate::redact("token=SEKRET_TWO"); + assert_eq!(first, second); + assert_eq!(messages(records.push(info(0, &first), start)), [first]); + assert!(messages(records.push(info(1, &second), start)).is_empty()); + let summary = records.flush().unwrap(); + assert!(summary.message.contains("***REDACTED***")); + assert!(!summary.message.contains("SEKRET")); + } +} diff --git a/crates/qbz-log/src/tee.rs b/crates/qbz-log/src/tee.rs index adfc8632b..b46aa0c8d 100644 --- a/crates/qbz-log/src/tee.rs +++ b/crates/qbz-log/src/tee.rs @@ -3,18 +3,53 @@ use std::fs::File; use std::io::{BufWriter, Write}; use std::sync::Mutex; +use std::time::Instant; use log::{Log, Metadata, Record}; use crate::line::LogLine; +use crate::repeat::ConsecutiveRecords; use crate::{redact, ring}; /// Wraps `env_logger`'s built `Logger` and tees every record to the in-memory ring and /// (optionally) the on-disk log file, with secret redaction applied once at this single /// write choke point. **All** sinks (ring, file, and stderr) receive the redacted text. +/// Consecutive duplicate records are summarized with their original severity, +/// target, redacted message, repetition count and monotonic elapsed time. pub struct TeeLogger { - pub(crate) inner: env_logger::Logger, - pub(crate) file: Option<Mutex<BufWriter<File>>>, + inner: env_logger::Logger, + // One lock covers grouping AND all sink writes: concurrent threads cannot + // interleave a summary and its next record or reorder the file vs. the ring. + output: Mutex<Output>, +} + +struct Output { + file: Option<BufWriter<File>>, + consecutive: ConsecutiveRecords, +} + +impl TeeLogger { + pub(crate) fn new(inner: env_logger::Logger, file: Option<BufWriter<File>>) -> Self { + Self { + inner, + output: Mutex::new(Output { + file, + consecutive: ConsecutiveRecords::default(), + }), + } + } +} + +impl Output { + fn write(&mut self, line: LogLine) { + let formatted = format_line(&line); + ring::push(line); + if let Some(writer) = &mut self.file { + let _ = writeln!(writer, "{formatted}"); + } + // Never delegate to inner.log(record): it would bypass redaction. + let _ = writeln!(std::io::stderr(), "{formatted}"); + } } fn now_epoch_ms() -> i64 { @@ -45,33 +80,35 @@ impl Log for TeeLogger { // Redact ONCE; every downstream sink gets the cleaned text. let msg = redact::redact(&record.args().to_string()); + let mut output = self.output.lock().unwrap_or_else(|p| p.into_inner()); let line = LogLine { ts: now_epoch_ms(), level: record.level(), target: record.target().to_owned(), - message: msg.clone(), + message: msg, }; - - ring::push(line.clone()); - - if let Some(file) = &self.file { - if let Ok(mut writer) = file.lock() { - let _ = writeln!(writer, "{}", format_line(&line)); + let [summary, first] = output.consecutive.push(line, Instant::now()); + let summarized = summary.is_some(); + for line in [summary, first].into_iter().flatten() { + output.write(line); + } + // Compaction may no longer fill BufWriter for minutes. Make periodic + // summaries visible to a live file tail as well as stderr/the ring. + if summarized { + if let Some(writer) = &mut output.file { + let _ = writer.flush(); } } - - // stderr: write the redacted line ourselves. Delegating to - // `self.inner.log(record)` would reprint the *original* Record args - // and bypass redaction (terminal transcripts, CI logs, support dumps). - let _ = writeln!(std::io::stderr(), "{}", format_line(&line)); } fn flush(&self) { + let mut output = self.output.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(summary) = output.consecutive.flush() { + output.write(summary); + } self.inner.flush(); - if let Some(file) = &self.file { - if let Ok(mut writer) = file.lock() { - let _ = writer.flush(); - } + if let Some(writer) = &mut output.file { + let _ = writer.flush(); } let _ = std::io::stderr().flush(); } diff --git a/crates/qbz-lyrics/Cargo.toml b/crates/qbz-lyrics/Cargo.toml index 7d87b0328..1b89ab39b 100644 --- a/crates/qbz-lyrics/Cargo.toml +++ b/crates/qbz-lyrics/Cargo.toml @@ -10,7 +10,7 @@ authors = ["blitzkriegfc"] qbz-qobuz = { path = "../qbz-qobuz" } # Database (same pin as every other QBZ crate) -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } # HTTP (external fallback providers: LRCLIB, lyrics.ovh) reqwest = { workspace = true } diff --git a/crates/qbz-media-cache/Cargo.toml b/crates/qbz-media-cache/Cargo.toml new file mode 100644 index 000000000..06d8b29f2 --- /dev/null +++ b/crates/qbz-media-cache/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "qbz-media-cache" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "One SQLite cache for every remote media server QBZ reads (Jellyfin, Subsonic, and Plex when it migrates)" + +[dependencies] +rusqlite = { version = "0.40", features = ["bundled"] } +serde_json.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/qbz-media-cache/src/lib.rs b/crates/qbz-media-cache/src/lib.rs new file mode 100644 index 000000000..25cc8dea9 --- /dev/null +++ b/crates/qbz-media-cache/src/lib.rs @@ -0,0 +1,2078 @@ +//! ONE SQLite cache for every remote media server QBZ reads. +//! +//! A remote source cannot be queried at scroll speed, so its library is mirrored +//! locally and the Local Library grid `ATTACH`es that mirror and `UNION`s it with +//! `local_tracks`. That mechanism already exists — for Plex, by name: +//! +//! ```text +//! qbz-library/src/database.rs:2097 +//! get_albums_metadata_page(…, plex_cache_path: Option<&Path>, …) +//! ATTACH DATABASE '<path>' AS plex_cache +//! FROM plex_cache.plex_cache_tracks // twice +//! ``` +//! +//! Copying that shape per source means three `Option<&Path>` parameters and six +//! UNION arms in a SHARED crate, and every future source pays the tax again. +//! This crate is the generic form: one table with a `source` discriminator, one +//! ATTACH, one UNION arm. +//! +//! # Why Plex is NOT migrated in the same change +//! +//! The schema below is `plex_cache_tracks` with its columns renamed — every one +//! of its 19 columns maps 1:1 onto Jellyfin and Subsonic, which is what made +//! "generic" the right call rather than a guess. Folding Plex in is therefore a +//! rename plus a data migration, not a redesign. +//! +//! It is still a migration of a table that ships with a user's data — 17 145 +//! rows on the owner's install — and doing it in the same change that introduces +//! two new sources would mean one commit where a regression could come from +//! either. So: this table serves Jellyfin and Subsonic first and proves itself +//! with two sources; Plex moves in as its own change, with its own verification, +//! once it has. Until then the union carries two ATTACHes instead of one, which +//! is a cost paid in one SQL builder rather than a risk taken with a live cache. +//! +//! # Identity: the namespaced row id +//! +//! The Local Library speaks in `i64` row ids, so a remote track needs one that +//! can never collide with a real `local_tracks.id`. The existing scheme is a +//! high bit per source: +//! +//! | source | floor | payload | +//! |---|---|---| +//! | Plex (`local_plex::PLEX_TRACK_ID_FLOOR`) | `1 << 40` | hashed rating key, 40 bits | +//! | ephemeral (`qbz_library::ephemeral`) | `1 << 48` | session counter | +//! | **Jellyfin** | `1 << 41` | this cache's rowid | +//! | **Subsonic** | `1 << 42` | this cache's rowid | +//! +//! Payloads are masked to 40 bits, so the ranges are disjoint by construction. +//! +//! The payload is **this table's `AUTOINCREMENT` rowid, not a hash of the +//! server's id.** Plex had to hash because its ids predate its cache, and that +//! hash is why `PlexSource::resolve_cache_row_id` has to scan to invert it. A +//! new table has no such debt: the mapping is a primary-key lookup in both +//! directions and cannot collide. With ~50 000 tracks a 40-bit hash carries +//! roughly a 0.1 % chance of at least one collision, and a collision here plays +//! the WRONG TRACK — which is precisely the class of bug the source seam exists +//! to remove, so it is not worth reintroducing for a marginally simpler write. + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +use rusqlite::{params, Connection, OptionalExtension}; + +/// Namespace bit for Jellyfin row ids. +pub const JELLYFIN_ID_FLOOR: i64 = 1 << 41; +/// Namespace bit for Subsonic row ids. +pub const SUBSONIC_ID_FLOOR: i64 = 1 << 42; +/// Payload width. Matches Plex's, so every source's payload occupies the same +/// low 40 bits and the floors stay disjoint. +pub const ID_PAYLOAD_BITS: u32 = 40; +const PAYLOAD_MASK: i64 = (1 << ID_PAYLOAD_BITS) - 1; + +// Jellyfin, Subsonic, and the quality hydrator own separate SQLite connections +// to the same cache file. WAL keeps their readers concurrent, but SQLite still +// permits only one writer. Serializing the short write transactions here avoids +// two deferred transactions colliding on their first UPDATE during Resync all. +// The SQLite busy timeout below remains the fallback for another QBZ process. +static WRITE_LOCK: Mutex<()> = Mutex::new(()); + +fn lock_writer() -> MutexGuard<'static, ()> { + WRITE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Which remote source a cached row came from. The wire values are the same +/// words `qbz_source::SourceId` uses, so nothing has to translate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RemoteSource { + Jellyfin, + Subsonic, +} + +impl RemoteSource { + pub const fn as_str(self) -> &'static str { + match self { + RemoteSource::Jellyfin => "jellyfin", + RemoteSource::Subsonic => "subsonic", + } + } + + pub fn from_word(w: &str) -> Option<Self> { + match w { + "jellyfin" => Some(RemoteSource::Jellyfin), + "subsonic" => Some(RemoteSource::Subsonic), + _ => None, + } + } + + pub const fn floor(self) -> i64 { + match self { + RemoteSource::Jellyfin => JELLYFIN_ID_FLOOR, + RemoteSource::Subsonic => SUBSONIC_ID_FLOOR, + } + } + + /// Namespace a cache rowid for the Local Library's `i64` space. + pub fn namespace(self, rowid: i64) -> i64 { + self.floor() | (rowid & PAYLOAD_MASK) + } + + /// Which source owns a namespaced id, if any. + /// + /// Tested against the OTHER floors on purpose: bit 40 is Plex's and bit 48 + /// is the ephemeral store's, and a predicate that answered "mine" for + /// either would route a row to the wrong source. + pub fn of_id(id: i64) -> Option<Self> { + for s in [RemoteSource::Jellyfin, RemoteSource::Subsonic] { + let floor = s.floor(); + if id & floor != 0 && id & !(floor | PAYLOAD_MASK) == 0 { + return Some(s); + } + } + None + } + + /// Recover the cache rowid from a namespaced id. + pub fn rowid_of(id: i64) -> i64 { + id & PAYLOAD_MASK + } +} + +/// One cached track, as this crate stores and returns it. +/// +/// Deliberately flat and owned: it crosses a `spawn_blocking` boundary on every +/// read, and a borrowed shape would tie it to the connection's lifetime. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CachedTrack { + /// The namespaced `i64` the Local Library uses. Assigned by this crate; + /// zero until the row is written. + pub id: i64, + pub source: String, + /// The server's OWN id for this track, opaque. The only thing that can be + /// handed back to the server. + pub item_id: String, + pub server_id: String, + pub library_id: String, + pub title: String, + pub artist: String, + pub album_artist: String, + pub album: String, + /// The server's album id — the grouping key for the album view. + pub album_id: String, + pub track_number: Option<u32>, + pub disc_number: Option<u32>, + pub duration_ms: u64, + pub year: Option<u32>, + /// Complete genre set when the protocol exposes one. The singular field + /// remains the compatibility primary value and migration fallback. + pub genres: Vec<String>, + pub genre: Option<String>, + pub container: String, + pub codec: Option<String>, + /// `None` for lossy. Both wire sentinels (Jellyfin's null, Subsonic's 0) + /// are folded before they reach here. + pub bit_depth: Option<u32>, + pub sample_rate_hz: Option<u32>, + pub channels: Option<u32>, + pub bitrate_kbps: Option<u32>, + /// Cross-source identity when the server exposes it (Jellyfin + /// `ProviderIds.MusicBrainzTrack`, OpenSubsonic `musicBrainzId` / + /// `isrc`). `None` otherwise; never synthesised. + pub isrc: Option<String>, + pub recording_mbid: Option<String>, + /// The RAW artwork token as the server issued it — a Jellyfin image tag, a + /// Subsonic `coverArt` id. Never a URL: a URL embeds credentials and a + /// size, and both change while the token does not. + pub artwork_token: Option<String>, + /// Album/collection artwork kept separately from the item token. Servers + /// can expose a box cover and different artwork for each disc; combining + /// those layers makes one overwrite the other during synchronization. + pub collection_artwork_token: Option<String>, + pub size_bytes: Option<u64>, +} + +/// Quality-only update from a secondary server hydration pass. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CachedTrackQuality { + pub item_id: String, + pub container: String, + pub codec: Option<String>, + pub bit_depth: Option<u32>, + pub sample_rate_hz: Option<u32>, + pub channels: Option<u32>, + pub bitrate_kbps: Option<u32>, +} + +/// One source sync generation. The authoritative cache remains readable while +/// a newer generation is incomplete; only completion may prune older rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SourceSyncGeneration { + pub generation: u64, + pub observed_rows: u64, + pub resumed: bool, +} + +/// One source-local artist aggregate for the Local Library rail. +/// +/// A row is emitted for both a track artist and a distinct album artist. The +/// Qt-side normalizer then folds spelling variants and merges these aggregates +/// with local/Plex rows. Keeping this aggregation in SQLite avoids loading a +/// whole remote track cache merely to answer artist counts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CachedArtist { + pub name: String, + pub album_count: u32, + pub track_count: u32, +} + +/// One cached library / music folder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CachedLibrary { + pub source: String, + pub library_id: String, + pub name: String, + pub server_id: String, +} + +pub type Result<T> = std::result::Result<T, String>; + +fn map_err<E: std::fmt::Display>(what: &str) -> impl Fn(E) -> String + '_ { + move |e| format!("media cache: {what}: {e}") +} + +/// Open (creating if needed) the cache at `path` and bring its schema up to date. +/// +/// WAL, like every other QBZ database (ADR-002): the scan writes while the grid +/// reads, and without WAL the grid blocks behind the sync. +pub fn open(path: &Path) -> Result<Connection> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(map_err("create dir"))?; + } + let conn = Connection::open(path).map_err(map_err("open"))?; + conn.busy_timeout(Duration::from_secs(10)) + .map_err(map_err("busy timeout"))?; + init_schema(&conn)?; + Ok(conn) +} + +/// The schema. Split out so tests can drive an in-memory connection. +pub fn init_schema(conn: &Connection) -> Result<()> { + let _writer = lock_writer(); + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(map_err("pragmas"))?; + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS remote_cache_libraries ( + source TEXT NOT NULL, + library_id TEXT NOT NULL, + name TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + PRIMARY KEY (source, library_id) + ); + + -- `id INTEGER PRIMARY KEY AUTOINCREMENT` is load-bearing: it is the + -- payload of the namespaced Local Library id, so it must never be + -- REUSED after a delete. Plain `INTEGER PRIMARY KEY` reuses the highest + -- freed rowid, which would silently point an already-published id at a + -- different track. + CREATE TABLE IF NOT EXISTS remote_cache_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + item_id TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT '', + library_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + artist TEXT NOT NULL DEFAULT '', + album_artist TEXT NOT NULL DEFAULT '', + album TEXT NOT NULL DEFAULT '', + album_id TEXT NOT NULL DEFAULT '', + track_number INTEGER, + disc_number INTEGER, + duration_ms INTEGER NOT NULL DEFAULT 0, + year INTEGER, + genre TEXT, + genres_json TEXT NOT NULL DEFAULT '[]', + container TEXT NOT NULL DEFAULT '', + codec TEXT, + bit_depth INTEGER, + sample_rate_hz INTEGER, + channels INTEGER, + bitrate_kbps INTEGER, + artwork_token TEXT, + collection_artwork_token TEXT, + size_bytes INTEGER, + observed_generation INTEGER NOT NULL DEFAULT 0, + quality_hydrated INTEGER NOT NULL DEFAULT 0, + quality_retry_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + UNIQUE (source, item_id) + ); + + CREATE TABLE IF NOT EXISTS remote_cache_source_sync ( + source TEXT PRIMARY KEY, + generation INTEGER NOT NULL DEFAULT 0, + observed_rows INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'idle', + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_rct_source_album + ON remote_cache_tracks(source, album_id); + CREATE INDEX IF NOT EXISTS idx_rct_source_library + ON remote_cache_tracks(source, library_id); + -- The Local Library grid sorts and filters on these two. + CREATE INDEX IF NOT EXISTS idx_rct_album_artist + ON remote_cache_tracks(album_artist); + CREATE INDEX IF NOT EXISTS idx_rct_title ON remote_cache_tracks(title); + ", + ) + .map_err(map_err("schema"))?; + + // Forward-only additive migration for caches created before incremental + // source generations and deferred Jellyfin quality existed. + for column in [ + "genres_json TEXT NOT NULL DEFAULT '[]'", + "observed_generation INTEGER NOT NULL DEFAULT 0", + "quality_hydrated INTEGER NOT NULL DEFAULT 0", + "quality_retry_at INTEGER NOT NULL DEFAULT 0", + "collection_artwork_token TEXT", + // Identity (2026-08-28): the server's MusicBrainz recording id and + // ISRC when it has them. NULL until the next sync re-reads the rows. + "isrc TEXT", + "recording_mbid TEXT", + ] { + let statement = format!("ALTER TABLE remote_cache_tracks ADD COLUMN {column}"); + let _ = conn.execute(&statement, []); + } + let _ = conn.execute( + "ALTER TABLE remote_cache_source_sync + ADD COLUMN observed_rows INTEGER NOT NULL DEFAULT 0", + [], + ); + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_rct_quality_pending + ON remote_cache_tracks(source,quality_hydrated,quality_retry_at,updated_at)", + [], + ) + .map_err(map_err("quality index"))?; + Ok(()) +} + +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn row_to_track(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedTrack> { + let source: String = row.get("source")?; + let rowid: i64 = row.get("id")?; + let id = RemoteSource::from_word(&source) + .map(|s| s.namespace(rowid)) + .unwrap_or(rowid); + let genre: Option<String> = row.get("genre")?; + let mut genres = serde_json::from_str::<Vec<String>>(&row.get::<_, String>("genres_json")?) + .unwrap_or_default(); + if genres.is_empty() { + if let Some(value) = genre.as_ref().filter(|value| !value.trim().is_empty()) { + genres.push(value.trim().to_string()); + } + } + Ok(CachedTrack { + id, + source, + item_id: row.get("item_id")?, + server_id: row.get("server_id")?, + library_id: row.get("library_id")?, + title: row.get("title")?, + artist: row.get("artist")?, + album_artist: row.get("album_artist")?, + album: row.get("album")?, + album_id: row.get("album_id")?, + track_number: row.get::<_, Option<i64>>("track_number")?.map(|v| v as u32), + disc_number: row.get::<_, Option<i64>>("disc_number")?.map(|v| v as u32), + duration_ms: row.get::<_, i64>("duration_ms")? as u64, + year: row.get::<_, Option<i64>>("year")?.map(|v| v as u32), + genres, + genre, + container: row.get("container")?, + codec: row.get("codec")?, + bit_depth: row.get::<_, Option<i64>>("bit_depth")?.map(|v| v as u32), + sample_rate_hz: row + .get::<_, Option<i64>>("sample_rate_hz")? + .map(|v| v as u32), + channels: row.get::<_, Option<i64>>("channels")?.map(|v| v as u32), + bitrate_kbps: row.get::<_, Option<i64>>("bitrate_kbps")?.map(|v| v as u32), + isrc: row.get::<_, Option<String>>("isrc")?.filter(|v| !v.is_empty()), + recording_mbid: row + .get::<_, Option<String>>("recording_mbid")? + .filter(|v| !v.is_empty()), + artwork_token: row.get("artwork_token")?, + collection_artwork_token: row.get("collection_artwork_token")?, + size_bytes: row.get::<_, Option<i64>>("size_bytes")?.map(|v| v as u64), + }) +} + +const SELECT: &str = "SELECT id, source, item_id, server_id, library_id, title, artist, \ + album_artist, album, album_id, track_number, disc_number, duration_ms, year, genre, genres_json, \ + container, codec, bit_depth, sample_rate_hz, channels, bitrate_kbps, artwork_token, \ + collection_artwork_token, size_bytes, isrc, recording_mbid FROM remote_cache_tracks"; + +fn genres_json(track: &CachedTrack) -> String { + let mut genres = Vec::<String>::new(); + for value in track.genres.iter().chain(track.genre.iter()) { + let value = value.trim(); + if !value.is_empty() + && !genres + .iter() + .any(|existing| existing.eq_ignore_ascii_case(value)) + { + genres.push(value.to_string()); + } + } + serde_json::to_string(&genres).unwrap_or_else(|_| "[]".to_string()) +} + +/// UPSERT a batch of tracks in ONE transaction. +/// +/// `ON CONFLICT (source, item_id) DO UPDATE` rather than delete-then-insert: +/// the row id is a published identity (it is inside every queue entry and every +/// `session.db` row), so a re-scan must not mint a new one for a track that was +/// already there. A user who re-syncs while a Jellyfin track is playing would +/// otherwise find the queue pointing at nothing. +pub fn save_tracks( + conn: &mut Connection, + source: RemoteSource, + tracks: &[CachedTrack], +) -> Result<usize> { + if tracks.is_empty() { + return Ok(0); + } + let _writer = lock_writer(); + let ts = now(); + let tx = conn.transaction().map_err(map_err("begin"))?; + { + let mut stmt = tx + .prepare( + "INSERT INTO remote_cache_tracks + (source, item_id, server_id, library_id, title, artist, album_artist, album, + album_id, track_number, disc_number, duration_ms, year, genre, genres_json, container, + codec, bit_depth, sample_rate_hz, channels, bitrate_kbps, artwork_token, + collection_artwork_token, size_bytes, quality_hydrated, quality_retry_at, updated_at, + isrc, recording_mbid) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,1,0,?25,?26,?27) + ON CONFLICT(source, item_id) DO UPDATE SET + isrc=COALESCE(excluded.isrc, remote_cache_tracks.isrc), + recording_mbid=COALESCE(excluded.recording_mbid, remote_cache_tracks.recording_mbid), + server_id=excluded.server_id, library_id=excluded.library_id, + title=excluded.title, artist=excluded.artist, + album_artist=excluded.album_artist, album=excluded.album, + album_id=excluded.album_id, track_number=excluded.track_number, + disc_number=excluded.disc_number, duration_ms=excluded.duration_ms, + year=excluded.year, genre=excluded.genre, + genres_json=excluded.genres_json, container=excluded.container, + codec=excluded.codec, bit_depth=excluded.bit_depth, + sample_rate_hz=excluded.sample_rate_hz, channels=excluded.channels, + bitrate_kbps=excluded.bitrate_kbps, artwork_token=excluded.artwork_token, + collection_artwork_token=excluded.collection_artwork_token, + size_bytes=excluded.size_bytes, quality_hydrated=1, + quality_retry_at=0, updated_at=excluded.updated_at", + ) + .map_err(map_err("prepare insert"))?; + for t in tracks { + let genres_json = genres_json(t); + stmt.execute(params![ + source.as_str(), + t.item_id, + t.server_id, + t.library_id, + t.title, + t.artist, + t.album_artist, + t.album, + t.album_id, + t.track_number.map(|v| v as i64), + t.disc_number.map(|v| v as i64), + t.duration_ms as i64, + t.year.map(|v| v as i64), + t.genre, + genres_json, + t.container, + t.codec, + t.bit_depth.map(|v| v as i64), + t.sample_rate_hz.map(|v| v as i64), + t.channels.map(|v| v as i64), + t.bitrate_kbps.map(|v| v as i64), + t.artwork_token, + t.collection_artwork_token, + t.size_bytes.map(|v| v as i64), + ts, + t.isrc, + t.recording_mbid, + ]) + .map_err(map_err("insert track"))?; + } + } + tx.commit().map_err(map_err("commit"))?; + Ok(tracks.len()) +} + +fn next_source_revision(conn: &Connection, source: RemoteSource) -> Result<i64> { + let current = conn + .query_row( + "SELECT COALESCE(MAX(updated_at),0) FROM remote_cache_tracks WHERE source=?1", + params![source.as_str()], + |row| row.get::<_, i64>(0), + ) + .map_err(map_err("read source revision"))?; + Ok(now().max(current.saturating_add(1))) +} + +/// Start a fresh authoritative generation for one remote source. +/// +/// A previous interrupted generation is deliberately not resumed here: the +/// Jellyfin essential pass is cheap and its offset spans multiple libraries. +/// Long-running quality hydration resumes independently from persisted pending +/// rows, while stale catalog rows remain intact until this generation finishes. +pub fn begin_source_sync( + conn: &mut Connection, + source: RemoteSource, +) -> Result<SourceSyncGeneration> { + let _writer = lock_writer(); + let tx = conn.transaction().map_err(map_err("begin source sync"))?; + let previous = tx + .query_row( + "SELECT generation FROM remote_cache_source_sync WHERE source=?1", + params![source.as_str()], + |row| row.get::<_, i64>(0), + ) + .optional() + .map_err(map_err("read source sync"))? + .unwrap_or(0) + .max(0) as u64; + let generation = previous.saturating_add(1).min(i64::MAX as u64).max(1); + tx.execute( + "INSERT INTO remote_cache_source_sync(source,generation,observed_rows,status,updated_at) + VALUES (?1,?2,0,'running',?3) + ON CONFLICT(source) DO UPDATE SET generation=excluded.generation, + observed_rows=0,status='running',updated_at=excluded.updated_at", + params![source.as_str(), generation as i64, now()], + ) + .map_err(map_err("write source sync"))?; + tx.commit().map_err(map_err("commit source sync"))?; + Ok(SourceSyncGeneration { + generation, + observed_rows: 0, + resumed: false, + }) +} + +/// Upsert one cheap metadata page and mark its quality pending. +/// +/// Existing quality values remain visible until their replacement arrives; +/// `quality_hydrated=0` is the separate truth that makes even a legitimately +/// NULL lossy bit depth distinguishable from "not fetched yet". +pub fn save_essential_tracks( + conn: &mut Connection, + source: RemoteSource, + generation: u64, + tracks: &[CachedTrack], +) -> Result<usize> { + save_source_generation_page(conn, source, generation, tracks, false) +} + +/// Upsert one complete-quality page in an authoritative source generation. +/// Subsonic carries its quality fields in the ordinary song row, so discovery +/// and hydration are one atomic page transaction for that protocol. +pub fn save_generation_tracks( + conn: &mut Connection, + source: RemoteSource, + generation: u64, + tracks: &[CachedTrack], +) -> Result<usize> { + save_source_generation_page(conn, source, generation, tracks, true) +} + +fn save_source_generation_page( + conn: &mut Connection, + source: RemoteSource, + generation: u64, + tracks: &[CachedTrack], + complete_quality: bool, +) -> Result<usize> { + if tracks.is_empty() { + return Ok(0); + } + let _writer = lock_writer(); + let tx = conn + .transaction() + .map_err(map_err("begin essential page"))?; + let state = tx + .query_row( + "SELECT status,observed_rows FROM remote_cache_source_sync + WHERE source=?1 AND generation=?2", + params![source.as_str(), generation.min(i64::MAX as u64) as i64], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional() + .map_err(map_err("read essential generation"))?; + let Some((status, observed_rows)) = state else { + return Err("media cache: essential generation is missing".to_string()); + }; + if status != "running" { + return Err("media cache: essential generation is no longer current".to_string()); + } + let revision = next_source_revision(&tx, source)?; + let generation = generation.min(i64::MAX as u64) as i64; + { + let mut stmt = tx + .prepare( + "INSERT INTO remote_cache_tracks + (source,item_id,server_id,library_id,title,artist,album_artist,album, + album_id,track_number,disc_number,duration_ms,year,genre,genres_json,container, + codec,bit_depth,sample_rate_hz,channels,bitrate_kbps,artwork_token, + collection_artwork_token,size_bytes,observed_generation,quality_hydrated,quality_retry_at,updated_at, + isrc,recording_mbid) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15, + ?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,0,?27,?28,?29) + ON CONFLICT(source,item_id) DO UPDATE SET + isrc=COALESCE(excluded.isrc,remote_cache_tracks.isrc), + recording_mbid=COALESCE(excluded.recording_mbid,remote_cache_tracks.recording_mbid), + server_id=excluded.server_id,library_id=excluded.library_id, + title=excluded.title,artist=excluded.artist, + album_artist=excluded.album_artist,album=excluded.album, + album_id=excluded.album_id,track_number=excluded.track_number, + disc_number=excluded.disc_number,duration_ms=excluded.duration_ms, + year=excluded.year,genre=excluded.genre, + genres_json=excluded.genres_json, + container=CASE WHEN excluded.quality_hydrated=1 + THEN excluded.container + ELSE COALESCE(NULLIF(excluded.container,''),remote_cache_tracks.container) + END, + codec=CASE WHEN excluded.quality_hydrated=1 + THEN excluded.codec ELSE remote_cache_tracks.codec END, + bit_depth=CASE WHEN excluded.quality_hydrated=1 + THEN excluded.bit_depth ELSE remote_cache_tracks.bit_depth END, + sample_rate_hz=CASE WHEN excluded.quality_hydrated=1 + THEN excluded.sample_rate_hz ELSE remote_cache_tracks.sample_rate_hz END, + channels=CASE WHEN excluded.quality_hydrated=1 + THEN excluded.channels ELSE remote_cache_tracks.channels END, + bitrate_kbps=CASE WHEN excluded.quality_hydrated=1 + THEN excluded.bitrate_kbps ELSE remote_cache_tracks.bitrate_kbps END, + artwork_token=excluded.artwork_token, + collection_artwork_token=excluded.collection_artwork_token, + size_bytes=excluded.size_bytes, + observed_generation=excluded.observed_generation, + quality_hydrated=excluded.quality_hydrated, + quality_retry_at=0,updated_at=excluded.updated_at", + ) + .map_err(map_err("prepare essential page"))?; + for track in tracks { + let genres_json = genres_json(track); + stmt.execute(params![ + source.as_str(), + track.item_id, + track.server_id, + track.library_id, + track.title, + track.artist, + track.album_artist, + track.album, + track.album_id, + track.track_number.map(|value| value as i64), + track.disc_number.map(|value| value as i64), + track.duration_ms.min(i64::MAX as u64) as i64, + track.year.map(|value| value as i64), + track.genre, + genres_json, + track.container, + track.codec, + track.bit_depth.map(|value| value as i64), + track.sample_rate_hz.map(|value| value as i64), + track.channels.map(|value| value as i64), + track.bitrate_kbps.map(|value| value as i64), + track.artwork_token, + track.collection_artwork_token, + track + .size_bytes + .map(|value| value.min(i64::MAX as u64) as i64), + generation, + i64::from(complete_quality), + revision, + track.isrc, + track.recording_mbid, + ]) + .map_err(map_err("write essential track"))?; + } + } + let expected = (observed_rows.max(0) as u64).saturating_add(tracks.len() as u64); + let actual = tx + .query_row( + "SELECT COUNT(*) FROM remote_cache_tracks + WHERE source=?1 AND observed_generation=?2", + params![source.as_str(), generation], + |row| row.get::<_, i64>(0), + ) + .map_err(map_err("verify essential identities"))? + .max(0) as u64; + if actual != expected { + return Err("media cache: source repeated an item id across pages".to_string()); + } + tx.execute( + "UPDATE remote_cache_source_sync SET observed_rows=?3,updated_at=?4 + WHERE source=?1 AND generation=?2 AND status='running'", + params![source.as_str(), generation, actual as i64, now()], + ) + .map_err(map_err("checkpoint essential page"))?; + tx.commit().map_err(map_err("commit essential page"))?; + Ok(tracks.len()) +} + +/// Finish a source generation and, for a full sweep, atomically prune rows the +/// server did not observe. A failed/interrupted caller never reaches this gate. +pub fn complete_source_sync( + conn: &mut Connection, + source: RemoteSource, + generation: u64, + prune_old: bool, +) -> Result<usize> { + let _writer = lock_writer(); + let tx = conn + .transaction() + .map_err(map_err("begin source completion"))?; + let generation = generation.min(i64::MAX as u64) as i64; + let state = tx + .query_row( + "SELECT status,observed_rows FROM remote_cache_source_sync + WHERE source=?1 AND generation=?2", + params![source.as_str(), generation], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional() + .map_err(map_err("read source completion"))?; + let Some((status, observed_rows)) = state else { + return Err("media cache: source generation is missing".to_string()); + }; + if status != "running" { + return Err("media cache: source generation cannot authorize prune".to_string()); + } + let actual = tx + .query_row( + "SELECT COUNT(*) FROM remote_cache_tracks + WHERE source=?1 AND observed_generation=?2", + params![source.as_str(), generation], + |row| row.get::<_, i64>(0), + ) + .map_err(map_err("verify source generation"))?; + if actual.max(0) != observed_rows.max(0) { + return Err("media cache: source generation identity count changed".to_string()); + } + let pruned = if prune_old { + tx.execute( + "DELETE FROM remote_cache_tracks + WHERE source=?1 AND observed_generation<>?2", + params![source.as_str(), generation], + ) + .map_err(map_err("prune source generation"))? + } else { + 0 + }; + tx.execute( + "UPDATE remote_cache_source_sync SET status='complete',updated_at=?3 + WHERE source=?1 AND generation=?2", + params![source.as_str(), generation, now()], + ) + .map_err(map_err("finish source generation"))?; + tx.commit().map_err(map_err("commit source completion"))?; + Ok(pruned) +} + +pub fn interrupt_source_sync( + conn: &Connection, + source: RemoteSource, + generation: u64, +) -> Result<()> { + let _writer = lock_writer(); + conn.execute( + "UPDATE remote_cache_source_sync SET status='interrupted',updated_at=?3 + WHERE source=?1 AND generation=?2 AND status='running'", + params![ + source.as_str(), + generation.min(i64::MAX as u64) as i64, + now() + ], + ) + .map(|_| ()) + .map_err(map_err("interrupt source sync")) +} + +/// Pending quality ids in deterministic order. The retry timestamp prevents a +/// deleted item or down server from creating a hot loop. +pub fn quality_candidates( + conn: &Connection, + source: RemoteSource, + limit: usize, +) -> Result<Vec<String>> { + let mut stmt = conn + .prepare( + "SELECT item_id FROM remote_cache_tracks + WHERE source=?1 AND quality_hydrated=0 AND quality_retry_at<=?2 + ORDER BY updated_at DESC,id LIMIT ?3", + ) + .map_err(map_err("prepare quality candidates"))?; + let rows = stmt + .query_map( + params![source.as_str(), now(), limit.min(i64::MAX as usize) as i64], + |row| row.get::<_, String>(0), + ) + .map_err(map_err("query quality candidates"))?; + rows.collect::<rusqlite::Result<Vec<_>>>() + .map_err(map_err("read quality candidates")) +} + +/// Preserve caller priority while dropping ids already hydrated or deferred. +pub fn pending_quality_ids( + conn: &Connection, + source: RemoteSource, + item_ids: &[String], +) -> Result<Vec<String>> { + let mut stmt = conn + .prepare( + "SELECT EXISTS( + SELECT 1 FROM remote_cache_tracks + WHERE source=?1 AND item_id=?2 AND quality_hydrated=0 + AND quality_retry_at<=?3 + )", + ) + .map_err(map_err("prepare pending quality lookup"))?; + let mut pending = Vec::new(); + for item_id in item_ids { + let exists = stmt + .query_row(params![source.as_str(), item_id, now()], |row| { + row.get::<_, bool>(0) + }) + .map_err(map_err("read pending quality lookup"))?; + if exists { + pending.push(item_id.clone()); + } + } + Ok(pending) +} + +/// Apply a quality batch without rewriting metadata or changing published ids. +pub fn update_track_quality( + conn: &mut Connection, + source: RemoteSource, + updates: &[CachedTrackQuality], +) -> Result<usize> { + if updates.is_empty() { + return Ok(0); + } + let _writer = lock_writer(); + let tx = conn + .transaction() + .map_err(map_err("begin quality update"))?; + let revision = next_source_revision(&tx, source)?; + let mut affected = 0usize; + { + let mut stmt = tx + .prepare( + "UPDATE remote_cache_tracks SET + container=COALESCE(NULLIF(?3,''),container),codec=?4,bit_depth=?5, + sample_rate_hz=?6,channels=?7,bitrate_kbps=?8, + quality_hydrated=1,quality_retry_at=0,updated_at=?9 + WHERE source=?1 AND item_id=?2", + ) + .map_err(map_err("prepare quality update"))?; + for update in updates { + affected += stmt + .execute(params![ + source.as_str(), + update.item_id, + update.container, + update.codec, + update.bit_depth.map(|value| value as i64), + update.sample_rate_hz.map(|value| value as i64), + update.channels.map(|value| value as i64), + update.bitrate_kbps.map(|value| value as i64), + revision, + ]) + .map_err(map_err("write quality update"))?; + } + } + tx.commit().map_err(map_err("commit quality update"))?; + Ok(affected) +} + +pub fn defer_track_quality( + conn: &mut Connection, + source: RemoteSource, + item_ids: &[String], + retry_after_secs: i64, +) -> Result<usize> { + if item_ids.is_empty() { + return Ok(0); + } + let _writer = lock_writer(); + let retry_at = now().saturating_add(retry_after_secs.max(1)); + let tx = conn.transaction().map_err(map_err("begin quality defer"))?; + let mut affected = 0usize; + { + let mut stmt = tx + .prepare( + "UPDATE remote_cache_tracks SET quality_retry_at=?3 + WHERE source=?1 AND item_id=?2 AND quality_hydrated=0", + ) + .map_err(map_err("prepare quality defer"))?; + for item_id in item_ids { + affected += stmt + .execute(params![source.as_str(), item_id, retry_at]) + .map_err(map_err("write quality defer"))?; + } + } + tx.commit().map_err(map_err("commit quality defer"))?; + Ok(affected) +} + +/// Replace the known libraries for one source. +pub fn save_libraries( + conn: &mut Connection, + source: RemoteSource, + libs: &[CachedLibrary], +) -> Result<()> { + let _writer = lock_writer(); + let ts = now(); + let tx = conn.transaction().map_err(map_err("begin"))?; + tx.execute( + "DELETE FROM remote_cache_libraries WHERE source = ?1", + params![source.as_str()], + ) + .map_err(map_err("clear libraries"))?; + { + let mut stmt = tx + .prepare( + "INSERT INTO remote_cache_libraries (source, library_id, name, server_id, updated_at) + VALUES (?1,?2,?3,?4,?5)", + ) + .map_err(map_err("prepare library"))?; + for l in libs { + stmt.execute(params![ + source.as_str(), + l.library_id, + l.name, + l.server_id, + ts + ]) + .map_err(map_err("insert library"))?; + } + } + tx.commit().map_err(map_err("commit")) +} + +pub fn libraries(conn: &Connection, source: RemoteSource) -> Result<Vec<CachedLibrary>> { + let mut stmt = conn + .prepare("SELECT source, library_id, name, server_id FROM remote_cache_libraries WHERE source = ?1 ORDER BY name") + .map_err(map_err("prepare libraries"))?; + let rows = stmt + .query_map(params![source.as_str()], |r| { + Ok(CachedLibrary { + source: r.get(0)?, + library_id: r.get(1)?, + name: r.get(2)?, + server_id: r.get(3)?, + }) + }) + .map_err(map_err("query libraries"))?; + rows.collect::<rusqlite::Result<Vec<_>>>() + .map_err(map_err("read libraries")) +} + +/// One track by its NAMESPACED id — a primary-key lookup, not a scan. +/// +/// This is the whole payoff of using the rowid as the payload: `PlexSource` +/// needs `resolve_cache_row_id` to invert a hash, and its slow path is a full +/// table scan. Here the id IS the key. +pub fn track_by_id(conn: &Connection, id: i64) -> Result<Option<CachedTrack>> { + let Some(source) = RemoteSource::of_id(id) else { + return Ok(None); + }; + let rowid = RemoteSource::rowid_of(id); + conn.query_row( + &format!("{SELECT} WHERE id = ?1 AND source = ?2"), + params![rowid, source.as_str()], + row_to_track, + ) + .optional() + .map_err(map_err("track by id")) +} + +/// One track by the SERVER's own id. +pub fn track_by_item_id( + conn: &Connection, + source: RemoteSource, + item_id: &str, +) -> Result<Option<CachedTrack>> { + conn.query_row( + &format!("{SELECT} WHERE source = ?1 AND item_id = ?2"), + params![source.as_str(), item_id], + row_to_track, + ) + .optional() + .map_err(map_err("track by item id")) +} + +/// Every track of one album, in disc/track order. +/// +/// NULLs sort LAST rather than first: 75 of 4924 measured Jellyfin rows carry no +/// track number, and letting them lead would put the untagged tracks above +/// track 1 on every album that has any. +pub fn album_tracks( + conn: &Connection, + source: RemoteSource, + album_id: &str, +) -> Result<Vec<CachedTrack>> { + let mut stmt = conn + .prepare(&format!( + "{SELECT} WHERE source = ?1 AND album_id = ?2 \ + ORDER BY disc_number IS NULL, disc_number, track_number IS NULL, track_number, title" + )) + .map_err(map_err("prepare album"))?; + let rows = stmt + .query_map(params![source.as_str(), album_id], row_to_track) + .map_err(map_err("query album"))?; + rows.collect::<rusqlite::Result<Vec<_>>>() + .map_err(map_err("read album")) +} + +/// Substring search across title / artist / album. An empty needle matches +/// everything, which is what the Local Library's unfiltered listing wants. +pub fn search( + conn: &Connection, + source: RemoteSource, + needle: &str, + limit: Option<u32>, +) -> Result<Vec<CachedTrack>> { + search_page( + conn, + source, + needle, + 0, + limit.unwrap_or(u32::MAX) as u64, + "default", + ) +} + +/// One deterministic page of a remote source, in the same allowlisted sort +/// orders used by the local Tracks query. `offset` belongs to this source only. +pub fn search_page( + conn: &Connection, + source: RemoteSource, + needle: &str, + offset: u64, + limit: u64, + sort: &str, +) -> Result<Vec<CachedTrack>> { + search_page_filtered(conn, source, needle, offset, limit, sort, &[], false, &[]) +} + +pub fn search_page_filtered( + conn: &Connection, + source: RemoteSource, + needle: &str, + offset: u64, + limit: u64, + sort: &str, + formats: &[String], + other_formats: bool, + quality_tiers: &[String], +) -> Result<Vec<CachedTrack>> { + let like = format!("%{}%", needle.trim()); + let order = match sort { + "title-asc" => "title COLLATE NOCASE, artist COLLATE NOCASE, id", + "title-desc" => "title COLLATE NOCASE DESC, artist COLLATE NOCASE, id", + "artist-asc" => "COALESCE(NULLIF(album_artist, ''), artist) COLLATE NOCASE, album COLLATE NOCASE, disc_number, track_number, id", + "artist-desc" => "COALESCE(NULLIF(album_artist, ''), artist) COLLATE NOCASE DESC, album COLLATE NOCASE, disc_number, track_number, id", + "group-artist" => "artist COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE, id", + "year-desc" => "year IS NULL, year DESC, album COLLATE NOCASE, disc_number, track_number, id", + "year-asc" => "year IS NULL, year ASC, album COLLATE NOCASE, disc_number, track_number, id", + // These rows currently map to LocalTrack with indexed_at=0. + "added-desc" => "album COLLATE NOCASE, disc_number, track_number, id", + _ => "album COLLATE NOCASE, COALESCE(NULLIF(album_artist, ''), artist) COLLATE NOCASE, disc_number, track_number, title COLLATE NOCASE, id", + }; + let media_filter = cached_track_media_filter_sql( + "container", + "bit_depth", + "sample_rate_hz", + formats, + other_formats, + quality_tiers, + ); + let mut stmt = conn + .prepare(&format!( + "{SELECT} WHERE source = ?1 AND (?2 = '' OR title LIKE ?3 OR artist LIKE ?3 \ + OR album LIKE ?3 OR album_artist LIKE ?3) {media_filter} \ + ORDER BY {order} LIMIT ?4 OFFSET ?5" + )) + .map_err(map_err("prepare search"))?; + let rows = stmt + .query_map( + params![ + source.as_str(), + needle.trim(), + like, + limit as i64, + offset as i64 + ], + row_to_track, + ) + .map_err(map_err("query search"))?; + rows.collect::<rusqlite::Result<Vec<_>>>() + .map_err(map_err("read search")) +} + +fn cached_track_media_filter_sql( + format_col: &str, + depth_col: &str, + rate_col: &str, + formats: &[String], + other_formats: bool, + quality_tiers: &[String], +) -> String { + let known = ["flac", "alac", "ape", "wav", "mp3", "aac"]; + let selected = known + .iter() + .copied() + .filter(|value| { + formats + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(value)) + }) + .collect::<Vec<_>>(); + let mut clauses = Vec::new(); + if !selected.is_empty() || other_formats { + let mut values = selected + .into_iter() + .map(|value| format!("LOWER(COALESCE({format_col},''))='{value}'")) + .collect::<Vec<_>>(); + if other_formats { + values.push(format!( + "LOWER(COALESCE({format_col},'')) NOT IN ('flac','alac','ape','wav','mp3','aac')" + )); + } + clauses.push(format!("AND ({})", values.join(" OR "))); + } + let hires = quality_tiers.iter().any(|value| value == "hires"); + let cd = quality_tiers.iter().any(|value| value == "cd"); + let lossy = quality_tiers.iter().any(|value| value == "lossy"); + if hires || cd || lossy { + let khz = format!( + "CASE WHEN COALESCE({rate_col},0)>=1000 THEN COALESCE({rate_col},0)/1000.0 ELSE COALESCE({rate_col},0) END" + ); + let mut values = Vec::new(); + if hires { + values.push(format!( + "(LOWER(COALESCE({format_col},'')) IN ('dsd','dsf','dff') OR COALESCE({depth_col},0)>=24)" + )); + } + if cd { + values.push(format!( + "(LOWER(COALESCE({format_col},'')) NOT IN ('mp3','dsd','dsf','dff') AND (({depth_col} IS NOT NULL AND {depth_col}<24) OR ({depth_col} IS NULL AND {khz}>=44.1)))" + )); + } + if lossy { + values.push(format!("LOWER(COALESCE({format_col},''))='mp3'")); + } + clauses.push(format!("AND ({})", values.join(" OR "))); + } + clauses.join(" ") +} + +/// Artist aggregates for one remote source, including track credits and +/// distinct album artists. +pub fn artists(conn: &Connection, source: RemoteSource) -> Result<Vec<CachedArtist>> { + let mut stmt = conn + .prepare( + "WITH credits AS ( + SELECT id, TRIM(artist) AS name, + COALESCE(NULLIF(album_id, ''), album) AS album_key + FROM remote_cache_tracks + WHERE source = ?1 AND TRIM(artist) != '' + UNION + SELECT id, TRIM(album_artist) AS name, + COALESCE(NULLIF(album_id, ''), album) AS album_key + FROM remote_cache_tracks + WHERE source = ?1 AND TRIM(album_artist) != '' + ) + SELECT name, COUNT(DISTINCT album_key), COUNT(DISTINCT id) + FROM credits + GROUP BY name COLLATE NOCASE + ORDER BY name COLLATE NOCASE", + ) + .map_err(map_err("prepare artists"))?; + let rows = stmt + .query_map(params![source.as_str()], |row| { + Ok(CachedArtist { + name: row.get(0)?, + album_count: row.get::<_, i64>(1)? as u32, + track_count: row.get::<_, i64>(2)? as u32, + }) + }) + .map_err(map_err("query artists"))?; + rows.collect::<rusqlite::Result<Vec<_>>>() + .map_err(map_err("read artists")) +} + +pub fn count(conn: &Connection, source: RemoteSource) -> Result<u64> { + conn.query_row( + "SELECT COUNT(*) FROM remote_cache_tracks WHERE source = ?1", + params![source.as_str()], + |r| r.get::<_, i64>(0), + ) + .map(|n| n as u64) + .map_err(map_err("count")) +} + +/// Forget everything one source cached (a disconnect, or a server swap). +pub fn clear(conn: &mut Connection, source: RemoteSource) -> Result<usize> { + let _writer = lock_writer(); + let tx = conn.transaction().map_err(map_err("begin"))?; + let n = tx + .execute( + "DELETE FROM remote_cache_tracks WHERE source = ?1", + params![source.as_str()], + ) + .map_err(map_err("clear tracks"))?; + tx.execute( + "DELETE FROM remote_cache_libraries WHERE source = ?1", + params![source.as_str()], + ) + .map_err(map_err("clear libraries"))?; + tx.execute( + "DELETE FROM remote_cache_source_sync WHERE source = ?1", + params![source.as_str()], + ) + .map_err(map_err("clear source sync"))?; + tx.commit().map_err(map_err("commit"))?; + Ok(n) +} + +/// Drop rows the last sweep did not touch — i.e. tracks deleted on the server. +/// +/// Keyed on `updated_at` rather than on a set of ids: a full sweep of 50 000 +/// tracks would otherwise have to hold every id in memory and build an +/// `IN (…)` of that size. `before` is the timestamp taken BEFORE the sweep +/// started, so anything still older than it was not seen. +/// +/// Only ever called after a sweep that COMPLETED. A partial sweep — a dropped +/// connection halfway through — would otherwise read as "the server deleted +/// everything it did not get to". +pub fn prune_stale(conn: &mut Connection, source: RemoteSource, before: i64) -> Result<usize> { + let _writer = lock_writer(); + let tx = conn.transaction().map_err(map_err("begin"))?; + let n = tx + .execute( + "DELETE FROM remote_cache_tracks WHERE source = ?1 AND updated_at < ?2", + params![source.as_str(), before], + ) + .map_err(map_err("prune"))?; + tx.commit().map_err(map_err("commit"))?; + Ok(n) +} + +/// The timestamp a caller should hand to [`prune_stale`] after its sweep. +pub fn sweep_start() -> i64 { + now() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn db() -> Connection { + let c = Connection::open_in_memory().unwrap(); + init_schema(&c).unwrap(); + c + } + + fn track(item_id: &str, album: &str, disc: Option<u32>, no: Option<u32>) -> CachedTrack { + CachedTrack { + item_id: item_id.into(), + album_id: album.into(), + album: album.into(), + title: format!("t-{item_id}"), + artist: "A".into(), + album_artist: "A".into(), + disc_number: disc, + track_number: no, + duration_ms: 1000, + container: "flac".into(), + bit_depth: Some(24), + sample_rate_hz: Some(96000), + ..Default::default() + } + } + + // ── Identity ─────────────────────────────────────────────────────────── + + /// The floors must not overlap each other, Plex's (`1 << 40`) or the + /// ephemeral store's (`1 << 48`). A predicate that answered "mine" for a + /// neighbouring source would route a row to the wrong server. + #[test] + fn the_id_namespaces_do_not_collide_with_each_other_or_the_existing_ones() { + const PLEX_FLOOR: i64 = 1 << 40; + const EPHEMERAL_FLOOR: i64 = 1 << 48; + + let j = RemoteSource::Jellyfin.namespace(1); + let s = RemoteSource::Subsonic.namespace(1); + assert_eq!(RemoteSource::of_id(j), Some(RemoteSource::Jellyfin)); + assert_eq!(RemoteSource::of_id(s), Some(RemoteSource::Subsonic)); + assert_ne!(j, s); + + // Neither claims a Plex id, an ephemeral id, or a plain rowid. + assert_eq!(RemoteSource::of_id(PLEX_FLOOR | 44_440), None); + assert_eq!(RemoteSource::of_id(EPHEMERAL_FLOOR + 10), None); + assert_eq!(RemoteSource::of_id(2954), None); + assert_eq!(RemoteSource::of_id(0), None); + + // ...and the round trip is exact for the whole payload range. + for rowid in [1i64, 2, 999, PAYLOAD_MASK] { + for src in [RemoteSource::Jellyfin, RemoteSource::Subsonic] { + let id = src.namespace(rowid); + assert_eq!(RemoteSource::of_id(id), Some(src), "rowid {rowid}"); + assert_eq!(RemoteSource::rowid_of(id), rowid); + } + } + } + + // ── Storage ──────────────────────────────────────────────────────────── + + #[test] + fn existing_cache_adds_the_collection_artwork_layer_in_place() { + let c = db(); + c.execute( + "ALTER TABLE remote_cache_tracks DROP COLUMN collection_artwork_token", + [], + ) + .unwrap(); + + init_schema(&c).unwrap(); + let present = c + .prepare("PRAGMA table_info(remote_cache_tracks)") + .unwrap() + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .filter_map(|row| row.ok()) + .any(|column| column == "collection_artwork_token"); + assert!(present); + } + + #[test] + fn a_saved_track_round_trips_by_both_of_its_ids() { + let mut c = db(); + let mut row = track("srv-1", "alb", Some(1), Some(3)); + row.artwork_token = Some("disc-cover".into()); + row.collection_artwork_token = Some("box-cover".into()); + save_tracks(&mut c, RemoteSource::Jellyfin, &[row]).unwrap(); + let by_item = track_by_item_id(&c, RemoteSource::Jellyfin, "srv-1") + .unwrap() + .expect("by item id"); + assert_eq!(by_item.title, "t-srv-1"); + assert_eq!(by_item.bit_depth, Some(24)); + assert_eq!(by_item.artwork_token.as_deref(), Some("disc-cover")); + assert_eq!( + by_item.collection_artwork_token.as_deref(), + Some("box-cover") + ); + assert_eq!( + RemoteSource::of_id(by_item.id), + Some(RemoteSource::Jellyfin) + ); + + let by_id = track_by_id(&c, by_item.id) + .unwrap() + .expect("by namespaced id"); + assert_eq!(by_id, by_item); + } + + #[test] + fn every_remote_genre_round_trips_in_server_order() { + let mut c = db(); + let mut row = track("genres", "album", Some(1), Some(1)); + row.genre = Some("Progressive Rock".into()); + row.genres = vec![ + "Progressive Rock".into(), + "Art Rock".into(), + "Psychedelic".into(), + ]; + save_tracks(&mut c, RemoteSource::Jellyfin, &[row]).unwrap(); + let stored = track_by_item_id(&c, RemoteSource::Jellyfin, "genres") + .unwrap() + .unwrap(); + assert_eq!( + stored.genres, + ["Progressive Rock", "Art Rock", "Psychedelic"] + ); + assert_eq!(stored.genre.as_deref(), Some("Progressive Rock")); + } + + /// THE reason this is an UPSERT. A published row id lives inside queue + /// entries and `session.db`; a re-scan that minted a new one would leave a + /// playing track pointing at nothing. + #[test] + fn a_rescan_updates_a_row_without_changing_its_id() { + let mut c = db(); + save_tracks( + &mut c, + RemoteSource::Jellyfin, + &[track("srv-1", "alb", Some(1), Some(3))], + ) + .unwrap(); + let first = track_by_item_id(&c, RemoteSource::Jellyfin, "srv-1") + .unwrap() + .unwrap(); + + let mut renamed = track("srv-1", "alb", Some(1), Some(3)); + renamed.title = "retagged".into(); + renamed.bit_depth = Some(16); + save_tracks(&mut c, RemoteSource::Jellyfin, &[renamed]).unwrap(); + + let second = track_by_item_id(&c, RemoteSource::Jellyfin, "srv-1") + .unwrap() + .unwrap(); + assert_eq!(second.id, first.id, "the row id changed under a re-scan"); + assert_eq!(second.title, "retagged"); + assert_eq!(second.bit_depth, Some(16)); + assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 1); + } + + /// The same `item_id` on two DIFFERENT servers is two different tracks. + /// Subsonic and Jellyfin ids are opaque and can coincide. + #[test] + fn the_same_item_id_under_two_sources_is_two_rows() { + let mut c = db(); + save_tracks( + &mut c, + RemoteSource::Jellyfin, + &[track("same", "a", None, None)], + ) + .unwrap(); + save_tracks( + &mut c, + RemoteSource::Subsonic, + &[track("same", "a", None, None)], + ) + .unwrap(); + assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 1); + assert_eq!(count(&c, RemoteSource::Subsonic).unwrap(), 1); + let j = track_by_item_id(&c, RemoteSource::Jellyfin, "same") + .unwrap() + .unwrap(); + let s = track_by_item_id(&c, RemoteSource::Subsonic, "same") + .unwrap() + .unwrap(); + assert_ne!(j.id, s.id); + assert_eq!(RemoteSource::of_id(j.id), Some(RemoteSource::Jellyfin)); + assert_eq!(RemoteSource::of_id(s.id), Some(RemoteSource::Subsonic)); + } + + /// Untagged rows sort LAST. 75 of 4924 measured Jellyfin rows have no track + /// number; leading with them would put them above track 1 on every album. + #[test] + fn album_order_puts_untagged_tracks_after_the_numbered_ones() { + let mut c = db(); + save_tracks( + &mut c, + RemoteSource::Jellyfin, + &[ + track("c", "alb", Some(1), None), + track("b", "alb", Some(1), Some(2)), + track("a", "alb", Some(1), Some(1)), + track("d", "alb", Some(2), Some(1)), + ], + ) + .unwrap(); + let got: Vec<String> = album_tracks(&c, RemoteSource::Jellyfin, "alb") + .unwrap() + .into_iter() + .map(|t| t.item_id) + .collect(); + assert_eq!(got, vec!["a", "b", "c", "d"]); + } + + #[test] + fn search_matches_every_display_field_and_an_empty_needle_matches_all() { + let mut c = db(); + let mut t = track("x", "Kind of Blue", Some(1), Some(1)); + t.artist = "Miles Davis".into(); + t.album_artist = "Miles Davis".into(); + t.title = "So What".into(); + save_tracks(&mut c, RemoteSource::Subsonic, &[t]).unwrap(); + + for needle in ["So What", "miles", "Kind of", "so wh"] { + assert_eq!( + search(&c, RemoteSource::Subsonic, needle, None) + .unwrap() + .len(), + 1, + "needle {needle:?} found nothing" + ); + } + assert_eq!( + search(&c, RemoteSource::Subsonic, "", None).unwrap().len(), + 1 + ); + assert!(search(&c, RemoteSource::Subsonic, "zzz", None) + .unwrap() + .is_empty()); + // A search never leaks another source's rows. + assert!(search(&c, RemoteSource::Jellyfin, "", None) + .unwrap() + .is_empty()); + } + + #[test] + fn remote_search_pages_cross_the_window_without_duplicates() { + let mut c = db(); + let rows: Vec<CachedTrack> = (0..1_201) + .map(|i| { + let mut t = track( + &format!("remote-{i:04}"), + &format!("Album {:03}", i / 12), + Some(1), + Some((i % 12 + 1) as u32), + ); + t.title = format!("Track {i:04}"); + t + }) + .collect(); + save_tracks(&mut c, RemoteSource::Jellyfin, &rows).unwrap(); + + let mut offset = 0; + let mut ids = std::collections::HashSet::new(); + loop { + let page = search_page( + &c, + RemoteSource::Jellyfin, + "Track", + offset, + 500, + "title-asc", + ) + .unwrap(); + if page.is_empty() { + break; + } + for row in &page { + assert!(ids.insert(row.id), "duplicate id {}", row.id); + } + offset += page.len() as u64; + } + assert_eq!(ids.len(), 1_201); + assert_eq!(offset, 1_201); + } + + #[test] + fn filtered_remote_pages_are_stable_and_do_not_leak_other_formats() { + let mut c = db(); + let rows = (0..41) + .map(|i| { + let mut row = track(&format!("fmt-{i:02}"), "Album", Some(1), Some(i + 1)); + row.title = format!("Track {i:02}"); + if i % 2 == 0 { + row.container = "mp3".into(); + row.codec = Some("mp3".into()); + row.bit_depth = None; + row.sample_rate_hz = Some(44_100); + } + row + }) + .collect::<Vec<_>>(); + save_tracks(&mut c, RemoteSource::Subsonic, &rows).unwrap(); + + let formats = vec!["mp3".to_string()]; + let qualities = vec!["lossy".to_string()]; + let mut offset = 0; + let mut ids = std::collections::HashSet::new(); + loop { + let page = search_page_filtered( + &c, + RemoteSource::Subsonic, + "", + offset, + 7, + "title-asc", + &formats, + false, + &qualities, + ) + .unwrap(); + if page.is_empty() { + break; + } + for row in &page { + assert_eq!(row.container, "mp3"); + assert!(ids.insert(row.id), "duplicate filtered id {}", row.id); + } + offset += page.len() as u64; + } + assert_eq!(ids.len(), 21); + } + + #[test] + fn remote_artist_group_uses_the_performing_artist() { + let mut c = db(); + let mut zed = track("zed", "A Album", Some(1), Some(1)); + zed.artist = "Zed Performer".into(); + zed.album_artist = "Alpha Album Artist".into(); + let mut alpha = track("alpha", "Z Album", Some(1), Some(1)); + alpha.artist = "Alpha Performer".into(); + alpha.album_artist = "Zed Album Artist".into(); + save_tracks(&mut c, RemoteSource::Jellyfin, &[zed, alpha]).unwrap(); + + let grouped = search_page(&c, RemoteSource::Jellyfin, "", 0, 10, "group-artist").unwrap(); + assert_eq!(grouped[0].artist, "Alpha Performer"); + + let album_artist_sorted = + search_page(&c, RemoteSource::Jellyfin, "", 0, 10, "artist-asc").unwrap(); + assert_eq!(album_artist_sorted[0].artist, "Zed Performer"); + } + + #[test] + fn remote_only_artist_has_exact_album_and_track_counts() { + let mut c = db(); + let mut rows = Vec::new(); + for (id, album) in [("one", "a"), ("two", "a"), ("three", "b")] { + let mut t = track(id, album, Some(1), Some(1)); + t.artist = "Remote Only".into(); + t.album_artist = "Remote Only".into(); + rows.push(t); + } + save_tracks(&mut c, RemoteSource::Subsonic, &rows).unwrap(); + + let got = artists(&c, RemoteSource::Subsonic).unwrap(); + assert_eq!( + got, + vec![CachedArtist { + name: "Remote Only".into(), + album_count: 2, + track_count: 3, + }] + ); + assert!(artists(&c, RemoteSource::Jellyfin).unwrap().is_empty()); + } + + #[test] + fn clearing_one_source_leaves_the_other_alone() { + let mut c = db(); + save_tracks( + &mut c, + RemoteSource::Jellyfin, + &[track("j", "a", None, None)], + ) + .unwrap(); + save_tracks( + &mut c, + RemoteSource::Subsonic, + &[track("s", "a", None, None)], + ) + .unwrap(); + assert_eq!(clear(&mut c, RemoteSource::Jellyfin).unwrap(), 1); + assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 0); + assert_eq!(count(&c, RemoteSource::Subsonic).unwrap(), 1); + } + + /// A row deleted on the server disappears; a row the sweep re-saw survives. + #[test] + fn prune_drops_only_what_the_sweep_did_not_touch() { + let mut c = db(); + save_tracks( + &mut c, + RemoteSource::Subsonic, + &[ + track("keep", "a", None, None), + track("gone", "a", None, None), + ], + ) + .unwrap(); + // A later sweep re-sees only `keep`. `updated_at` is whole seconds, so + // the timestamps are set explicitly rather than raced against the clock. + c.execute( + "UPDATE remote_cache_tracks SET updated_at = 100 WHERE item_id = 'gone'", + [], + ) + .unwrap(); + c.execute( + "UPDATE remote_cache_tracks SET updated_at = 200 WHERE item_id = 'keep'", + [], + ) + .unwrap(); + assert_eq!(prune_stale(&mut c, RemoteSource::Subsonic, 150).unwrap(), 1); + assert_eq!(count(&c, RemoteSource::Subsonic).unwrap(), 1); + assert!(track_by_item_id(&c, RemoteSource::Subsonic, "keep") + .unwrap() + .is_some()); + assert!(track_by_item_id(&c, RemoteSource::Subsonic, "gone") + .unwrap() + .is_none()); + } + + /// AUTOINCREMENT, not plain rowid: a freed id must never be handed to a + /// different track, because the old one may still be sitting in a queue. + #[test] + fn a_deleted_rows_id_is_never_reused() { + let mut c = db(); + save_tracks( + &mut c, + RemoteSource::Jellyfin, + &[track("first", "a", None, None)], + ) + .unwrap(); + let first = track_by_item_id(&c, RemoteSource::Jellyfin, "first") + .unwrap() + .unwrap(); + clear(&mut c, RemoteSource::Jellyfin).unwrap(); + save_tracks( + &mut c, + RemoteSource::Jellyfin, + &[track("second", "a", None, None)], + ) + .unwrap(); + let second = track_by_item_id(&c, RemoteSource::Jellyfin, "second") + .unwrap() + .unwrap(); + assert_ne!( + second.id, first.id, + "the deleted track's id was recycled — a stale queue entry would now play this row" + ); + } + + #[test] + fn libraries_round_trip_per_source() { + let mut c = db(); + save_libraries( + &mut c, + RemoteSource::Jellyfin, + &[CachedLibrary { + source: "jellyfin".into(), + library_id: "lib1".into(), + name: "Music".into(), + server_id: "srv".into(), + }], + ) + .unwrap(); + let got = libraries(&c, RemoteSource::Jellyfin).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].name, "Music"); + assert!(libraries(&c, RemoteSource::Subsonic).unwrap().is_empty()); + } + + #[test] + fn a_lossy_row_keeps_its_absent_quality_through_the_database() { + let mut c = db(); + let mut t = track("mp3", "a", None, None); + t.bit_depth = None; + t.sample_rate_hz = Some(44100); + t.container = "mp3".into(); + save_tracks(&mut c, RemoteSource::Subsonic, &[t]).unwrap(); + let got = track_by_item_id(&c, RemoteSource::Subsonic, "mp3") + .unwrap() + .unwrap(); + assert_eq!(got.bit_depth, None, "NULL came back as something else"); + assert_eq!(got.sample_rate_hz, Some(44100)); + } + + #[test] + fn essential_generation_preserves_quality_and_prunes_only_on_completion() { + let mut c = db(); + let mut keep = track("keep", "album", Some(1), Some(1)); + keep.title = "Old title".into(); + keep.container = "flac".into(); + keep.codec = Some("flac".into()); + keep.bit_depth = Some(24); + keep.sample_rate_hz = Some(96_000); + let gone = track("gone", "album", Some(1), Some(2)); + save_tracks(&mut c, RemoteSource::Jellyfin, &[keep.clone(), gone]).unwrap(); + + let first = begin_source_sync(&mut c, RemoteSource::Jellyfin).unwrap(); + let mut essential = keep.clone(); + essential.title = "Fresh title".into(); + essential.codec = None; + essential.bit_depth = None; + essential.sample_rate_hz = None; + save_essential_tracks( + &mut c, + RemoteSource::Jellyfin, + first.generation, + &[essential], + ) + .unwrap(); + assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 2); + let visible = track_by_item_id(&c, RemoteSource::Jellyfin, "keep") + .unwrap() + .unwrap(); + assert_eq!(visible.title, "Fresh title"); + assert_eq!(visible.bit_depth, Some(24)); + assert_eq!(visible.sample_rate_hz, Some(96_000)); + assert_eq!( + quality_candidates(&c, RemoteSource::Jellyfin, 10).unwrap(), + vec!["keep".to_string()] + ); + + interrupt_source_sync(&c, RemoteSource::Jellyfin, first.generation).unwrap(); + assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 2); + let second = begin_source_sync(&mut c, RemoteSource::Jellyfin).unwrap(); + save_essential_tracks(&mut c, RemoteSource::Jellyfin, second.generation, &[keep]).unwrap(); + assert_eq!( + complete_source_sync(&mut c, RemoteSource::Jellyfin, second.generation, true,).unwrap(), + 1 + ); + assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "gone") + .unwrap() + .is_none()); + } + + #[test] + fn quality_hydration_is_keyed_by_id_and_clears_stale_lossless_values() { + let mut c = db(); + let mut row = track("changed", "album", Some(1), Some(1)); + row.container = "flac".into(); + row.codec = Some("flac".into()); + row.bit_depth = Some(24); + row.sample_rate_hz = Some(192_000); + save_tracks(&mut c, RemoteSource::Jellyfin, &[row.clone()]).unwrap(); + let generation = begin_source_sync(&mut c, RemoteSource::Jellyfin) + .unwrap() + .generation; + save_essential_tracks(&mut c, RemoteSource::Jellyfin, generation, &[row]).unwrap(); + + let before_revision: i64 = c + .query_row( + "SELECT updated_at FROM remote_cache_tracks WHERE item_id='changed'", + [], + |record| record.get(0), + ) + .unwrap(); + assert_eq!( + pending_quality_ids( + &c, + RemoteSource::Jellyfin, + &["missing".into(), "changed".into()], + ) + .unwrap(), + vec!["changed".to_string()] + ); + update_track_quality( + &mut c, + RemoteSource::Jellyfin, + &[CachedTrackQuality { + item_id: "changed".into(), + container: "mp3".into(), + codec: Some("mp3".into()), + bit_depth: None, + sample_rate_hz: Some(44_100), + channels: Some(2), + bitrate_kbps: Some(320), + }], + ) + .unwrap(); + let hydrated = track_by_item_id(&c, RemoteSource::Jellyfin, "changed") + .unwrap() + .unwrap(); + assert_eq!(hydrated.container, "mp3"); + assert_eq!(hydrated.bit_depth, None); + assert_eq!(hydrated.sample_rate_hz, Some(44_100)); + assert!(quality_candidates(&c, RemoteSource::Jellyfin, 10) + .unwrap() + .is_empty()); + let after_revision: i64 = c + .query_row( + "SELECT updated_at FROM remote_cache_tracks WHERE item_id='changed'", + [], + |record| record.get(0), + ) + .unwrap(); + assert!(after_revision > before_revision); + } + + #[test] + fn completed_delta_generation_keeps_unobserved_rows() { + let mut c = db(); + let keep = track("changed", "album", Some(1), Some(1)); + let untouched = track("unchanged", "album", Some(1), Some(2)); + save_tracks(&mut c, RemoteSource::Jellyfin, &[keep.clone(), untouched]).unwrap(); + let generation = begin_source_sync(&mut c, RemoteSource::Jellyfin) + .unwrap() + .generation; + save_essential_tracks(&mut c, RemoteSource::Jellyfin, generation, &[keep]).unwrap(); + assert_eq!( + complete_source_sync(&mut c, RemoteSource::Jellyfin, generation, false,).unwrap(), + 0 + ); + assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 2); + assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "unchanged") + .unwrap() + .is_some()); + } + + /// Jellyfin can remint every item id after a library rebuild while the + /// audio and album metadata remain identical. A full user-requested + /// reconciliation must treat the newly observed identity set as + /// authoritative; otherwise every physical track appears twice forever. + #[test] + fn full_reconciliation_replaces_a_completely_remapped_identity_set() { + let mut c = db(); + let mut old_one = track("old-one", "same album", Some(1), Some(1)); + old_one.title = "First".into(); + let mut old_two = track("old-two", "same album", Some(1), Some(2)); + old_two.title = "Second".into(); + save_tracks( + &mut c, + RemoteSource::Jellyfin, + &[old_one.clone(), old_two.clone()], + ) + .unwrap(); + + let generation = begin_source_sync(&mut c, RemoteSource::Jellyfin) + .unwrap() + .generation; + let mut new_one = old_one; + new_one.item_id = "new-one".into(); + let mut new_two = old_two; + new_two.item_id = "new-two".into(); + save_essential_tracks( + &mut c, + RemoteSource::Jellyfin, + generation, + &[new_one, new_two], + ) + .unwrap(); + + assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 4); + assert_eq!( + complete_source_sync(&mut c, RemoteSource::Jellyfin, generation, true).unwrap(), + 2 + ); + assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 2); + assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "old-one") + .unwrap() + .is_none()); + assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "old-two") + .unwrap() + .is_none()); + assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "new-one") + .unwrap() + .is_some()); + assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "new-two") + .unwrap() + .is_some()); + } + + #[test] + fn duplicate_item_across_essential_pages_rolls_back_the_second_page() { + let mut c = db(); + let generation = begin_source_sync(&mut c, RemoteSource::Jellyfin) + .unwrap() + .generation; + let first = track("same", "album", Some(1), Some(1)); + save_essential_tracks(&mut c, RemoteSource::Jellyfin, generation, &[first.clone()]) + .unwrap(); + let mut duplicate = first; + duplicate.title = "Wrong duplicate".into(); + assert!( + save_essential_tracks(&mut c, RemoteSource::Jellyfin, generation, &[duplicate],) + .is_err() + ); + assert_eq!( + track_by_item_id(&c, RemoteSource::Jellyfin, "same") + .unwrap() + .unwrap() + .title, + "t-same" + ); + } + + #[test] + fn complete_quality_generation_is_atomic_and_prunes_only_after_completion() { + let mut c = db(); + let mut keep = track("keep", "album", Some(1), Some(1)); + keep.container = "flac".into(); + keep.codec = Some("flac".into()); + keep.bit_depth = Some(24); + keep.sample_rate_hz = Some(96_000); + let gone = track("gone", "album", Some(1), Some(2)); + save_tracks(&mut c, RemoteSource::Subsonic, &[keep.clone(), gone]).unwrap(); + + let first = begin_source_sync(&mut c, RemoteSource::Subsonic).unwrap(); + keep.container = "mp3".into(); + keep.codec = Some("audio/mpeg".into()); + keep.bit_depth = None; + keep.sample_rate_hz = Some(44_100); + save_generation_tracks( + &mut c, + RemoteSource::Subsonic, + first.generation, + &[keep.clone()], + ) + .unwrap(); + let visible = track_by_item_id(&c, RemoteSource::Subsonic, "keep") + .unwrap() + .unwrap(); + assert_eq!(visible.container, "mp3"); + assert_eq!(visible.bit_depth, None, "lossy quality stayed stale"); + assert!(quality_candidates(&c, RemoteSource::Subsonic, 10) + .unwrap() + .is_empty()); + interrupt_source_sync(&c, RemoteSource::Subsonic, first.generation).unwrap(); + assert_eq!(count(&c, RemoteSource::Subsonic).unwrap(), 2); + + let second = begin_source_sync(&mut c, RemoteSource::Subsonic).unwrap(); + save_generation_tracks(&mut c, RemoteSource::Subsonic, second.generation, &[keep]).unwrap(); + assert_eq!( + complete_source_sync(&mut c, RemoteSource::Subsonic, second.generation, true).unwrap(), + 1 + ); + assert!(track_by_item_id(&c, RemoteSource::Subsonic, "gone") + .unwrap() + .is_none()); + } + + #[test] + fn shared_cache_writes_are_serialized_across_connections() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("remote-media.db"); + let _first = open(&path).unwrap(); + let mut second = open(&path).unwrap(); + let writer = lock_writer(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + done_tx + .send(save_tracks( + &mut second, + RemoteSource::Subsonic, + &[track("serialized", "album", Some(1), Some(1))], + )) + .unwrap(); + }); + + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!( + done_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "a second cache connection wrote while the writer lock was held" + ); + drop(writer); + assert_eq!( + done_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(), + 1 + ); + worker.join().unwrap(); + } +} diff --git a/crates/qbz-media-controls/Cargo.toml b/crates/qbz-media-controls/Cargo.toml index c8ef567f8..ba95569f6 100644 --- a/crates/qbz-media-controls/Cargo.toml +++ b/crates/qbz-media-controls/Cargo.toml @@ -8,6 +8,10 @@ description = "Frontend-agnostic system media controls: MPRIS (mpris-server) on [dependencies] log = { workspace = true } +# fs_url::local_path -- the ONE file-URL helper (W2). SMTC needs a native +# Windows path where MPRIS needs a file:// URL, and hand-rolling that +# conversion is exactly the bug class that helper exists to prevent. +qbz-models = { path = "../qbz-models" } # Notifications (all platforms): the async entry point spawns the HTTP download # + image resize on `spawn_blocking`, so a tokio runtime must be present. tokio = { workspace = true } @@ -27,8 +31,13 @@ urlencoding = "2" # org.mpris.MediaPlayer2.DesktopEntry property (RootInterface::desktop_entry), # which is the only way GNOME Shell resolves the app icon. souvlaki never sets # it. tokio feature so the server shares the app's tokio runtime. +[target.'cfg(target_os = "windows")'.dependencies] +# SetThreadExecutionState: keep the machine awake while a track plays. +# 0.61 is already in the workspace lock. +windows-sys = { version = "0.61", features = ["Win32_System_Power"] } + [target.'cfg(target_os = "linux")'.dependencies] -mpris-server = "0.8" +mpris-server = "0.10" async-channel = "2" # XDG notification portal (goes over D-Bus). async-io, NOT tokio: zbus 5's # "tokio" feature unifies graph-wide and its executor then requires a tokio diff --git a/crates/qbz-media-controls/src/inhibit.rs b/crates/qbz-media-controls/src/inhibit.rs index 86f1e02c8..0db65a561 100644 --- a/crates/qbz-media-controls/src/inhibit.rs +++ b/crates/qbz-media-controls/src/inhibit.rs @@ -13,11 +13,48 @@ //! unlike MPRIS (session bus), so this keeps its own lazy connection. use mpris_server::zbus::{self, zvariant::OwnedFd}; +use std::time::{Duration, Instant}; const LOGIN1_DEST: &str = "org.freedesktop.login1"; const LOGIN1_PATH: &str = "/org/freedesktop/login1"; const LOGIN1_IFACE: &str = "org.freedesktop.login1.Manager"; +/// After a transient `Inhibit` failure, wait this long before the next try. +/// `set_playing(true)` arrives with every playback-state update (~2 s), so +/// without a backoff a headless box logged the same warning every 2 s (369 +/// lines in 11 min on a Pi daemon, 2026-08-29). +const RETRY_BACKOFF: Duration = Duration::from_secs(300); + +/// Why the last `Inhibit` attempt did not produce a lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Denial { + /// A permanent policy answer for this process (polkit wants an + /// interactive auth we can never give — a daemon with no logind + /// session). Never retried. + Policy, + /// Anything else (bus hiccup, logind restarting). Retried after + /// `RETRY_BACKOFF`. + Transient, +} + +/// Pure retry decision, so the backoff can be unit-tested without a bus. +fn should_attempt(last: Option<(Denial, Instant)>, now: Instant) -> bool { + match last { + None => true, + Some((Denial::Policy, _)) => false, + Some((Denial::Transient, at)) => now.duration_since(at) >= RETRY_BACKOFF, + } +} + +fn classify(error: &zbus::Error) -> Denial { + let text = error.to_string(); + if text.contains("InteractiveAuthorizationRequired") || text.contains("AccessDenied") { + Denial::Policy + } else { + Denial::Transient + } +} + /// Holds the inhibitor fd while playing. Owned by the MPRIS update loop /// (single-threaded); acquire/release are driven by playback-state updates. pub struct SleepInhibitor { @@ -27,11 +64,18 @@ pub struct SleepInhibitor { /// The inhibitor lock. `Some` while Playing; dropping it closes the fd, /// which is how logind releases the lock. fd: Option<OwnedFd>, + /// Last failed attempt and why — gates the next attempt (see + /// `should_attempt`). + last_failure: Option<(Denial, Instant)>, } impl SleepInhibitor { pub fn new() -> Self { - Self { conn: None, fd: None } + Self { + conn: None, + fd: None, + last_failure: None, + } } /// Drive the inhibitor from a playback transition: acquire on Playing, @@ -50,6 +94,9 @@ impl SleepInhibitor { if self.fd.is_some() { return; } + if !should_attempt(self.last_failure, Instant::now()) { + return; + } let conn = match &self.conn { Some(c) => c.clone(), None => match zbus::Connection::system().await { @@ -58,7 +105,11 @@ impl SleepInhibitor { c } Err(e) => { - log::warn!("[inhibit] system bus unavailable, cannot inhibit sleep: {e}"); + log::warn!( + "[inhibit] system bus unavailable, cannot inhibit sleep (retry in {}s): {e}", + RETRY_BACKOFF.as_secs() + ); + self.last_failure = Some((Denial::Transient, Instant::now())); return; } }, @@ -77,10 +128,27 @@ impl SleepInhibitor { Ok(fd) => { log::info!("[inhibit] acquired login1 sleep:idle inhibitor"); self.fd = Some(fd); + self.last_failure = None; + } + Err(e) => { + log::warn!("[inhibit] bad Inhibit reply (expected fd): {e}"); + self.last_failure = Some((Denial::Transient, Instant::now())); } - Err(e) => log::warn!("[inhibit] bad Inhibit reply (expected fd): {e}"), }, - Err(e) => log::warn!("[inhibit] login1 Inhibit call failed: {e}"), + Err(e) => { + let denial = classify(&e); + match denial { + Denial::Policy => log::info!( + "[inhibit] login1 refuses a sleep:idle inhibitor for this process ({e}); \ + giving up — playback is unaffected" + ), + Denial::Transient => log::warn!( + "[inhibit] login1 Inhibit call failed (retry in {}s): {e}", + RETRY_BACKOFF.as_secs() + ), + } + self.last_failure = Some((denial, Instant::now())); + } } } @@ -91,3 +159,28 @@ impl SleepInhibitor { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_attempt_is_always_made() { + assert!(should_attempt(None, Instant::now())); + } + + #[test] + fn policy_denial_is_never_retried() { + let at = Instant::now(); + assert!(!should_attempt(Some((Denial::Policy, at)), at)); + assert!(!should_attempt(Some((Denial::Policy, at)), at + RETRY_BACKOFF * 10)); + } + + #[test] + fn transient_failure_waits_out_the_backoff() { + let at = Instant::now(); + assert!(!should_attempt(Some((Denial::Transient, at)), at)); + assert!(!should_attempt(Some((Denial::Transient, at)), at + RETRY_BACKOFF / 2)); + assert!(should_attempt(Some((Denial::Transient, at)), at + RETRY_BACKOFF)); + } +} diff --git a/crates/qbz-media-controls/src/lib.rs b/crates/qbz-media-controls/src/lib.rs index 0f1aa0492..8c9fb9149 100644 --- a/crates/qbz-media-controls/src/lib.rs +++ b/crates/qbz-media-controls/src/lib.rs @@ -13,10 +13,10 @@ use std::sync::Arc; mod types; -pub use types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta}; +pub use types::{xesam_url_for, MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta}; pub mod notify; -pub use notify::{show_track_notification, NotificationMeta}; +pub use notify::{show_track_notification, withdraw_track_notification, NotificationMeta}; #[cfg(target_os = "linux")] mod inhibit; @@ -33,22 +33,51 @@ mod platform; /// **macOS:** souvlaki's command callbacks fire on the app run loop, so this is /// safe to call from any thread, but the run loop (Slint's winit loop) must be /// running for inbound events to arrive. +/// Keep the machine awake while playing. No-op off Windows; see +/// `platform::set_sleep_inhibit` for why it is not a handle method. +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub use platform::set_sleep_inhibit; + +/// Keep the machine awake while playing. No-op on this platform. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub fn set_sleep_inhibit(_playing: bool) {} + +/// The platform window identity the OS integration needs. +/// +/// Windows SMTC is registered against a window: souvlaki's backend calls +/// `config.hwnd.expect(..)`, so a missing handle is a PANIC, not a graceful +/// degrade. Linux and macOS key off the D-Bus name and the app bundle +/// respectively and ignore this entirely, which is why `default()` is the +/// right thing to pass there and from headless callers. +#[derive(Clone, Copy, Debug, Default)] +pub struct NativeWindow { + pub hwnd: Option<*mut core::ffi::c_void>, +} + +// SAFETY: an HWND is an opaque process-wide identifier, not a pointer that is +// dereferenced here. It is read once by souvlaki on the thread that receives +// it. Windows itself allows a window handle to be named from any thread; what +// is thread-affine is the window's message loop, which this never touches. +unsafe impl Send for NativeWindow {} + pub fn spawn( on_event: impl Fn(MediaEvent) + Send + Sync + 'static, + native: NativeWindow, ) -> Option<Box<dyn MediaIntegration>> { let cb: Arc<dyn Fn(MediaEvent) + Send + Sync> = Arc::new(on_event); #[cfg(target_os = "linux")] { + let _ = native; return linux::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>); } #[cfg(any(target_os = "macos", target_os = "windows"))] { - return platform::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>); + return platform::spawn(cb, native).map(|h| Box::new(h) as Box<dyn MediaIntegration>); } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { - let _ = cb; + let _ = (cb, native); None } } diff --git a/crates/qbz-media-controls/src/linux.rs b/crates/qbz-media-controls/src/linux.rs index 6d44b4dec..7fabf7249 100644 --- a/crates/qbz-media-controls/src/linux.rs +++ b/crates/qbz-media-controls/src/linux.rs @@ -20,7 +20,7 @@ use std::sync::{ use mpris_server::zbus::{self, fdo}; use mpris_server::{ LoopStatus, Metadata, PlaybackRate, PlaybackStatus as MprisStatus, PlayerInterface, Property, - RootInterface, Server, Time, TrackId, Volume, + RootInterface, Server, Signal, Time, TrackId, Volume, }; use crate::inhibit::SleepInhibitor; @@ -43,7 +43,16 @@ struct State { metadata: Metadata, status: MprisStatus, volume: Volume, + /// The last position we were TOLD, and when we were told it. + /// + /// MPRIS `Position` is read on demand, so what matters is the answer at + /// READ time, not at push time. Storing the instant alongside lets the + /// getter extrapolate — `position + (now - stamped)` while Playing — which + /// is exact to the microsecond no matter how often the app pushes. Without + /// it the answer is only as fresh as the last push, and the app would have + /// to choose between a stale clock and a faster poll it does not want. position: Time, + stamped: std::time::Instant, } /// Update commands sent from the app to the server thread. @@ -54,6 +63,15 @@ enum Update { position: Option<Time>, }, Volume(Volume), + /// Position ONLY — stores, emits nothing. MPRIS excludes `Position` from + /// `PropertiesChanged` by design, so there is no signal to send; the point + /// is purely that the next on-demand read is not stale. + Position(Time), + /// A DISCONTINUOUS jump. Stores like `Position` and additionally emits the + /// `Seeked` signal, which is the ONE thing that tells an already-open + /// client to stop extrapolating and re-read. Without it a widget keeps + /// counting from wherever it was until it happens to poll again. + Seeked(Time), } /// The cloneable handle returned to the app. Pushing state is a non-blocking @@ -77,6 +95,18 @@ impl MediaIntegration for LinuxHandle { fn set_volume(&self, vol: f64) { let _ = self.tx.try_send(Update::Volume(vol.clamp(0.0, 1.0))); } + + fn set_position(&self, position: std::time::Duration) { + let _ = self + .tx + .try_send(Update::Position(Time::from_micros(position.as_micros() as i64))); + } + + fn seeked(&self, position: std::time::Duration) { + let _ = self + .tx + .try_send(Update::Seeked(Time::from_micros(position.as_micros() as i64))); + } } fn map_status(s: PlaybackStatus) -> MprisStatus { @@ -105,6 +135,9 @@ fn build_metadata(meta: &TrackMeta) -> Metadata { if let Some(url) = &meta.art_url { b = b.art_url(url.clone()); } + if let Some(url) = &meta.url { + b = b.url(url.clone()); + } b.build() } @@ -231,7 +264,15 @@ impl PlayerInterface for QbzMpris { Ok(()) } async fn position(&self) -> fdo::Result<Time> { - Ok(self.state.lock().unwrap().position) + let st = self.state.lock().unwrap(); + // Extrapolate while playing; a paused or stopped player has not moved + // since it was stamped. + if st.status == MprisStatus::Playing { + let elapsed = st.stamped.elapsed().as_micros() as i64; + Ok(Time::from_micros(st.position.as_micros() + elapsed)) + } else { + Ok(st.position) + } } async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> { Ok(1.0) @@ -271,6 +312,19 @@ async fn apply(server: &Server<QbzMpris>, state: &Arc<Mutex<State>>, update: Upd st.status = status; if let Some(p) = position { st.position = p; + st.stamped = std::time::Instant::now(); + } else { + // A status change with no position still re-anchors: the + // extrapolation base must not keep running across a pause. + st.position = Time::from_micros( + st.position.as_micros() + + if st.status == MprisStatus::Playing { + st.stamped.elapsed().as_micros() as i64 + } else { + 0 + }, + ); + st.stamped = std::time::Instant::now(); } } let _ = server @@ -281,6 +335,19 @@ async fn apply(server: &Server<QbzMpris>, state: &Arc<Mutex<State>>, update: Upd state.lock().unwrap().volume = v; let _ = server.properties_changed([Property::Volume(v)]).await; } + Update::Position(p) => { + let mut st = state.lock().unwrap(); + st.position = p; + st.stamped = std::time::Instant::now(); + } + Update::Seeked(p) => { + { + let mut st = state.lock().unwrap(); + st.position = p; + st.stamped = std::time::Instant::now(); + } + let _ = server.emit(Signal::Seeked { position: p }).await; + } } } @@ -309,6 +376,7 @@ pub fn spawn(on_event: EventCb) -> Option<LinuxHandle> { status: MprisStatus::Stopped, volume: 1.0, position: Time::ZERO, + stamped: std::time::Instant::now(), })); let imp = QbzMpris { on_event, diff --git a/crates/qbz-media-controls/src/notify.rs b/crates/qbz-media-controls/src/notify.rs index 99363e621..7e251b12e 100644 --- a/crates/qbz-media-controls/src/notify.rs +++ b/crates/qbz-media-controls/src/notify.rs @@ -20,6 +20,31 @@ use std::path::PathBuf; +#[cfg(target_os = "linux")] +const PORTAL_NOTIFICATION_ID: &str = "track-now-playing"; + +/// The portal id of the LAST toast this process published, so the next one +/// (or a withdraw) can remove it. Ids are UNIQUE per toast on purpose +/// (`track-now-playing-<generation>`): re-publishing under one stable id is +/// an in-place update, and Plasma never re-presents an update as a banner — +/// `show-as-new` included (measured on the owner's desktop, 2026-08-31: a +/// same-id replacement with the hint produced NO popup while a fresh id +/// bannered every time). So each track change publishes a new id and removes +/// the previous one, which is the fresh-id presentation with the same +/// no-pileup lifecycle the stable id was for. +#[cfg(target_os = "linux")] +static PORTAL_NOTIFICATION_LAST: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None); + +/// Serializes portal mutations and invalidates slow, stale artwork jobs. A +/// track-A notification can spend seconds downloading its cover while track B +/// starts (or playback stops); without this generation check A may overwrite B +/// or resurrect the notification after it was withdrawn. +#[cfg(target_os = "linux")] +static PORTAL_NOTIFICATION_GENERATION: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); +#[cfg(target_os = "linux")] +static PORTAL_NOTIFICATION_GATE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// Everything needed to render a track-change notification. The crate formats /// the body + quality line itself so the output matches the Tauri notification /// exactly, regardless of frontend. @@ -87,9 +112,9 @@ fn build_body(meta: &NotificationMeta) -> String { lines.join("\n") } -// --- artwork cache (Linux + macOS) ------------------------------------------ +// --- artwork cache (Linux + macOS + Windows) ------------------------------------------ -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn artwork_cache_dir() -> Result<PathBuf, String> { let dir = dirs::cache_dir() .ok_or_else(|| "Could not find cache directory".to_string())? @@ -99,7 +124,7 @@ fn artwork_cache_dir() -> Result<PathBuf, String> { Ok(dir) } -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn resolve_local_artwork(url: &str) -> Option<PathBuf> { if let Some(path) = url.strip_prefix("file://") { // file:// URLs built with url::Url::from_file_path (e.g. the shared @@ -109,6 +134,29 @@ fn resolve_local_artwork(url: &str) -> Option<PathBuf> { let decoded = urlencoding::decode(path) .map(|c| c.into_owned()) .unwrap_or_else(|_| path.to_string()); + // `file:///C:/x` leaves `/C:/x` here: the empty authority's slash, in + // front of a drive letter. PathBuf keeps it and the file never + // resolves, so the toast silently shows no cover. Forward slashes + // after that are fine -- Windows accepts them. + // + // Additive: Linux and macOS never match this shape, and their full + // percent-decode above is left exactly as it was. It has to stay + // full: art_url reaches here from BOTH url::Url::from_file_path + // (which escapes spaces as %20) and fs_url::file_url (which escapes + // only % # ?), and only the wider decode reads both. + #[cfg(target_os = "windows")] + { + let b = decoded.as_bytes(); + if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { + return Some(PathBuf::from(&decoded[1..])); + } + // A non-empty authority is a UNC server: `file://nas/share/x` + // names `\\nas\share\x`. Without this it stays RELATIVE and + // resolves against the process working directory. + if !decoded.starts_with('/') && !decoded.is_empty() { + return Some(PathBuf::from(format!("//{decoded}"))); + } + } return Some(PathBuf::from(decoded)); } if let Some(path) = url.strip_prefix("asset://localhost/") { @@ -120,7 +168,7 @@ fn resolve_local_artwork(url: &str) -> Option<PathBuf> { /// Shared blocking HTTP client (a fresh client per track leaks an fd → EMFILE /// over a long session — same reasoning as the Tauri image cache). -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn http_client() -> &'static reqwest::blocking::Client { static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new(); CLIENT.get_or_init(|| { @@ -136,7 +184,7 @@ fn http_client() -> &'static reqwest::blocking::Client { /// `offline` = local paths + md5 cache hits only, never the HTTP download — /// the verdict is injected by the caller so this crate stays frontend-agnostic /// (no dependency on the app's offline-mode engine). -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn cache_artwork(url: &str, offline: bool) -> Result<PathBuf, String> { use md5::{Digest, Md5}; use std::io::Write; @@ -251,9 +299,19 @@ pub async fn show_track_notification(meta: NotificationMeta, offline: bool) { { use ashpd::desktop::notification::{Notification as PortalNotification, NotificationProxy}; use ashpd::desktop::Icon; + use std::sync::atomic::Ordering; - let mut notification = - PortalNotification::new(&meta.title).body(Some(body.as_str())); + let generation = PORTAL_NOTIFICATION_GENERATION + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1); + + // A UNIQUE id per toast — see PORTAL_NOTIFICATION_LAST for why the + // stable-id + show-as-new shape was abandoned: Plasma presents a + // same-id replacement as a silent in-place update, hint or not. The + // previous toast is removed right before the new publish below, so + // the no-pileup lifecycle survives the id change. + let notification_id = format!("{PORTAL_NOTIFICATION_ID}-{generation}"); + let mut notification = PortalNotification::new(&meta.title).body(Some(body.as_str())); if let Some(url) = meta.art_url.clone() { let prepared = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> { @@ -273,11 +331,28 @@ pub async fn show_track_notification(meta: NotificationMeta, offline: bool) { match NotificationProxy::new().await { Ok(proxy) => { - if let Err(e) = proxy - .add_notification("track-now-playing", notification) - .await - { - log::warn!("[notify] XDG portal add_notification failed: {e}"); + let _guard = PORTAL_NOTIFICATION_GATE.lock().await; + if PORTAL_NOTIFICATION_GENERATION.load(Ordering::Acquire) != generation { + log::debug!("[notify] stale track notification discarded"); + return; + } + // Retire the previous toast FIRST: on a rapid skip its banner + // may still be on screen, and the new publish must not stack + // on it. The record is swapped before the awaits (a std lock + // is never held across them); a failed add then leaves a + // dangling id behind, whose later removal is a harmless no-op. + let previous = PORTAL_NOTIFICATION_LAST + .lock() + .map(|mut last| last.replace(notification_id.clone())) + .unwrap_or(None); + if let Some(prev) = previous { + if let Err(e) = proxy.remove_notification(&prev).await { + log::debug!("[notify] XDG portal remove_notification failed: {e}"); + } + } + match proxy.add_notification(¬ification_id, notification).await { + Ok(()) => log::debug!("[notify] XDG portal notification published"), + Err(e) => log::warn!("[notify] XDG portal add_notification failed: {e}"), } } Err(e) => log::warn!("[notify] XDG notification portal unavailable: {e}"), @@ -307,10 +382,82 @@ pub async fn show_track_notification(meta: NotificationMeta, offline: bool) { .await; } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(target_os = "windows")] + { + let _ = tokio::task::spawn_blocking(move || { + let artwork_path = meta.art_url.as_deref().and_then(|url| { + match cache_artwork(url, offline) { + Ok(path) => Some(path), + Err(e) => { + log::debug!("[notify] could not cache artwork: {e}"); + None + } + } + }); + let mut notification = notify_rust::Notification::new(); + // app_id MUST match the AUMID set in main() and registered by the + // MSI, or Windows drops the toast without a word. notify-rust's + // Windows arm forwards summary/body/app_id/image and SILENTLY + // ignores `actions` (src/windows.rs), so no buttons are attempted + // here -- adding them would look implemented and do nothing. + notification + .summary(&meta.title) + .body(&body) + .app_id("com.blitzfc.qbz"); + if let Some(path) = artwork_path.as_ref().and_then(|p| p.to_str()) { + notification.image_path(path); + } + if let Err(e) = notification.show() { + log::warn!("[notify] Windows toast failed: {e}"); + } + }) + .await; + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = body; let _ = offline; log::info!("[notify] desktop notifications not implemented on this platform"); } } + +/// Withdraw the active Linux portal notification, if any. This also +/// invalidates an in-flight artwork preparation so it cannot publish after a +/// stop or process shutdown. Other platforms currently have no replaceable +/// notification handle, so this is a deliberate no-op there. +pub async fn withdraw_track_notification() { + #[cfg(target_os = "linux")] + { + use ashpd::desktop::notification::NotificationProxy; + use std::sync::atomic::Ordering; + + let generation = PORTAL_NOTIFICATION_GENERATION + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1); + let proxy = match NotificationProxy::new().await { + Ok(proxy) => proxy, + Err(e) => { + log::debug!("[notify] XDG notification portal unavailable during withdrawal: {e}"); + return; + } + }; + let _guard = PORTAL_NOTIFICATION_GATE.lock().await; + if PORTAL_NOTIFICATION_GENERATION.load(Ordering::Acquire) != generation { + return; + } + // The ids are per-toast now (see PORTAL_NOTIFICATION_LAST) — take and + // remove whichever one this process last published. Nothing published + // = nothing to withdraw. + let target = PORTAL_NOTIFICATION_LAST + .lock() + .map(|mut last| last.take()) + .unwrap_or(None); + let Some(target) = target else { + return; + }; + if let Err(e) = proxy.remove_notification(&target).await { + log::debug!("[notify] XDG portal remove_notification failed: {e}"); + } + } +} diff --git a/crates/qbz-media-controls/src/platform.rs b/crates/qbz-media-controls/src/platform.rs index a9eff1fab..5224595f0 100644 --- a/crates/qbz-media-controls/src/platform.rs +++ b/crates/qbz-media-controls/src/platform.rs @@ -8,18 +8,109 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; -use souvlaki::{ - MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, - SeekDirection, -}; +use souvlaki::{MediaControls, MediaMetadata, MediaPlayback, MediaPosition}; +use souvlaki::{MediaControlEvent, PlatformConfig, SeekDirection}; use crate::types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta}; +use crate::NativeWindow; /// Default step for a magnitude-less MPRIS-style `Seek` (5 seconds, micros). const SEEK_STEP_MICROS: i64 = 5_000_000; type EventCb = Arc<dyn Fn(MediaEvent) + Send + Sync>; +/// Keeps Windows from sleeping while a track plays. +/// +/// `SetThreadExecutionState` sets state on the CALLING THREAD, and this +/// module's own contract says outbound `set_playback` is "free from any +/// thread" -- in practice the mpris-server thread, a tokio worker, or the Qt +/// GUI thread, whichever pushed the update. Calling the API from there would +/// pin the flag to a thread that may be a pool worker with a short life, and +/// the request would silently evaporate with it. +/// +/// So the state is owned by ONE thread that lives as long as the process, and +/// callers only send it a bool. Windows drops every request a thread made when +/// that thread exits, which is exactly why this thread never does. +#[cfg(target_os = "windows")] +mod sleep { + use std::sync::mpsc::{channel, Sender}; + use std::sync::OnceLock; + + fn worker() -> &'static Sender<bool> { + static TX: OnceLock<Sender<bool>> = OnceLock::new(); + TX.get_or_init(|| { + let (tx, rx) = channel::<bool>(); + std::thread::Builder::new() + .name("qbz-sleep-inhibit".to_string()) + .spawn(move || { + use windows_sys::Win32::System::Power::{ + SetThreadExecutionState, ES_CONTINUOUS, ES_SYSTEM_REQUIRED, + }; + let mut held = false; + while let Ok(playing) = rx.recv() { + if playing == held { + continue; + } + // ES_SYSTEM_REQUIRED without ES_DISPLAY_REQUIRED: an + // audio player must keep the MACHINE awake, not the + // screen. No ES_AWAYMODE_REQUIRED either -- that is for + // media-centre appliances and it suppresses the sleep + // the user asked for. + let flags = if playing { + ES_CONTINUOUS | ES_SYSTEM_REQUIRED + } else { + ES_CONTINUOUS + }; + // SAFETY: a plain flags call with no pointers. Always on + // this thread, which is the whole point of the module. + let previous = unsafe { SetThreadExecutionState(flags) }; + if previous == 0 { + // The call FAILED (it returns the previous state, + // or zero). Leave `held` alone: recording the new + // value here would make every later message with + // the same bool a no-op, so a failed RELEASE would + // pin the machine awake for the rest of the + // session with nothing left to undo it. + log::warn!( + "[inhibit] SetThreadExecutionState failed; execution state unchanged" + ); + continue; + } + held = playing; + log::debug!( + "[inhibit] Windows execution state -> {}", + if playing { "system required" } else { "continuous" } + ); + } + }) + .expect("spawn sleep-inhibit thread"); + tx + }) + } + + pub(super) fn set_playing(playing: bool) { + let _ = worker().send(playing); + } +} + +/// Keep the machine awake while playing, INDEPENDENTLY of whether the media +/// integration started. +/// +/// Deliberately a free function and not a `PlatformHandle` method. Hanging it +/// off the handle tied it to SMTC: every caller returns early when +/// `handle()` is `None`, so a failed souvlaki init or an HWND that never +/// arrived would leave playback working and the machine falling asleep +/// mid-track -- two unrelated features failing together for no reason. +/// +/// A no-op everywhere but Windows: Linux hangs its logind inhibitor off the +/// MPRIS update (`inhibit.rs`) and macOS needs none. +pub fn set_sleep_inhibit(playing: bool) { + #[cfg(target_os = "windows")] + sleep::set_playing(playing); + #[cfg(not(target_os = "windows"))] + let _ = playing; +} + pub struct PlatformHandle { controls: Arc<Mutex<Option<MediaControls>>>, } @@ -28,12 +119,19 @@ impl MediaIntegration for PlatformHandle { fn set_metadata(&self, meta: &TrackMeta) { if let Ok(mut guard) = self.controls.lock() { if let Some(c) = guard.as_mut() { + // SMTC wants a NATIVE path where MPRIS wants a URL; see + // `cover_for_souvlaki`. Bound to a local so the &str borrowed + // into MediaMetadata outlives the call. + let cover = meta + .art_url + .as_deref() + .map(|u| cover_for_souvlaki(u, cfg!(target_os = "windows"))); let md = MediaMetadata { title: Some(meta.title.as_str()), artist: Some(meta.artist.as_str()), album: Some(meta.album.as_str()), duration: meta.duration, - cover_url: meta.art_url.as_deref(), + cover_url: cover.as_deref(), }; let _ = c.set_metadata(md); } @@ -85,13 +183,96 @@ fn map_event(e: MediaControlEvent) -> Option<MediaEvent> { }) } -pub fn spawn(on_event: EventCb) -> Option<PlatformHandle> { +/// Convert a cover-art URL into the exact string souvlaki's Windows arm can +/// open. +/// +/// souvlaki 0.8.3 (`src/platform/windows/mod.rs:192-203`) branches like this: +/// +/// ```text +/// if url.starts_with("file://") { +/// let path = url.trim_start_matches("file://"); +/// StorageFile::GetFileFromPathAsync(path) // wants C:\x or \\nas\x +/// } else { +/// RandomAccessStreamReference::CreateFromUri(url) +/// } +/// ``` +/// +/// So `file://` is the DISCRIMINATOR and must survive, while everything after +/// it has to be a native Windows path. Two ways to get this wrong, and the +/// first draft of this function managed one of them: +/// +/// - Hand over `file:///C:/x.jpg` and souvlaki trims seven characters, leaving +/// `/C:/x.jpg` -- a leading slash before a drive letter, which never opens. +/// - Hand over a bare `C:\x.jpg` and it no longer starts with `file://`, so it +/// takes the REMOTE branch and `Uri::CreateUri` fails on it. +/// +/// The answer is the hybrid `file://C:\x.jpg`: prefix kept, remainder native. +/// +/// UNC is a real Windows path too. `file://nas/music/a.jpg` names +/// `\\nas\music\a.jpg`, and leaving it alone would hand souvlaki the +/// relative `nas/music/a.jpg`. +/// +/// The percent-decode is FULL and applied ONCE. `art_url` reaches this from +/// two producers -- `url::Url::from_file_path`, which escapes a space as +/// `%20`, and `fs_url::file_url`, which escapes only `%`, `#` and `?` -- and a +/// single full decode is correct for both. `fs_url::local_path` is NOT used +/// here for that reason: its deliberate three-escape decode would leave a +/// `%20` from the other producer intact. +pub(crate) fn cover_for_souvlaki(url: &str, windows: bool) -> String { + if !windows { + return url.to_string(); + } + let Some(rest) = url.strip_prefix("file://") else { + // http(s) and anything else: souvlaki's Uri branch is the right one. + return url.to_string(); + }; + + let decoded = urlencoding::decode(rest) + .map(|c| c.into_owned()) + .unwrap_or_else(|_| rest.to_string()); + let b = decoded.as_bytes(); + + let native = if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { + // `file:///C:/x` -> the empty authority's slash sits in front of the + // drive letter; drop it. + decoded[1..].to_string() + } else if !decoded.starts_with('/') && !decoded.is_empty() { + // A non-empty authority is a UNC server: `file://nas/share/x`. + format!("//{decoded}") + } else { + // A POSIX absolute path. Not a Windows path at all, and nothing here + // can make it one -- hand it back untouched rather than inventing + // `\home\...`. + return url.to_string(); + }; + + format!("file://{}", native.replace('/', "\\")) +} + +pub fn spawn(on_event: EventCb, native: NativeWindow) -> Option<PlatformHandle> { + // souvlaki's Windows backend does NOT degrade gracefully without an HWND: + // `config.hwnd.expect(...)` at souvlaki-0.8.3/src/platform/windows/mod.rs:57-59 + // PANICS. The guard therefore stays even now that a handle IS wired: + // a missing integration is a missing feature, a panic inside a tokio task + // is a dead session. The caller reads the HWND on the GUI thread after the + // window is shown, so None here means it asked too early. + #[cfg(target_os = "windows")] + if native.hwnd.is_none() { + log::warn!( + "[media-controls] SMTC disabled: no top-level window yet (init ran before it was shown)" + ); + return None; + } + + spawn_native(on_event, native) +} + +fn spawn_native(on_event: EventCb, native: NativeWindow) -> Option<PlatformHandle> { let config = PlatformConfig { dbus_name: "com.blitzfc.qbz", display_name: "QBZ", - // macOS: unused. Windows SMTC needs the window HWND; not shipped/tested, - // so left None (init may fail there → None handle, no media controls). - hwnd: None, + // macOS ignores it; Windows registers SMTC against this window. + hwnd: native.hwnd, }; let mut controls = match MediaControls::new(config) { @@ -116,3 +297,90 @@ pub fn spawn(on_event: EventCb) -> Option<PlatformHandle> { controls: Arc::new(Mutex::new(Some(controls))), }) } + +#[cfg(test)] +mod tests { + use super::cover_for_souvlaki; + + #[test] + fn windows_keeps_the_file_prefix_and_makes_the_rest_native() { + // The prefix is souvlaki's discriminator: drop it and the string takes + // the remote-URI branch. Keep it, and what it trims must be a real + // Windows path. + assert_eq!( + cover_for_souvlaki("file:///C:/Users/v/c.jpg", true), + "file://C:\\Users\\v\\c.jpg" + ); + } + + #[test] + fn non_windows_is_byte_identical() { + assert_eq!( + cover_for_souvlaki("file:///home/v/c.jpg", false), + "file:///home/v/c.jpg" + ); + assert_eq!( + cover_for_souvlaki("file:///C:/Users/v/c.jpg", false), + "file:///C:/Users/v/c.jpg" + ); + } + + #[test] + fn remote_urls_are_never_touched() { + assert_eq!(cover_for_souvlaki("https://x/y.jpg", true), "https://x/y.jpg"); + } + + #[test] + fn a_unc_url_becomes_a_unc_path() { + // `file://nas/music/a.jpg` is a real Windows path. Left alone, souvlaki + // would trim it to the RELATIVE `nas/music/a.jpg`. + assert_eq!( + cover_for_souvlaki("file://nas/music/a.jpg", true), + "file://\\\\nas\\music\\a.jpg" + ); + } + + #[test] + fn a_posix_path_is_left_alone_even_on_windows() { + // No drive, no authority: nothing here can turn it into a Windows path. + assert_eq!( + cover_for_souvlaki("file:///home/v/c.jpg", true), + "file:///home/v/c.jpg" + ); + } + + #[test] + fn both_producers_percent_decode_the_same_way() { + // fs_url::file_url leaves a space LITERAL... + assert_eq!( + cover_for_souvlaki("file:///C:/My Music/a b.jpg", true), + "file://C:\\My Music\\a b.jpg" + ); + // ...while url::Url::from_file_path escapes it as %20. One full decode + // is right for both. + assert_eq!( + cover_for_souvlaki("file:///C:/My%20Music/a.jpg", true), + "file://C:\\My Music\\a.jpg" + ); + } + + #[test] + fn the_escapes_that_matter_are_undone() { + // A # in a filename reaches SMTC as %23 otherwise, and the cover + // silently never loads. + assert_eq!( + cover_for_souvlaki("file:///C:/m/a%23b%3Fc.jpg", true), + "file://C:\\m\\a#b?c.jpg" + ); + } + + #[test] + fn a_literal_percent_twenty_is_not_double_decoded() { + // A filename that really contains "%20" is escaped to %2520 by either + // producer; ONE decode must give back "%20", not a space. + assert_eq!( + cover_for_souvlaki("file:///C:/m/a%2520b.jpg", true), + "file://C:\\m\\a%20b.jpg" + ); + } +} diff --git a/crates/qbz-media-controls/src/types.rs b/crates/qbz-media-controls/src/types.rs index 9193faff4..4a34abeb3 100644 --- a/crates/qbz-media-controls/src/types.rs +++ b/crates/qbz-media-controls/src/types.rs @@ -13,6 +13,26 @@ pub struct TrackMeta { /// is NOT the application icon (that is resolved by GNOME from the MPRIS /// `DesktopEntry` property → the installed .desktop → `Icon=`). pub art_url: Option<String>, + /// `xesam:url` (#658) — the track's PUBLIC web link so external scripts + /// (playerctl widgets, handoff automation, scrobblers) can identify the + /// exact song: `https://open.qobuz.com/track/<id>` for Qobuz catalog rows + /// (offline `qobuz_download` keeps its real Qobuz id, so it qualifies too). + /// None for local/Plex rows — QueueTrack carries no file path to map to a + /// file:// URI. + pub url: Option<String>, +} + +/// Build the `xesam:url` for a queue track (#658): the public Qobuz web link +/// (`https://open.qobuz.com/track/<id>`) for catalog tracks — offline +/// `qobuz_download` rows qualify too, their id IS the real Qobuz id. None for +/// `local` / `plex` rows: QueueTrack carries no file path to map to a file:// +/// URI, and a synthetic local id would produce a bogus Qobuz link. +pub fn xesam_url_for(source: Option<&str>, track_id: u64) -> Option<String> { + match source.map(|s| s.to_ascii_lowercase()) { + Some(ref s) if s == "local" || s == "plex" => None, + _ if track_id > 0 => Some(format!("https://open.qobuz.com/track/{track_id}")), + _ => None, + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -50,4 +70,39 @@ pub trait MediaIntegration: Send + Sync { fn set_metadata(&self, meta: &TrackMeta); fn set_playback(&self, status: PlaybackStatus, position: Option<Duration>); fn set_volume(&self, vol: f64); + + /// Keep the reported playback POSITION current. + /// + /// MPRIS `Position` is a read-on-demand property — the spec explicitly + /// excludes it from `PropertiesChanged`, so a client reads it when it + /// wants it and otherwise extrapolates. That makes a stored snapshot the + /// wrong shape unless somebody keeps it fresh: before this existed, the + /// Linux backend only moved its stored position when `set_playback` + /// carried one — at a track start and at a play/pause edge — so a desktop + /// widget would sit on a value minutes stale. Measured 2026-08-04: QBZ at + /// 4:03, Plasma's widget frozen at 0:14. + /// + /// Callers push this on their existing playback tick. It stores and emits + /// NOTHING, so it is cheap enough to call once a second and cannot spam + /// the bus. + /// + /// Defaulted to a no-op so a backend that has no notion of position (and + /// any future one) needs no change. + fn set_position(&self, position: Duration) { + let _ = position; + } + + /// Report a DISCONTINUOUS jump — a user seek, not the ordinary advance of + /// playback. + /// + /// This is the one thing that reaches a client which is already open and + /// extrapolating: `Seeked` tells it to stop and re-read. Keeping the + /// stored position fresh (above) fixes what a client sees when it OPENS; + /// this fixes what it sees while it is already watching. + /// + /// Defaulted to `set_position`, so a backend without the signal still gets + /// the value right on the next read. + fn seeked(&self, position: Duration) { + self.set_position(position); + } } diff --git a/crates/qbz-mixtape/Cargo.toml b/crates/qbz-mixtape/Cargo.toml index 1aacf0014..08896b41a 100644 --- a/crates/qbz-mixtape/Cargo.toml +++ b/crates/qbz-mixtape/Cargo.toml @@ -6,14 +6,12 @@ description = "Mixtapes & Collections backend for QBZ — schema, repo, shuffle, authors = ["blitzkriegfc"] [dependencies] -# Shared QBZ crates (the real models + resolvers, not src-tauri shims) +# Shared QBZ crates qbz-models = { path = "../qbz-models" } qbz-qobuz = { path = "../qbz-qobuz" } -qbz-library = { path = "../qbz-library" } -qbz-plex = { path = "../qbz-plex" } # Database -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } # UUID generation uuid = { version = "1", features = ["v4"] } diff --git a/crates/qbz-mixtape/src/enqueue.rs b/crates/qbz-mixtape/src/enqueue.rs index 792d0c8b1..99ed3139e 100644 --- a/crates/qbz-mixtape/src/enqueue.rs +++ b/crates/qbz-mixtape/src/enqueue.rs @@ -6,13 +6,9 @@ //! //! Frontend-agnostic notes (ADR-006): //! - The Qobuz resolvers are async free fns over `qbz_qobuz::QobuzClient`. -//! - The local/Plex resolvers are SYNCHRONOUS free fns over -//! `qbz_library::LibraryDatabase` / the `qbz-plex` cache. `&LibraryDatabase` -//! wraps a `rusqlite::Connection`, which is `!Sync`, so a `&LibraryDatabase` -//! is `!Send` and must NEVER be held across an `.await`. Keeping local/Plex -//! resolution in a synchronous free fn enforces that at the type level: the -//! caller does its own DB access (e.g. Slint's `with_db(|db| ...)`) and the -//! crate bakes in no specific handle type. +//! - Provider-specific local and LAN resolution belongs to `qbz-source`. +//! This crate only defines collection ordering/flattening and the generic +//! resolver seam, so it does not pull provider databases into its graph. use qbz_models::mixtape::{AlbumSource, CollectionPlayMode, ItemType, MixtapeCollectionItem}; use qbz_models::QueueTrack as CoreQueueTrack; @@ -21,8 +17,8 @@ use qbz_models::QueueTrack as CoreQueueTrack; use qbz_models::Track as ApiTrack; /// Trait for expanding a single Mixtape item into its tracks. Implementations: -/// - `ProdItemResolver` — uses the Qobuz client + a caller-supplied local -/// resolver (the real production path) +/// - `ProdItemResolver` — uses the Qobuz client plus a caller-supplied resolver +/// for non-Qobuz items /// - mocks in `#[cfg(test)]` #[async_trait::async_trait] pub trait ItemResolver: Send + Sync { @@ -93,7 +89,10 @@ pub fn shuffle_items(mut items: Vec<MixtapeCollectionItem>) -> Vec<MixtapeCollec pub fn next_item_index(queue: &[CoreQueueTrack], current: usize) -> Option<usize> { let boundary_of = |i: usize| -> Option<&str> { queue.get(i).and_then(|track| { - track.source_item_id_hint.as_deref().or(track.album_id.as_deref()) + track + .source_item_id_hint + .as_deref() + .or(track.album_id.as_deref()) }) }; let current_boundary = boundary_of(current)?; @@ -127,9 +126,7 @@ pub fn previous_item_index( let current_boundary = boundary_of(current)?; let mut item_start = current; - while item_start > 0 - && boundary_of(item_start - 1) == Some(current_boundary.clone()) - { + while item_start > 0 && boundary_of(item_start - 1) == Some(current_boundary.clone()) { item_start -= 1; } @@ -152,16 +149,9 @@ pub fn previous_item_index( // ──────────────────────────── ProdItemResolver ──────────────────────────── -/// Production resolver. Holds a reference to the shared Qobuz client and a -/// caller-supplied `local` closure that resolves local/Plex items -/// synchronously. -/// -/// `&qbz_library::LibraryDatabase` is `!Send`/`!Sync` (it wraps a rusqlite -/// `Connection`), so it cannot be stored here without breaking the -/// `ItemResolver: Send + Sync` bound. Instead the caller supplies a -/// `Send + Sync` closure that performs the DB access in its own synchronous -/// scope (e.g. Slint's `with_db(|db| resolve_local_album_tracks(db, key))`). -/// This keeps the crate free of any frontend's DB-handle type. +/// Resolver over the shared Qobuz client plus a caller-supplied synchronous +/// closure for non-Qobuz items. The closure keeps provider state and database +/// handles outside this generic crate. pub struct ProdItemResolver<'a, L> where L: Fn(&MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, String> + Send + Sync, @@ -174,9 +164,8 @@ impl<'a, L> ProdItemResolver<'a, L> where L: Fn(&MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, String> + Send + Sync, { - /// Build a production resolver from the shared Qobuz client and a local - /// resolver closure. The closure is invoked only for `AlbumSource::Local` - /// items and must perform its DB access synchronously. + /// Build a resolver from the shared Qobuz client and a non-Qobuz resolver + /// closure. The closure is invoked only for `AlbumSource::Local` items. pub fn new(client: &'a qbz_qobuz::QobuzClient, local: L) -> Self { Self { client, local } } @@ -206,8 +195,7 @@ where .map_err(|_| format!("invalid qobuz playlist id: {}", item.source_item_id))?; resolve_qobuz_playlist(self.client, playlist_id).await } - // All local/Plex resolution is delegated to the caller-supplied - // synchronous closure (no `&LibraryDatabase` held across `.await`). + // Provider resolution is delegated to the caller-supplied closure. (_, AlbumSource::Local) => (self.local)(item), } } @@ -236,7 +224,10 @@ pub async fn resolve_qobuz_album( // Build QueueTrack from each track. We have the parent Album in scope so // we can fill artwork / album title / album artist even when the track's // own `album` field is absent (shallow responses inside albums/get). - let album_artwork = album.image.large.clone() + let album_artwork = album + .image + .large + .clone() .or_else(|| album.image.extralarge.clone()) .or_else(|| album.image.thumbnail.clone()); let album_title = album.title.clone(); @@ -275,13 +266,18 @@ pub async fn resolve_qobuz_album( is_local: false, album_id: Some(album_id_str.clone()), artist_id, - streamable: track.streamable, + // Resolved once here: `CoreQueueTrack.streamable` is a plain + // `bool`, and absence on the catalog `Track` means "the + // endpoint was terse", not "pulled" (`Track::is_streamable`). + streamable: track.is_streamable(), source: Some("qobuz".to_string()), parental_warning: track.parental_warning, // Stamped centrally by resolve_collection_tracks; left None here. source_item_id_hint: None, context_kind: None, context_id: None, + isrc: None, + recording_mbid: None, } }) .collect(); @@ -326,105 +322,7 @@ pub async fn resolve_qobuz_playlist( Ok(tracks.iter().map(track_to_queue_track_from_api).collect()) } -// ── Local album (synchronous, frontend-agnostic) ── - -/// Resolve a `Local` album item's `source_item_id` into tracks. -/// -/// Routes the `plex:`-prefixed keys to the Plex cache; everything else is a -/// true local album resolved against the passed `&LibraryDatabase`. Both -/// branches are synchronous — no `&LibraryDatabase` is held across an `.await`. -pub fn resolve_local_album( - db: &qbz_library::LibraryDatabase, - group_key: &str, -) -> Result<Vec<CoreQueueTrack>, String> { - // Plex-backed items carry a Plex album_key as their source_item_id. Those - // rows live in the Plex cache DB (plex_cache_tracks), not local_tracks, - // so route them to the Plex cache fetcher instead of db.get_album_tracks. - if group_key.starts_with("plex:") { - return resolve_plex_album_tracks(group_key); - } - - resolve_local_album_tracks(db, group_key) -} - -/// Resolve a true local album group (no `plex:` prefix) against the library DB. -pub fn resolve_local_album_tracks( - db: &qbz_library::LibraryDatabase, - group_key: &str, -) -> Result<Vec<CoreQueueTrack>, String> { - let tracks = db - .get_album_tracks(group_key) - .map_err(|e| format!("local get_album_tracks({}) failed: {}", group_key, e))?; - - if tracks.is_empty() { - return Err(format!("local album {} has 0 tracks", group_key)); - } - - Ok(tracks.iter().map(local_track_to_queue_track).collect()) -} - -/// Resolve a `plex:`-prefixed album key against the shared Plex cache. -pub fn resolve_plex_album_tracks(group_key: &str) -> Result<Vec<CoreQueueTrack>, String> { - let tracks = qbz_plex::plex_cache_get_album_tracks(group_key.to_string()) - .map_err(|e| format!("plex cache get_album_tracks({}) failed: {}", group_key, e))?; - if tracks.is_empty() { - return Err(format!( - "plex album {} has 0 tracks (cache empty — visit LocalLibrary to sync)", - group_key - )); - } - Ok(tracks.iter().map(plex_cached_track_to_queue_track).collect()) -} - -// ── Local track (synchronous) ── - -pub fn resolve_local_track( - db: &qbz_library::LibraryDatabase, - track_id: i64, -) -> Result<Vec<CoreQueueTrack>, String> { - let track = db - .get_track(track_id) - .map_err(|e| format!("local get_track({}) failed: {}", track_id, e))? - .ok_or_else(|| format!("local track {} not found", track_id))?; - - Ok(vec![local_track_to_queue_track(&track)]) -} - -/// Full local-item dispatch contract (Album / Track / Playlist), centralized in -/// the crate so frontends do NOT re-implement (and silently drift from) the -/// matrix. This is the synchronous `&LibraryDatabase` counterpart of the async -/// Qobuz resolvers; a frontend wires it into `ProdItemResolver`'s `local` -/// closure through its own DB accessor — e.g. Slint's -/// `with_db(|db| resolve_local_item(db, item))` (the closure runs in a sync -/// scope, so `&LibraryDatabase` never crosses an `.await`). -/// -/// Mirrors the original src-tauri `ProdItemResolver` Local arms exactly: -/// - Album → [`resolve_local_album`] (handles the `plex:` prefix internally) -/// - Track → parse `source_item_id` to `i64` (`invalid local track id`) → [`resolve_local_track`] -/// - Playlist → hard error `local playlists not supported in this release` -pub fn resolve_local_item( - db: &qbz_library::LibraryDatabase, - item: &MixtapeCollectionItem, -) -> Result<Vec<CoreQueueTrack>, String> { - match item.item_type { - ItemType::Album => resolve_local_album(db, &item.source_item_id), - ItemType::Track => { - let track_id: i64 = item - .source_item_id - .parse() - .map_err(|_| format!("invalid local track id: {}", item.source_item_id))?; - resolve_local_track(db, track_id) - } - ItemType::Playlist => { - // Local playlists are not supported in this release. The library DB - // schema stores qobuz_playlist_id + local_track_id rows but there is - // no unique "local-only playlist id" to resolve against. - Err("local playlists not supported in this release".into()) - } - } -} - -// ── Shared mapping helpers ── +// ── Qobuz mapping helper ── /// Map a Qobuz API `Track` to a `CoreQueueTrack`. pub fn track_to_queue_track_from_api(track: &ApiTrack) -> CoreQueueTrack { @@ -433,7 +331,12 @@ pub fn track_to_queue_track_from_api(track: &ApiTrack) -> CoreQueueTrack { .as_ref() .and_then(|a| a.image.large.clone()) .or_else(|| track.album.as_ref().and_then(|a| a.image.thumbnail.clone())) - .or_else(|| track.album.as_ref().and_then(|a| a.image.extralarge.clone())); + .or_else(|| { + track + .album + .as_ref() + .and_then(|a| a.image.extralarge.clone()) + }); let artist = track .performer .as_ref() @@ -462,93 +365,15 @@ pub fn track_to_queue_track_from_api(track: &ApiTrack) -> CoreQueueTrack { is_local: false, album_id, artist_id, - streamable: track.streamable, + // Same resolution as the album path above, single-track flavour. + streamable: track.is_streamable(), source: Some("qobuz".to_string()), parental_warning: track.parental_warning, source_item_id_hint: None, context_kind: None, context_id: None, - } -} - -/// Map a cached Plex track to a CoreQueueTrack. The Plex rating_key (numeric -/// string) becomes the QueueTrack.id so the frontend's Plex playback path -/// (`v2_plex_play_track` with `ratingKey: String(track.id)`) resolves to the -/// same Plex object. source="plex" lets playback route to the Plex branch -/// instead of the local-file branch. -pub fn plex_cached_track_to_queue_track(track: &qbz_plex::PlexCachedTrack) -> CoreQueueTrack { - let id: u64 = track.rating_key.parse().unwrap_or(track.id); - let sample_rate_khz = if track.sample_rate > 0 { - Some((track.sample_rate as f64) / 1000.0) - } else { - None - }; - CoreQueueTrack { - id, - title: track.title.clone(), - version: None, - artist: track.artist.clone(), - album: track.album.clone(), - album_version: None, - duration_secs: track.duration_secs, - artwork_url: track.artwork_path.clone(), - hires: track.bit_depth.map(|d| d > 16).unwrap_or(false), - bit_depth: track.bit_depth, - sample_rate: sample_rate_khz, - is_local: true, - album_id: Some(track.album_key.clone()), - artist_id: None, - streamable: true, - source: Some("plex".to_string()), - parental_warning: false, - source_item_id_hint: None, - context_kind: None, - context_id: None, - } -} - -/// Map a `LocalTrack` to a `CoreQueueTrack`. -/// `is_local = true`, `source = "local"`, `sample_rate` is converted from Hz -/// to kHz to match the Qobuz convention used elsewhere in the queue display. -pub fn local_track_to_queue_track(track: &qbz_library::LocalTrack) -> CoreQueueTrack { - // Artwork: local tracks store a file path; expose it as a `file://` URL - // so the frontend's <img> can load it. Falls back to None when absent. - let artwork_url = track.artwork_path.as_ref().map(|p| { - if p.starts_with("file://") { - p.clone() - } else { - format!("file://{}", p) - } - }); - - // sample_rate in LocalTrack is stored in Hz (e.g. 44100.0 / 192000.0). - // CoreQueueTrack.sample_rate is in kHz (e.g. 44.1 / 192.0) matching the - // Qobuz API field `maximum_sampling_rate`. Divide by 1000. - let sample_rate_khz = track.sample_rate / 1000.0; - - CoreQueueTrack { - // Local track ids are i64; CoreQueueTrack.id is u64. - // Local ids start from 1 and are never negative in practice. - id: track.id as u64, - title: track.title.clone(), - version: None, - artist: track.artist.clone(), - album: track.album_group_title.clone(), - album_version: None, - duration_secs: track.duration_secs, - artwork_url, - hires: track.bit_depth.map(|d| d > 16).unwrap_or(false), - bit_depth: track.bit_depth, - sample_rate: Some(sample_rate_khz), - is_local: true, - album_id: Some(track.album_group_key.clone()), - artist_id: None, - streamable: true, - source: Some("local".to_string()), - parental_warning: false, - source_item_id_hint: None, - context_kind: None, - context_id: None, + isrc: None, + recording_mbid: None, } } @@ -595,6 +420,8 @@ mod tests { source_item_id_hint: None, // stamped by resolve_collection_tracks context_kind: None, context_id: None, + isrc: None, + recording_mbid: None, }) .collect()) } @@ -622,24 +449,6 @@ mod tests { } } - #[test] - fn resolve_local_item_playlist_is_unsupported() { - // The (Playlist, Local) hard error is a load-bearing contract (spec §5.4/§10); - // lock it so the later Slint enqueue slice cannot silently drop it. - let db = qbz_library::LibraryDatabase::open(std::path::Path::new(":memory:")).unwrap(); - let it = item(0, ItemType::Playlist, AlbumSource::Local, "whatever", 0); - let err = resolve_local_item(&db, &it).unwrap_err(); - assert_eq!(err, "local playlists not supported in this release"); - } - - #[test] - fn resolve_local_item_track_rejects_non_numeric_id() { - let db = qbz_library::LibraryDatabase::open(std::path::Path::new(":memory:")).unwrap(); - let it = item(0, ItemType::Track, AlbumSource::Local, "not-a-number", 0); - let err = resolve_local_item(&db, &it).unwrap_err(); - assert_eq!(err, "invalid local track id: not-a-number"); - } - #[tokio::test] async fn resolver_stamps_hint_and_flattens_in_order() { let items = vec![ @@ -653,7 +462,10 @@ mod tests { assert_eq!(tracks[0].source_item_id_hint.as_deref(), Some("a-1")); assert_eq!(tracks[2].source_item_id_hint.as_deref(), Some("a-1")); assert_eq!(tracks[3].source_item_id_hint.as_deref(), Some("t-99")); - assert_eq!(tracks[4].source_item_id_hint.as_deref(), Some("al-local-xyz")); + assert_eq!( + tracks[4].source_item_id_hint.as_deref(), + Some("al-local-xyz") + ); } #[tokio::test] @@ -664,8 +476,7 @@ mod tests { item(2, ItemType::Album, AlbumSource::Qobuz, "a-3", 3), ]; let tracks = - resolve_collection_tracks(items, CollectionPlayMode::AlbumShuffle, &MockResolver) - .await; + resolve_collection_tracks(items, CollectionPlayMode::AlbumShuffle, &MockResolver).await; assert_eq!(tracks.len(), 9); // Each album's tracks must be contiguous (no interleaving). let mut i = 0; @@ -678,9 +489,7 @@ mod tests { h ); seen.insert(h.clone()); - while i < tracks.len() - && tracks[i].source_item_id_hint.as_deref() == Some(&h) - { + while i < tracks.len() && tracks[i].source_item_id_hint.as_deref() == Some(&h) { i += 1; } } @@ -709,6 +518,8 @@ mod tests { source_item_id_hint: item.map(String::from), context_kind: None, context_id: None, + isrc: None, + recording_mbid: None, } } diff --git a/crates/qbz-mixtape/src/lib.rs b/crates/qbz-mixtape/src/lib.rs index b04a3c85d..37eabf68d 100644 --- a/crates/qbz-mixtape/src/lib.rs +++ b/crates/qbz-mixtape/src/lib.rs @@ -1,26 +1,23 @@ //! Mixtapes & Collections backend — schema, repository, shuffle/DJ-mix, and //! enqueue/resolution logic for QBZ. //! -//! Extracted verbatim from `src-tauri/src/mixtape/*` and re-typed against the -//! real shared crates (`qbz-models`, `qbz-qobuz`, `qbz-library`, `qbz-plex`), -//! so it runs headless with no Tauri state and no `#[tauri::command]` wrappers -//! (ADR-005: no legacy wrappers; ADR-006: frontend-agnostic core). +//! Runs headless over shared models and Qobuz APIs, while provider-specific +//! local/LAN resolution remains in `qbz-source` (ADR-006). //! //! - [`schema`] — SQLite migrations (`run_mixtape_migrations`). //! - [`repo`] — CRUD over `&Connection` / `&mut Connection`. //! - [`shuffle`] — pure DJ-mix sampler (`rand`, `strsim`). //! - [`enqueue`] — `ItemResolver` trait, `resolve_collection_tracks`, //! `shuffle_items`, `next_item_index` / `previous_item_index`, plus the -//! free resolver fns + `ProdItemResolver` typed against the shared crates. +//! Qobuz resolver fns + `ProdItemResolver`. pub mod enqueue; pub mod repo; pub mod schema; pub mod shuffle; -// Convenience re-exports for callers (the Slint app in a later slice). +// Convenience re-exports for application callers. pub use enqueue::{ - next_item_index, previous_item_index, resolve_collection_tracks, ItemResolver, - ProdItemResolver, + next_item_index, previous_item_index, resolve_collection_tracks, ItemResolver, ProdItemResolver, }; pub use schema::run_mixtape_migrations; diff --git a/crates/qbz-mixtape/src/repo.rs b/crates/qbz-mixtape/src/repo.rs index 6c01edbf2..c94160d1e 100644 --- a/crates/qbz-mixtape/src/repo.rs +++ b/crates/qbz-mixtape/src/repo.rs @@ -2,7 +2,7 @@ //! //! All functions take `&Connection` (or `&mut Connection` when a transaction //! is needed). No Tauri state, no async runtime — testable with in-memory -//! SQLite. The Slint command layer wraps these with the app's library handle. +//! SQLite. The application layer wraps these with its per-user library handle. use rusqlite::{params, Connection, OptionalExtension, Result}; use uuid::Uuid; @@ -343,6 +343,36 @@ pub fn item_exists( Ok(exists) } +/// Fill an artwork snapshot that was absent when an item was inserted. +/// Existing non-empty artwork is authoritative (including a user-selected +/// snapshot) and is never overwritten. Every duplicate of the same +/// source-owned item in the collection is repaired together. +pub fn backfill_item_artwork( + conn: &Connection, + collection_id: &str, + source: AlbumSource, + source_item_id: &str, + artwork_url: &str, +) -> Result<usize> { + if artwork_url.trim().is_empty() { + return Ok(0); + } + conn.execute( + "UPDATE mixtape_collection_items + SET artwork_url = ?1 + WHERE collection_id = ?2 + AND source = ?3 + AND source_item_id = ?4 + AND (artwork_url IS NULL OR TRIM(artwork_url) = '')", + params![ + artwork_url, + collection_id, + serialize_source(source), + source_item_id, + ], + ) +} + pub fn remove_item(conn: &Connection, collection_id: &str, position: i32) -> Result<()> { conn.execute( "DELETE FROM mixtape_collection_items @@ -653,6 +683,61 @@ mod tests { assert!(matches!(items[2].item_type, ItemType::Playlist)); } + #[test] + fn artwork_backfill_only_repairs_empty_snapshots() { + let conn = fresh_db(); + let c = create_collection( + &conn, + CollectionKind::Collection, + "remote", + None, + CollectionSourceType::Manual, + None, + ) + .unwrap(); + add_item( + &conn, + &c.id, + ItemType::Album, + AlbumSource::Local, + "jellyfin:album-1", + "Album", + None, + None, + None, + None, + ) + .unwrap(); + + assert_eq!( + backfill_item_artwork( + &conn, + &c.id, + AlbumSource::Local, + "jellyfin:album-1", + "jellyfin:album-1/tag", + ) + .unwrap(), + 1 + ); + assert_eq!( + backfill_item_artwork( + &conn, + &c.id, + AlbumSource::Local, + "jellyfin:album-1", + "jellyfin:new-tag", + ) + .unwrap(), + 0, + "a resolved refresh must not replace an existing snapshot" + ); + assert_eq!( + list_items(&conn, &c.id).unwrap()[0].artwork_url.as_deref(), + Some("jellyfin:album-1/tag") + ); + } + #[test] fn remove_item_compacts_positions() { let conn = fresh_db(); diff --git a/crates/qbz-mixtape/src/shuffle.rs b/crates/qbz-mixtape/src/shuffle.rs index b109bc415..7ff11757b 100644 --- a/crates/qbz-mixtape/src/shuffle.rs +++ b/crates/qbz-mixtape/src/shuffle.rs @@ -365,6 +365,8 @@ mod tests { source_item_id_hint: None, context_kind: None, context_id: None, + isrc: None, + recording_mbid: None, } } diff --git a/crates/qbz-models/Cargo.toml b/crates/qbz-models/Cargo.toml index 9d75c3e65..3d77fd10e 100644 --- a/crates/qbz-models/Cargo.toml +++ b/crates/qbz-models/Cargo.toml @@ -26,5 +26,5 @@ chrono = { workspace = true } url = "2" # Percent-encoding the Plex transcode `url=`/token params (plex_thumb_url), # matching the Tauri frontend's buildPlexArtworkUrl. Same crate used across -# qbz-slint / qbz-media-controls / qbz-integrations. +# qbz-qt / qbz-media-controls / qbz-integrations. urlencoding = "2" diff --git a/crates/qbz-models/src/events.rs b/crates/qbz-models/src/events.rs index 2ef906e71..ab03f5cbd 100644 --- a/crates/qbz-models/src/events.rs +++ b/crates/qbz-models/src/events.rs @@ -47,6 +47,17 @@ pub enum CoreEvent { /// Repeat mode changed RepeatModeChanged { mode: crate::playback::RepeatMode }, + // ============ Qobuz Connect Events ============ + /// Qobuz Connect session lifecycle changed (headless renderer) + QconnectSessionChanged { + /// "off" | "connecting" | "connected" | "retrying" | "exhausted" + state: String, + /// The advertised renderer name, when one is set + device_name: Option<String>, + /// True only while a session is established ("connected") + session_active: bool, + }, + // ============ Authentication Events ============ /// User logged in successfully LoggedIn { session: UserSession }, diff --git a/crates/qbz-models/src/fs_url.rs b/crates/qbz-models/src/fs_url.rs new file mode 100644 index 000000000..638a54123 --- /dev/null +++ b/crates/qbz-models/src/fs_url.rs @@ -0,0 +1,247 @@ +//! `file://` URLs and "is this an absolute local path" — ONE definition. +//! +//! Sites used to build `format!("file://{p}")` by hand and to test +//! `starts_with('/')` for "absolute path". Both are Unix-only: +//! `file://C:\Users\…` parses `C:` as a URL **host**, and `C:\…` does not +//! start with `/`. Windows lost every local cover, the folder tree and the +//! runtime-tinted icons — silently, because a URL that resolves to nothing +//! renders as an empty rectangle, not as an error. Everything routes through +//! here now. +//! +//! # Two ambiguities no string algorithm can resolve +//! +//! A backslash is a legal filename character on Unix, and so is a colon. So +//! `C:\cover.jpg` and `/C:/music` are each simultaneously a valid Windows path +//! and a valid (if pathological) Unix one. This module reads them as Windows, +//! which costs a Unix user only if their path's FIRST component is a single +//! letter followed by a colon. The alternative — deciding by host — would make +//! the tests pass on the machine that runs them and fail on the one that +//! ships, which is the failure mode this module exists to end. + +/// Absolute local path, by SHAPE (so the tests run on every host): +/// `/…` (POSIX), `X:\…` / `X:/…` (drive), `\\server\share…` (UNC). +/// +/// Shape, not `Path::is_absolute`: that answers for the HOST it runs on, so a +/// Linux CI run would call `C:\x` relative and the Windows-only regression +/// would pass every test on the machine that runs them. +pub fn is_local_abs_path(s: &str) -> bool { + s.starts_with('/') || is_drive_path(s) || s.starts_with("\\\\") +} + +/// `X:\…` or `X:/…`. A separator is REQUIRED: `C:cover.jpg` is drive-RELATIVE +/// on Windows and an ordinary relative filename on Unix, so it is neither an +/// absolute path nor something to rewrite. +fn is_drive_path(s: &str) -> bool { + let b = s.as_bytes(); + b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/') +} + +/// Undo Windows extended-length ("verbatim") syntax. +/// +/// `std::fs::canonicalize` RETURNS this form on Windows — documented, not +/// incidental — and `qbz-library/src/scan.rs` stores exactly that string for +/// every scanned file. Left alone, `\\?\C:\Music\cover.jpg` reads as a UNC +/// path whose server is `?`, and the URL comes out `file://%3F/C:/Music/…`: +/// the whole Local Library would have lost its covers on Windows. +fn undo_verbatim(path: &str) -> String { + match path.strip_prefix("\\\\?\\") { + // `\\?\UNC\server\share` is the verbatim spelling of `\\server\share`. + Some(rest) => match rest.strip_prefix("UNC\\") { + Some(unc) => format!("\\\\{unc}"), + None => rest.to_string(), + }, + None => path.to_string(), + } +} + +/// `file://` URL for a raw filesystem path. Idempotent for inputs that already +/// carry the scheme. +/// +/// Qt's `QUrl` parses `#` as a fragment and `?` as a query, so a cover under +/// `…/Album #1/cover.jpg` resolves to nothing when concatenated raw. +/// Percent-encode exactly those two plus `%` itself (first, or the escapes we +/// add would be double-decoded). Spaces are left alone: `QUrl` accepts them. +/// +/// Shapes: +/// * `/home/v/x` → `file:///home/v/x` +/// * `C:\Users\v\x` → `file:///C:/Users/v/x` (empty authority; what `QUrl` and +/// WinRT both accept) +/// * `\\nas\music\x` → `file://nas/music/x` (the server IS the authority, and +/// `QUrl::toLocalFile` restores `//nas/music/x` from exactly this) +/// * `rel/x` → `file://rel/x`, byte-identical to the hand-rolled code this +/// replaced. A relative path was never a valid `file://` URL; it is left +/// alone rather than "improved", because Linux behaviour does not move in a +/// Windows port. +pub fn file_url(path: &str) -> String { + if path.starts_with("file://") { + return path.to_string(); + } + let path = undo_verbatim(path); + + // Backslash-to-slash ONLY for a Windows-shaped input. A backslash is a + // legal filename character on Unix, so rewriting it unconditionally would + // point `/home/v/AC\DC/cover.jpg` at a directory that does not exist. + let win_shaped = is_drive_path(&path) || path.starts_with("\\\\"); + let normalised = if win_shaped { + path.replace('\\', "/") + } else { + path + }; + + let mut out = String::with_capacity(normalised.len() + 8); + out.push_str("file://"); + let body: &str = if let Some(unc) = normalised.strip_prefix("//") { + // UNC: the server becomes the authority. + unc + } else if win_shaped { + // A drive path needs the third slash for the empty authority. + out.push('/'); + &normalised + } else { + // POSIX absolute already carries its own leading slash; a relative + // path keeps the shape the old code produced. + &normalised + }; + for ch in body.chars() { + match ch { + '%' => out.push_str("%25"), + '#' => out.push_str("%23"), + '?' => out.push_str("%3F"), + _ => out.push(ch), + } + } + out +} + +/// The inverse of [`file_url`]: a `file://` URL back to a filesystem path. +/// +/// `trim_start_matches("file://")` is NOT the inverse — it leaves the three +/// percent-escapes in place, drops the `//` a UNC authority stands for, and on +/// Windows leaves a `/` before the drive letter. Each of those names a file +/// that does not exist, and the caller (a tint decode, a copy, an +/// `Image.source`) then fails silently. +pub fn local_path(url: &str) -> String { + let Some(rest) = url.strip_prefix("file://") else { + return url.to_string(); + }; + let raw = rest + .replace("%23", "#") + .replace("%3F", "?") + .replace("%25", "%"); + let b = raw.as_bytes(); + // `file:///C:/x` → rest `/C:/x` → `C:/x`. + if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { + return raw[1..].to_string(); + } + // `file:///home/x` → rest `/home/x`: an empty authority, already a path. + if raw.starts_with('/') || raw.is_empty() { + return raw; + } + // A non-empty authority is a UNC server: `file://nas/music/x` names + // `//nas/music/x`, which is what `QUrl::toLocalFile` returns too. + format!("//{raw}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn posix_paths_round_trip_and_escape() { + assert_eq!( + file_url("/home/v/Album #1/cover?.jpg"), + "file:///home/v/Album %231/cover%3F.jpg" + ); + assert_eq!( + local_path("file:///home/v/Album%20%231/c%25.jpg"), + "/home/v/Album%20#1/c%.jpg" + ); + assert_eq!(file_url("file:///already"), "file:///already"); + } + + #[test] + fn drive_paths_get_the_empty_authority_form() { + assert_eq!( + file_url("C:\\Users\\v\\cover.jpg"), + "file:///C:/Users/v/cover.jpg" + ); + assert_eq!( + file_url("C:/Users/v/cover.jpg"), + "file:///C:/Users/v/cover.jpg" + ); + assert_eq!( + local_path("file:///C:/Users/v/cover.jpg"), + "C:/Users/v/cover.jpg" + ); + } + + #[test] + fn drive_paths_round_trip_through_both_directions() { + // The regression that started this: a tinted icon directory. + let p = "C:\\Users\\blitz\\AppData\\Local\\qbz\\icon-tints\\4285f4"; + let u = file_url(p); + assert_eq!( + u, + "file:///C:/Users/blitz/AppData/Local/qbz/icon-tints/4285f4" + ); + assert_eq!( + local_path(&u), + "C:/Users/blitz/AppData/Local/qbz/icon-tints/4285f4" + ); + assert_eq!(file_url(&u), u, "file_url must be idempotent"); + } + + /// `std::fs::canonicalize` returns this shape on Windows and + /// `qbz-library/src/scan.rs` stores it for every scanned file. Read as UNC + /// it emits `file://%3F/C:/…` and the Local Library shows no covers at all. + #[test] + fn verbatim_extended_length_paths_are_undone() { + assert_eq!( + file_url("\\\\?\\C:\\Music\\Album\\cover.jpg"), + "file:///C:/Music/Album/cover.jpg" + ); + assert_eq!( + file_url("\\\\?\\UNC\\nas\\music\\cover.jpg"), + "file://nas/music/cover.jpg" + ); + } + + #[test] + fn unc_paths_put_the_server_in_the_authority_and_come_back() { + let u = file_url("\\\\nas\\music\\cover.jpg"); + assert_eq!(u, "file://nas/music/cover.jpg"); + // Not `nas/music/cover.jpg`: that names nothing. Same as + // QUrl::toLocalFile. + assert_eq!(local_path(&u), "//nas/music/cover.jpg"); + } + + #[test] + fn a_backslash_in_a_unix_filename_is_not_a_separator() { + // Legal on Unix; rewriting it would name a path that does not exist. + assert_eq!( + file_url("/home/v/AC\\DC/cover.jpg"), + "file:///home/v/AC\\DC/cover.jpg" + ); + } + + /// A relative path is not a valid `file://` URL in any spelling. What + /// matters is that Linux gets the SAME bytes the hand-rolled code gave it. + #[test] + fn relative_paths_keep_the_shape_the_old_code_produced() { + assert_eq!(file_url("covers/x.jpg"), "file://covers/x.jpg"); + // Drive-RELATIVE is relative too, on both platforms. + assert_eq!(file_url("C:cover.jpg"), "file://C:cover.jpg"); + } + + #[test] + fn absolute_path_shape_is_host_independent() { + assert!(is_local_abs_path("/x")); + assert!(is_local_abs_path("C:\\x")); + assert!(is_local_abs_path("c:/x")); + assert!(is_local_abs_path("\\\\nas\\x")); + assert!(!is_local_abs_path("x/y")); + assert!(!is_local_abs_path("https://x")); + assert!(!is_local_abs_path("Album|Artist")); + assert!(!is_local_abs_path("C:x")); // drive-relative is not absolute + } +} diff --git a/crates/qbz-models/src/lib.rs b/crates/qbz-models/src/lib.rs index 61e9d13df..045cacbde 100644 --- a/crates/qbz-models/src/lib.rs +++ b/crates/qbz-models/src/lib.rs @@ -31,6 +31,7 @@ pub mod error; pub mod events; +pub mod fs_url; pub mod lenient; pub mod mixtape; pub mod playback; @@ -54,6 +55,7 @@ pub use types::{ RadioResponse, ArtistAlbums, ArtistBiography, + Goody, // Discover types DiscoverAlbum, DiscoverAlbumDates, @@ -137,6 +139,7 @@ pub use types::{ AssetOrigin, AudioParams, probe_streaminfo, + qobuz_cover_at_px, ExternalStreamAsset, StreamQualityInfo, StreamRestriction, @@ -145,5 +148,6 @@ pub use types::{ TrackFileUrl, TrackToAnalyse, TracksContainer, + Entitlements, UserSession, }; diff --git a/crates/qbz-models/src/playback.rs b/crates/qbz-models/src/playback.rs index bf85693ce..91684ac65 100644 --- a/crates/qbz-models/src/playback.rs +++ b/crates/qbz-models/src/playback.rs @@ -69,6 +69,19 @@ pub struct QueueTrack { pub context_kind: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub context_id: Option<String>, + /// Cross-source identity keys, when the source knows them: the ISRC (the + /// one key Qobuz always has) and the MusicBrainz recording id (Picard- + /// tagged local files, media servers that expose provider ids). Purely + /// additive — `None` everywhere that has not been taught them — and + /// stamped into the listen log, which is the only consumer today. + /// + /// NOT an identity on their own: numeric `id`s collide across sources, + /// so `(source, native item id)` stays the row identity and these are + /// the JOIN keys between sources. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub isrc: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recording_mbid: Option<String>, } fn default_streamable() -> bool { @@ -100,6 +113,10 @@ pub struct QueueState { pub repeat: RepeatMode, pub total_tracks: usize, pub stop_after_track_id: Option<u64>, + /// Manual-block size (#442): how many entries right after `current_index` + /// were hand-queued ("Play next" / "Play later") and play before the + /// source resumes. + pub manual_next_count: usize, } // ============ Playback State ============ diff --git a/crates/qbz-models/src/purchase_serde.rs b/crates/qbz-models/src/purchase_serde.rs index 310290384..624edd1f4 100644 --- a/crates/qbz-models/src/purchase_serde.rs +++ b/crates/qbz-models/src/purchase_serde.rs @@ -281,23 +281,39 @@ mod tests { } #[test] - fn purchase_track_has_no_version_field() { - // Compile-time + behavioral guard: an incoming `version` key must be - // silently ignored (PurchaseTrack does not declare it). A faithful port - // never renders a subtitle/version on purchased tracks. + fn purchase_track_captures_version_when_present() { + // INVERTED 2026-08-16 (contract §10-C). This test used to be named + // `purchase_track_has_no_version_field` and asserted that an incoming + // `version` key was silently ignored — except it never asserted anything + // about `version` at all, so it passed either way while documenting the + // opposite of the behaviour we now want. A guard that guards nothing and + // misleads the next reader is worse than no guard. + // + // `version` is now captured. The purchases ENDPOINTS do not send it, but + // the album-detail screen builds its tracks from the catalog album, which + // does, and the reference renders `formatTrackTitle(title, version)`. const TRACK: &str = r#"{ "id": 1, - "title": "No Version", + "title": "Song", "version": "Remastered 2024" }"#; let track: PurchaseTrack = - serde_json::from_str(TRACK).expect("track with stray version must deserialize"); + serde_json::from_str(TRACK).expect("track with a version must deserialize"); assert_eq!(track.id, 1); - assert_eq!(track.title, "No Version"); + assert_eq!(track.title, "Song"); + assert_eq!(track.version.as_deref(), Some("Remastered 2024")); // `streamable` defaults TRUE even on the bare track shape. assert!(track.streamable); } + #[test] + fn purchase_track_without_a_version_is_none() { + // The purchases list shape, which genuinely carries no `version`. + let track: PurchaseTrack = + serde_json::from_str(r#"{"id": 2, "title": "Plain"}"#).expect("bare track"); + assert_eq!(track.version, None); + } + #[test] fn radio_response_tolerates_missing_total() { use crate::types::RadioResponse; diff --git a/crates/qbz-models/src/source.rs b/crates/qbz-models/src/source.rs index 3aa1e6bb0..7da75daf0 100644 --- a/crates/qbz-models/src/source.rs +++ b/crates/qbz-models/src/source.rs @@ -228,8 +228,17 @@ impl QueueTrack { } if raw.starts_with("http://") || raw.starts_with("https://") { ArtworkRef::Remote(raw.to_string()) - } else if let Some(path) = raw.strip_prefix("file://") { - ArtworkRef::LocalFile(path.to_string()) + } else if raw.starts_with("file://") { + // Queue builders hand this layer a URI, not a filesystem path. + // Decode it before `to_mpris_url` canonicalises it again; merely + // stripping the scheme turns `%23` into a literal filename and + // the second encode publishes `%2523` to MPRIS. + let path = url::Url::parse(raw) + .ok() + .and_then(|url| url.to_file_path().ok()) + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| raw.trim_start_matches("file://").to_string()); + ArtworkRef::LocalFile(path) } else { ArtworkRef::LocalFile(raw.to_string()) } @@ -347,6 +356,8 @@ mod tests { source_item_id_hint: None, context_kind: None, context_id: None, + isrc: None, + recording_mbid: None, } } @@ -373,6 +384,20 @@ mod tests { ); } + #[test] + fn local_file_uri_round_trips_special_path_chars_for_mpris() { + let path = "/mnt/music/Disc 01 - Soundtrack #01/100%? cover.jpg"; + let queue_uri = "file:///mnt/music/Disc 01 - Soundtrack %2301/100%25%3F cover.jpg"; + let artwork = track_with(Some("local"), Some(queue_uri)).artwork_ref(); + assert_eq!(artwork, ArtworkRef::LocalFile(path.into())); + assert_eq!( + artwork.to_mpris_url(), + url::Url::from_file_path(path) + .ok() + .map(|url| url.to_string()) + ); + } + #[test] fn artwork_ref_with_plex_resolves_library_path() { // Plex thumb path + creds → PlexThumb (the now-playing/queue/MPRIS path). diff --git a/crates/qbz-models/src/types.rs b/crates/qbz-models/src/types.rs index e5c35ab46..0b4dce851 100644 --- a/crates/qbz-models/src/types.rs +++ b/crates/qbz-models/src/types.rs @@ -117,8 +117,40 @@ pub enum QualityLimit { // ============ User Session ============ +/// What the account is entitled to, straight from +/// `user.credential.parameters` of the `/user/login` payload (wire names, +/// verified against Qobuz's own embedded fixture — see +/// `qbz-nix-docs/offline-mode/tauri-review-2026-06-09/10-subscription-trial-offline-gating.md` +/// §1.2). An account without an active subscription ("Qobuz member") has +/// no `parameters` at all and therefore every flag `false`; Qobuz still +/// serves it favorites, playlists, purchases and 30-second previews. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Entitlements { + #[serde(default)] + pub lossy_streaming: bool, + #[serde(default)] + pub lossless_streaming: bool, + #[serde(default)] + pub hires_streaming: bool, + #[serde(default)] + pub hires_purchases_streaming: bool, + #[serde(default)] + pub offline_streaming: bool, + #[serde(default)] + pub mobile_streaming: bool, +} + +impl Entitlements { + /// No streaming entitlement of any kind: a Qobuz member without a + /// subscription. Purchases and previews still work; full-length + /// catalog playback and offline do not. + pub fn is_member_only(&self) -> bool { + !(self.lossy_streaming || self.lossless_streaming || self.hires_streaming) + } +} + /// User credentials and session info -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UserSession { pub user_auth_token: String, pub user_id: u64, @@ -127,6 +159,20 @@ pub struct UserSession { pub subscription_label: String, #[serde(default)] pub subscription_valid_until: Option<String>, + /// Entitlement flags from `credential.parameters`. `serde(default)` + /// keeps persisted pre-member-mode sessions loadable (all `false`, + /// which the next real login corrects). + #[serde(default)] + pub entitlements: Entitlements, + /// `user.subscription.end_date` (`YYYY-MM-DD`), when Qobuz sends the + /// `subscription` object at all. Informational: the entitlement flags + /// are the server's verdict, not this date (the embedded fixture has a + /// past `end_date` with every flag still `true`). + #[serde(default)] + pub subscription_end_date: Option<String>, + /// `user.subscription.offer` (e.g. "studio", "sublime"), when present. + #[serde(default)] + pub subscription_offer: Option<String>, /// Account territory (ISO 3166-1 alpha-2, e.g. "FR") from the login /// response. `serde(default)` keeps pre-v10 persisted sessions loadable. #[serde(default)] @@ -149,6 +195,12 @@ pub struct StreamUrl { pub bit_depth: Option<u32>, pub track_id: u64, pub restrictions: Vec<StreamRestriction>, + /// `true` when Qobuz served a 30-second preview instead of the track + /// (an account without the streaming entitlement, or a track that is + /// not streamable in the territory). The URL still plays; the player + /// must say so and must not count it as a listen. + #[serde(default)] + pub sample: bool, } impl StreamUrl { @@ -232,9 +284,8 @@ pub struct AudioParams { /// path can measure the bytes it actually serves (#638 fix 1). pub fn probe_streaminfo(bytes: &[u8]) -> Option<AudioParams> { if bytes.len() >= 26 && bytes.starts_with(b"fLaC") { - let sample_rate = ((bytes[18] as u32) << 12) - | ((bytes[19] as u32) << 4) - | ((bytes[20] as u32) >> 4); + let sample_rate = + ((bytes[18] as u32) << 12) | ((bytes[19] as u32) << 4) | ((bytes[20] as u32) >> 4); let channels = ((bytes[20] >> 1) & 0x07) + 1; let bit_depth = ((bytes[20] & 0x01) << 4) | ((bytes[21] >> 4) & 0x0F); Some(AudioParams { @@ -329,6 +380,16 @@ pub struct TrackFileUrl { // ============ Image Types ============ +/// Pixel size each `ImageSet` variant serves, ascending: small, thumbnail, +/// large, extralarge, mega. This is the Qobuz CDN CONVENTION — the API does +/// not state variant dimensions in-repo (contract +/// `2026-08-15-immersive-completion` 00 §8: verify against live responses +/// before retuning). THE table lives here and only here: `ImageSet::for_px` +/// and the size bucketing (`ImageSet::bucket_for_px`, used by the Qt +/// frontend's responsive-art request path) both read it, so tuning either +/// side is ONE edit. +pub const IMAGE_VARIANT_PX: [u32; 5] = [50, 150, 300, 600, 3000]; + /// Image set with multiple resolutions #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ImageSet { @@ -350,6 +411,46 @@ impl ImageSet { .or(self.small.as_ref()) } + /// The smallest variant whose pixel size (per [`IMAGE_VARIANT_PX`]) + /// covers `px` — the size-aware pick for a surface that knows its slot + /// size, so a 72px row never fetches a ~3000px mega and a big slot never + /// settles for a 150px thumbnail. A missing variant keeps the scan + /// moving UP (a bigger variant still covers the slot); when nothing is + /// big enough (or the set is partial — Discover returns only + /// small/thumbnail/large) the fallbacks are `best()` and then + /// `smallest()` order, so this never returns WORSE than what a bare + /// `best()` call would have served. + pub fn for_px(&self, px: u32) -> Option<&String> { + let variants = [ + (&self.small, IMAGE_VARIANT_PX[0]), + (&self.thumbnail, IMAGE_VARIANT_PX[1]), + (&self.large, IMAGE_VARIANT_PX[2]), + (&self.extralarge, IMAGE_VARIANT_PX[3]), + (&self.mega, IMAGE_VARIANT_PX[4]), + ]; + for (slot, slot_px) in variants { + if slot_px >= px { + if let Some(url) = slot.as_ref() { + return Some(url); + } + } + } + self.best().or_else(|| self.smallest()) + } + + /// The size bucket a slot of `px` pixels resolves to: the smallest + /// [`IMAGE_VARIANT_PX`] entry that covers it, or the mega entry when none + /// does. Callers that re-resolve art on window resize compare BUCKETS, not + /// pixels, so a resize drag costs at most one re-request per tier crossed + /// instead of one per pixel. + pub fn bucket_for_px(px: u32) -> u32 { + IMAGE_VARIANT_PX + .iter() + .copied() + .find(|&entry| entry >= px) + .unwrap_or(IMAGE_VARIANT_PX[IMAGE_VARIANT_PX.len() - 1]) + } + /// The smallest available variant — for list-row thumbnails, where /// `best()` (mega/large) would needlessly download huge images. pub fn smallest(&self) -> Option<&String> { @@ -362,6 +463,51 @@ impl ImageSet { } } +/// Rewrite a sized Qobuz CDN cover url to the variant tier covering `px`. +/// +/// Qobuz covers carry the variant's pixel size as the `_<px>.<ext>` suffix +/// (`https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb_50.jpg`), +/// with the sizes of [`IMAGE_VARIANT_PX`] — EXCEPT the mega tier, whose +/// suffix is the literal `_max` (the size it serves is the album master's, +/// 1400px and up). The queue, `recently_played.json` +/// and the persisted session store WHATEVER variant the building surface +/// picked, so a restored session can carry the 50px `small` into the +/// now-playing feed, MPRIS and Discord — measured 2026-08-15 on the owner's +/// cache: 50x50 JPEGs downloaded by the Qt now-playing art feed, pixelated +/// art on every big surface. +/// +/// The rewrite targets the smallest table entry covering `px` +/// ([`ImageSet::bucket_for_px`]), so any surface holding ANY sized Qobuz +/// cover can serve itself the tier it needs without the original +/// `ImageSet`. Returns `None` — leaving the url to the caller unchanged — +/// for non-Qobuz urls, unsized covers, and suffixes the table does not know +/// (a stray `_<digits>` in an id is never mistaken for a size). +pub fn qobuz_cover_at_px(url: &str, px: u32) -> Option<String> { + if !url.contains("/images/covers/") { + return None; + } + let (stem, ext) = url.rsplit_once('.')?; + if ext.len() > 4 || !ext.chars().all(|c| c.is_ascii_alphanumeric()) { + return None; + } + let (head, size) = stem.rsplit_once('_')?; + // The mega tier's suffix is the LITERAL `_max`, not `_3000`: measured + // against the live CDN 2026-08-15, `_3000.jpg` is a 404 on albums whose + // mega exists (`_max.jpg` serves it, at whatever size that album's + // master is — 1400px and up), and the API advertises mega urls with the + // `_max` suffix. `3000` stays the tier's pixel CONVENTION in + // [`IMAGE_VARIANT_PX`]; only the wire suffix is `max`. + if size != "max" && !IMAGE_VARIANT_PX.contains(&size.parse::<u32>().ok()?) { + return None; + } + let bucket = ImageSet::bucket_for_px(px); + if bucket == IMAGE_VARIANT_PX[IMAGE_VARIANT_PX.len() - 1] { + Some(format!("{head}_max.{ext}")) + } else { + Some(format!("{head}_{bucket}.{ext}")) + } +} + // ============ Core Media Types ============ /// Track model @@ -382,6 +528,10 @@ pub struct Track { /// headers on the album view, mirroring the official Qobuz player (PR #536). pub work: Option<String>, pub isrc: Option<String>, + /// `audio_info` on the wire: replaygain gain/peak (always present in the + /// captured samples). Absent on older cached payloads → `None`. + #[serde(default)] + pub audio_info: Option<DiscoverAudioInfo>, #[serde(default)] pub duration: u32, #[serde(default)] @@ -395,8 +545,43 @@ pub struct Track { pub hires_streamable: bool, pub maximum_sampling_rate: Option<f64>, pub maximum_bit_depth: Option<u32>, + /// Whether Qobuz will stream this track today. + /// + /// `None` means the endpoint did not report availability — it is **NOT** a + /// claim of unavailability. Only `Some(false)` marks a track as pulled from + /// the catalogue. The distinction is load-bearing: this field used to be a + /// plain `bool` with a bare `#[serde(default)]`, and that collapses an + /// ABSENT key into `false`, so every track returned by an endpoint that + /// omits it came back marked unavailable. That was harmless while nothing + /// rendered the flag; it stops being harmless the moment the flag greys a + /// row out and keeps it out of the queue, where a single terse endpoint + /// would silently take out an entire view. + /// + /// `/album/get` does send the key (captured 2026-08-17, album + /// `0886443985094`, where track 1 is `false` while the ALBUM around it is + /// `true` — the album-level flag can never stand in for this one). + /// `/playlist/get?extra=tracks` has never been captured, and playlists are + /// where dead tracks show up most; that unverified endpoint is exactly what + /// this `Option` guards against. + /// + /// Never read the field directly — go through [`Track::is_streamable`], + /// which is the one place absence is interpreted. + /// + /// Mirrors [`Album::streamable`], which has been `Option<bool>` all along + /// for the same reasons; the two shapes are now consistent. #[serde(default)] - pub streamable: bool, + pub streamable: Option<bool>, + /// Unix seconds at which streaming rights START for this track + /// (`/album/get` sends it on every item: a PAST value on live tracks, + /// `null` on a pulled one, a FUTURE value on a pre-release). Read through + /// [`Track::is_upcoming`], which is what tells "not yet" from "no longer". + #[serde(default)] + pub streamable_at: Option<i64>, + /// Per-track streaming release date (ISO `YYYY-MM-DD`), the same shape as + /// [`Album::release_date_stream`]. Fallback for [`Track::is_upcoming`] + /// when `streamable_at` is absent. + #[serde(default)] + pub release_date_stream: Option<String>, #[serde(default)] pub parental_warning: bool, /// Playlist-specific: ID within the playlist (for removal) @@ -409,6 +594,71 @@ pub struct Track { pub copyright: Option<String>, } +impl Track { + /// The ONE place an absent `streamable` is interpreted: **absent means + /// available**. + /// + /// The asymmetry is deliberate, and it is the whole reason the field is an + /// `Option` rather than a `bool` with a convenient default. + /// + /// Refusing to play a track because an endpoint was terse is strictly worse + /// than offering one that turns out to be dead. The second case is already + /// caught downstream by the reactive path — the play fails, the error is + /// recognised as terminal (`is_terminal_unavailable`), and the bounded + /// auto-skip moves on — so it costs one failed round trip and one toast, + /// and the user hears the next song. The first case has no recovery at all: + /// the user is shown a greyed, unplayable catalogue, no request is ever + /// made, nothing fails loudly, and there is no signal anywhere that would + /// tell them (or us) that the data was merely missing rather than the music + /// gone. On a music player, silent refusal is the unrecoverable failure and + /// a wasted request is the cheap one. + /// + /// Call this instead of touching `streamable` directly. A hand-written + /// `unwrap_or` at a call site is how the two halves of this rule drift + /// apart, and the drift is invisible until a whole view goes grey. + pub fn is_streamable(&self) -> bool { + self.streamable.unwrap_or(true) + } + + /// Whether this track is a PRE-RELEASE: not streamable today because its + /// streaming rights have not started yet, as opposed to a track Qobuz + /// pulled. Only meaningful alongside `!is_streamable()`; a streamable + /// track is never "upcoming". See [`upcoming_from`] for the rule. + pub fn is_upcoming(&self) -> bool { + !self.is_streamable() + && upcoming_from( + self.streamable_at, + self.release_date_stream.as_deref(), + chrono::Utc::now(), + ) + } +} + +/// The ONE rule that tells "not yet available" from "no longer available": +/// a FUTURE `streamable_at` (Unix seconds) wins; without it, a FUTURE +/// `release_date_stream` (`YYYY-MM-DD`, compared by calendar day in UTC). +/// Anything absent, malformed, or in the past reads as NOT upcoming, so a +/// terse endpoint keeps the existing "no longer available" wording rather +/// than promising a release that never comes. +pub fn upcoming_now(streamable_at: Option<i64>, release_date_stream: Option<&str>) -> bool { + upcoming_from(streamable_at, release_date_stream, chrono::Utc::now()) +} + +/// [`upcoming_now`] against an explicit clock (tests). +pub fn upcoming_from( + streamable_at: Option<i64>, + release_date_stream: Option<&str>, + now: chrono::DateTime<chrono::Utc>, +) -> bool { + if let Some(at) = streamable_at { + return at > now.timestamp(); + } + release_date_stream + .and_then(|s| chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()) + .map(|date| date > now.date_naive()) + .unwrap_or(false) +} + /// Album summary (embedded in track responses) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AlbumSummary { @@ -422,6 +672,47 @@ pub struct AlbumSummary { pub label: Option<Label>, /// Genre (when returned, e.g. on favorites track album objects). pub genre: Option<Genre>, + /// The album's MAIN artist, when the track's embedded album carries it + /// (search results and favourites do — captured + /// `qobuz-api/search-results-response.json`). Distinct from the track's + /// `performer`, which is who is credited on THAT track; the artist page's + /// "In library" tab admits a track through either. + #[serde(default)] + pub artist: Option<Artist>, + /// Release/edition suffix on embedded album rows (for example + /// "Remastered 2021"). Search and favourites payloads carry it even + /// though older endpoints omit it. + #[serde(default)] + pub version: Option<String>, + /// Availability of the release that owns this track. As with the full + /// [`Album`] model, absence means unknown — never unavailable. + #[serde(default)] + pub streamable: Option<bool>, + /// Unix timestamp at which this release becomes streamable, when supplied + /// by the embedding endpoint. + #[serde(default)] + pub streamable_at: Option<i64>, + /// ISO streaming release date, used as the fallback upcoming signal. + #[serde(default)] + pub release_date_stream: Option<String>, +} + +impl AlbumSummary { + /// `None` is a terse endpoint, not a withdrawal. + pub fn is_streamable(&self) -> bool { + self.streamable.unwrap_or(true) + } + + /// Distinguish a future release from a withdrawn one using the same rule + /// as [`Track::is_upcoming`]. + pub fn is_upcoming(&self) -> bool { + !self.is_streamable() + && upcoming_from( + self.streamable_at, + self.release_date_stream.as_deref(), + chrono::Utc::now(), + ) + } } /// Album model @@ -477,8 +768,18 @@ pub struct Album { pub upc: Option<String>, /// Editorial description/review of the album pub description: Option<String>, - /// Album goodies (booklets, liner notes PDFs) - #[serde(default)] + /// Album goodies (booklets, liner notes PDFs, videos). + /// + /// LENIENT on purpose. `/album/get` is parsed with the strict structs — its + /// `Option<T>` fields carry no lenient wrapper, so ONE wrong-typed value + /// fails the entire album parse and the album simply will not open. That + /// risk is concentrated exactly here: on albums nobody owns, `goodies` comes + /// back an empty array, so its populated item shape has never been observed. + /// The vendor's own downloader gates goodie fetching behind purchases, which + /// is the hypothesis for why. We cannot capture a populated one without + /// owning a purchase, so the shape stays UNVERIFIED — and an unverified + /// shape must degrade to `None`, never take the album down with it. + #[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")] pub goodies: Option<Vec<Goody>>, /// Editorial awards (Qobuzissime, Album of the Week, press accolades). #[serde(default)] @@ -505,6 +806,15 @@ pub struct Album { pub composer: Option<Artist>, } +impl Album { + /// Interpret the catalog's optional availability bit without turning an + /// omitted field into a withdrawal. Only an explicit `false` means the + /// album is unavailable. + pub fn is_streamable(&self) -> bool { + self.streamable.unwrap_or(true) + } +} + /// Album artist contributor entry (main artist + featured artists). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AlbumArtist { @@ -514,7 +824,14 @@ pub struct AlbumArtist { pub roles: Option<Vec<String>>, } -/// A downloadable extra bundled with an album (e.g. PDF booklet) +/// A downloadable extra bundled with an album (e.g. PDF booklet). +/// +/// Every field defaults, so a missing key never fails an item, and the whole +/// list is behind `lenient_option` on `Album::goodies`, so a surprising list or +/// item shape degrades to "no goodies" rather than to "the album will not open". +/// Read `url`/`name` through [`Goody::best_url`] / [`Goody::display_name`] rather +/// than directly — the populated shape has never been observed and those helpers +/// are where the fallbacks live. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Goody { #[serde(default)] @@ -526,6 +843,11 @@ pub struct Goody { /// Original (full-size) URL #[serde(default)] pub original_url: String, + /// A third spelling seen in vendor payloads; kept as a fallback source for + /// [`Goody::best_url`] because we cannot confirm which key a real, + /// purchase-gated goodie uses. + #[serde(default)] + pub file_url: Option<String>, /// File format id (e.g. 21 for PDF) #[serde(default)] pub file_format_id: Option<u32>, @@ -533,6 +855,39 @@ pub struct Goody { pub description: Option<String>, } +impl Goody { + /// The URL to fetch, trying every spelling we know of. `original_url` wins + /// because it is documented as the full-size asset; `url` is the common + /// shape; `file_url` is the unconfirmed third. Returns `None` when the item + /// carries no usable URL at all, which is the signal to skip it rather than + /// to fail anything. + pub fn best_url(&self) -> Option<&str> { + [ + self.original_url.as_str(), + self.url.as_str(), + self.file_url.as_deref().unwrap_or(""), + ] + .into_iter() + .map(str::trim) + .find(|candidate| !candidate.is_empty()) + } + + /// A human label, falling back through the fields most likely to carry one + /// and finally to the id, so a goodie is never rendered nameless. + pub fn display_name(&self) -> String { + for candidate in [Some(self.name.as_str()), self.description.as_deref()] + .into_iter() + .flatten() + { + let trimmed = candidate.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + format!("Goody {}", self.id) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TracksContainer { pub items: Vec<Track>, @@ -604,8 +959,20 @@ pub struct Playlist { pub genres: Option<Vec<PlaylistGenre>>, pub images150: Option<Vec<String>>, pub images300: Option<Vec<String>>, + /// The playlist's OWN Qobuz artwork (editorial playlists). The `images*` + /// lists above are MEMBER-ALBUM covers, which is why a Qobuz playlist + /// card binding them shows an album sleeve instead of the playlist + /// graphic. Absent on user playlists, which fall back to the collage. + #[serde(default)] + pub image_rectangle: Option<Vec<String>>, + #[serde(default)] + pub image_rectangle_mini: Option<Vec<String>>, pub slug: Option<String>, pub users_count: Option<u32>, + /// API revision stamp (unix seconds). The membership index reads it as + /// change evidence when the API provides it; absent on some list shapes. + #[serde(default)] + pub updated_at: Option<i64>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -647,6 +1014,9 @@ pub struct PlaylistWithTrackIds { pub images300: Option<Vec<String>>, pub slug: Option<String>, pub users_count: Option<u32>, + /// API revision stamp (unix seconds), when the response carries one. + #[serde(default)] + pub updated_at: Option<i64>, } /// Result of checking for duplicate tracks in a playlist @@ -952,6 +1322,18 @@ pub struct PurchaseAlbum { pub maximum_bit_depth: Option<u32>, #[serde(default = "crate::purchase_serde::serde_true")] pub downloadable: bool, + /// THE ENTITLEMENT: the `format_id`s this purchase may be downloaded in. + /// First seen on a real account 2026-09-01 — `[55]` (DSD64), `[56]` + /// (DSD128), `[7, 6, 5]` (a hi-res FLAC purchase) — and it is the only + /// authority for the download menu: a DSD purchase reports `hires:false` + /// with a 16/44.1 streaming catalog, so nothing can be inferred from the + /// album's own quality fields. Empty on older captures. + #[serde(default)] + pub downloadable_format_ids: Vec<u32>, + /// Purchased AS hi-res, independent of what the catalog streams + /// (`hires:false, hires_purchased:true` on the DSD64 album). + #[serde(default)] + pub hires_purchased: bool, #[serde(default)] pub downloaded: bool, #[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")] @@ -960,17 +1342,37 @@ pub struct PurchaseAlbum { pub tracks: Option<SearchResultsPage<PurchaseTrack>>, } -/// A purchased track. NOTE: there is intentionally **no `version` field** — the -/// purchases path never carries the track subtitle/edition (see source-of-truth -/// §4.6). `streamable` defaults TRUE; `downloaded`/`downloaded_format_ids` are +/// A purchased track. +/// +/// It DOES carry a `version` (the track subtitle/edition). It deliberately did +/// not, on the reasoning that the purchases endpoints never send one — which is +/// true of the endpoints and beside the point for the screen: the album-detail +/// view builds its tracks from the CATALOG album (`/album/get`), which does send +/// `version`, and the reference's frontend renders +/// `formatTrackTitle(title, version)`. Dropping the field is what made every +/// purchased track lose its subtitle (issue #360); contract §10-C rules the +/// field back in and `build_purchase_album` maps it across. +/// +/// `streamable` defaults TRUE; `downloaded`/`downloaded_format_ids` are /// server-computed from the local registry; `media_number` is the disc number /// used for disc-grouping. `purchased_at` is unix epoch seconds. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PurchaseTrack { - #[serde(default, deserialize_with = "crate::purchase_serde::deserialize_u64_id")] + #[serde( + default, + deserialize_with = "crate::purchase_serde::deserialize_u64_id" + )] pub id: u64, #[serde(default)] pub title: String, + /// Track subtitle ("Remastered", "Live at …"). The purchases endpoints do + /// not send it, but the catalog `/album/get` track does, and the detail + /// screen renders `formatTrackTitle(title, version)` — so `build_purchase_album` + /// carries it across. Tauri declared the same field on its frontend type and + /// never mapped it, which is why purchased track titles lost their version + /// (issue #360); this field is what closes that. + #[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")] + pub version: Option<String>, #[serde(default)] pub track_number: u32, #[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")] @@ -993,6 +1395,11 @@ pub struct PurchaseTrack { pub downloaded: bool, #[serde(default)] pub downloaded_format_ids: Vec<u32>, + /// The per-track entitlement, same meaning as the album's. Not yet observed + /// on a real `type=tracks` page (every capture so far had zero standalone + /// track purchases); defaulted so an absent key costs nothing. + #[serde(default)] + pub downloadable_format_ids: Vec<u32>, #[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")] pub purchased_at: Option<i64>, } @@ -1007,6 +1414,14 @@ pub struct PurchaseFormatOption { pub label: String, pub bit_depth: Option<u32>, pub sampling_rate: Option<f64>, + /// `false` = the purchased file, fetched through the purchase grant + /// (`getFileUrl`, `intent=download`), which the store only issues for the + /// ids in the entitlement. `true` = a lower quality the store does NOT + /// sell for this purchase, fetched through the streaming grant + /// (`intent=stream`) instead — the route the Slint/Tauri builds used for + /// every download, kept only BELOW the purchased quality. + #[serde(default)] + pub streaming: bool, } /// Response from `/album/suggest` — albums similar to a seed album. @@ -1025,7 +1440,10 @@ pub struct RadioResponse { pub radio_type: Option<String>, #[serde(default)] pub title: Option<String>, - #[serde(default, deserialize_with = "crate::purchase_serde::lenient_page_flexible")] + #[serde( + default, + deserialize_with = "crate::purchase_serde::lenient_page_flexible" + )] pub tracks: SearchResultsPage<Track>, } @@ -1191,12 +1609,20 @@ pub struct DiscoverAlbumDates { pub stream: Option<String>, } -/// Audio info from discover album +/// Audio info from discover album (and, since 2026-08-28, from tracks) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoverAudioInfo { pub maximum_sampling_rate: Option<f64>, pub maximum_bit_depth: Option<u32>, pub maximum_channel_count: Option<u32>, + /// ReplayGain track gain (dB) and peak, as Qobuz ships them on every + /// track in search/album envelopes + /// (`qbz-nix-docs/qobuz-api/search-results-response.json`, 15 samples). + /// Modelled only — no reader yet; the loudness analyser stays as is. + #[serde(default)] + pub replaygain_track_gain: Option<f64>, + #[serde(default)] + pub replaygain_track_peak: Option<f64>, } // ============ Artist Page Types (/artist/page) ============ @@ -1525,4 +1951,472 @@ mod tests { serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap(); assert_eq!(back.language_code.as_deref(), Some("fr")); } + + fn image_set() -> ImageSet { + ImageSet { + small: Some("s50".into()), + thumbnail: Some("t150".into()), + large: Some("l300".into()), + extralarge: Some("x600".into()), + mega: Some("m3000".into()), + back: None, + } + } + + #[test] + fn for_px_picks_the_smallest_covering_variant() { + let set = image_set(); + assert_eq!(set.for_px(72).map(String::as_str), Some("t150")); + assert_eq!(set.for_px(150).map(String::as_str), Some("t150")); + assert_eq!(set.for_px(151).map(String::as_str), Some("l300")); + assert_eq!(set.for_px(300).map(String::as_str), Some("l300")); + assert_eq!(set.for_px(660).map(String::as_str), Some("m3000")); + // Past mega there is nothing bigger: best() is the ceiling. + assert_eq!(set.for_px(4000).map(String::as_str), Some("m3000")); + } + + #[test] + fn for_px_skips_missing_variants_upward_on_partial_sets() { + // Discover endpoints return only small/thumbnail/large. + let discover = ImageSet { + small: Some("s50".into()), + thumbnail: Some("t150".into()), + large: Some("l300".into()), + ..ImageSet::default() + }; + assert_eq!(discover.for_px(72).map(String::as_str), Some("t150")); + // Nothing >= 600: best-available (large), never an upscale promise. + assert_eq!(discover.for_px(600).map(String::as_str), Some("l300")); + // A gap below the request keeps scanning UP: {small, mega} at 200px + // is served by mega — the smallest variant that COVERS the slot (the + // "never upscale into a slot" rule wins over download size; full + // album/track sets populate all five variants, so this only bites on + // genuinely sparse sets). + let gapped = ImageSet { + small: Some("s50".into()), + mega: Some("m3000".into()), + ..ImageSet::default() + }; + assert_eq!(gapped.for_px(200).map(String::as_str), Some("m3000")); + assert_eq!(gapped.for_px(72).map(String::as_str), Some("m3000")); + } + + #[test] + fn for_px_never_serves_worse_than_best() { + let tiny = ImageSet { + small: Some("s50".into()), + ..ImageSet::default() + }; + assert_eq!(tiny.for_px(1600).map(String::as_str), Some("s50")); + assert_eq!(ImageSet::default().for_px(300), None); + } + + #[test] + fn bucket_for_px_maps_slots_onto_the_variant_table() { + assert_eq!(ImageSet::bucket_for_px(0), IMAGE_VARIANT_PX[0]); + assert_eq!(ImageSet::bucket_for_px(72), 150); + assert_eq!(ImageSet::bucket_for_px(150), 150); + assert_eq!(ImageSet::bucket_for_px(151), 300); + assert_eq!(ImageSet::bucket_for_px(660), 3000); + // Beyond the table the bucket saturates at mega, so a 4K window does + // not invent a size no variant serves. + assert_eq!(ImageSet::bucket_for_px(5000), 3000); + } + + #[test] + fn qobuz_cover_at_px_rewrites_the_size_suffix() { + let small = "https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb_50.jpg"; + assert_eq!( + qobuz_cover_at_px(small, 600).as_deref(), + Some("https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb_600.jpg") + ); + // Slots bucket UP to the smallest covering variant. + assert_eq!( + qobuz_cover_at_px(small, 250).as_deref(), + Some("https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb_300.jpg") + ); + assert_eq!( + qobuz_cover_at_px(small, 5000).as_deref(), + Some("https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb_max.jpg") + ); + // The mega bucket emits the literal `_max` suffix, never `_3000` + // (a 404 on the live CDN, measured 2026-08-15). + assert_eq!( + qobuz_cover_at_px(small, 700).as_deref(), + Some("https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb_max.jpg") + ); + // Down-tiering works too (a mega `_max` carried into a 150px slot). + let mega = "https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb_max.jpg"; + assert_eq!( + qobuz_cover_at_px(mega, 100).as_deref(), + Some("https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb_150.jpg") + ); + } + + #[test] + fn qobuz_cover_at_px_leaves_unrecognized_urls_alone() { + // Not a cover url, no size suffix, an unknown size, a query string — + // all None, so callers keep the original. + assert_eq!(qobuz_cover_at_px("https://example.com/a_50.jpg", 600), None); + assert_eq!( + qobuz_cover_at_px( + "https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb.jpg", + 600 + ), + None + ); + assert_eq!( + qobuz_cover_at_px( + "https://static.qobuz.com/images/covers/pb/ap/gxy13gb56appb_123.jpg", + 600 + ), + None + ); + assert_eq!( + qobuz_cover_at_px( + "https://static.qobuz.com/images/covers/pb/ap/x_50.jpg?token=1", + 600 + ), + None + ); + assert_eq!(qobuz_cover_at_px("/home/u/Music/cover.jpg", 600), None); + } +} + +// ─── §12-4: purchase deserializers, one payload per missing optional field ──── +// +// The whole point of this module is that the purchase path cannot be smoke- +// tested: Qobuz Purchases is not sold in the owner's region, so the only +// populated payloads anyone will ever see belong to end users. Every default +// below is therefore asserted rather than assumed, and the ones that DIFFER +// between two structs with the same field name get their own test, because that +// is the trap most likely to invert a screen's behaviour silently. +#[cfg(test)] +mod purchase_deserializer_tests { + use super::*; + + // ── §2.6: the `streamable` split ───────────────────────────────────────── + // + // Two structs, same field name, three semantics. `PurchaseTrack.streamable` + // is a `bool` defaulting TRUE (`serde_true`) — the purchases endpoints are + // terse and that screen gates click-to-play on the flag, so a default of + // false would make every purchased album unclickable. Catalog + // `Track.streamable` is now `Option<bool>`: it does not guess in EITHER + // direction, because the greyout and the queue filter key on it and a wrong + // guess is visible on every screen in the app. Absence is resolved in + // exactly one function, `Track::is_streamable`, and nowhere else. + + /// A purchases-list track defaults `streamable` to **true**. + #[test] + fn purchase_track_streamable_defaults_true() { + let t: PurchaseTrack = serde_json::from_str(r#"{"id":1,"title":"T"}"#).unwrap(); + assert!( + t.streamable, + "PurchaseTrack.streamable defaults TRUE (serde_true)" + ); + } + + /// A CATALOG track that does not mention `streamable` deserializes to + /// `None`, and `None` reads as **available**. + /// + /// This is the regression that would otherwise grey out the whole + /// catalogue. While the field was a `bool` with a bare `#[serde(default)]`, + /// an endpoint that omits the key produced `false` — "pulled from Qobuz" — + /// for every track it returned. `/album/get` does send the key (captured + /// 2026-08-17), but `/playlist/get?extra=tracks` never has been, and that is + /// the surface where dead tracks actually show up. + #[test] + fn catalog_track_streamable_absent_is_none_and_reads_available() { + let t: Track = serde_json::from_str(r#"{"id":1,"title":"T"}"#).unwrap(); + assert_eq!( + t.streamable, None, + "an absent key must stay absent — never collapse into Some(false)" + ); + assert!( + t.is_streamable(), + "absence is NOT a claim of unavailability: absent reads as available" + ); + } + + /// An explicit `"streamable": false` is honoured, and it is the ONLY shape + /// that marks a track as pulled. + /// + /// This is the real wire signal, captured from + /// `/album/get?album_id=0886443985094` on 2026-08-17: track 1 carries + /// `false` (and keeps its `isrc`, which is what makes the replacement + /// search's ISRC short-circuit reachable) while the album around it is + /// `true`. + #[test] + fn catalog_track_streamable_false_is_honoured() { + let t: Track = serde_json::from_str(r#"{"id":1,"title":"T","streamable":false}"#).unwrap(); + assert_eq!(t.streamable, Some(false)); + assert!( + !t.is_streamable(), + "Some(false) is the ONLY shape that marks a track unavailable" + ); + } + + #[test] + fn catalog_album_streamable_absent_reads_available() { + let album: Album = serde_json::from_str(r#"{"id":"a","title":"A"}"#).unwrap(); + assert_eq!(album.streamable, None); + assert!(album.is_streamable()); + } + + #[test] + fn catalog_album_streamable_false_is_honoured() { + let album: Album = + serde_json::from_str(r#"{"id":"a","title":"A","streamable":false}"#).unwrap(); + assert_eq!(album.streamable, Some(false)); + assert!(!album.is_streamable()); + } + + #[test] + fn embedded_album_availability_keeps_unknown_distinct_from_false() { + let absent: AlbumSummary = serde_json::from_str(r#"{"id":"a","title":"A"}"#).unwrap(); + assert_eq!(absent.streamable, None); + assert!(absent.is_streamable()); + + let withdrawn: AlbumSummary = serde_json::from_str( + r#"{"id":"a","title":"A","version":"Remastered","streamable":false}"#, + ) + .unwrap(); + assert_eq!(withdrawn.streamable, Some(false)); + assert!(!withdrawn.is_streamable()); + assert!(!withdrawn.is_upcoming()); + assert_eq!(withdrawn.version.as_deref(), Some("Remastered")); + } + + #[test] + fn embedded_future_album_is_upcoming_not_withdrawn() { + let upcoming: AlbumSummary = serde_json::from_str( + r#"{"id":"a","title":"A","streamable":false,"release_date_stream":"2999-01-01"}"#, + ) + .unwrap(); + assert!(upcoming.is_upcoming()); + } + + /// `downloadable` drives three list behaviours (the hide-unavailable filter, + /// the album click gate, the unavailable marker), so its default is not + /// decoration: getting it wrong ships clickable unavailable albums. + #[test] + fn purchase_album_downloadable_defaults_true() { + let a: PurchaseAlbum = serde_json::from_str(r#"{"id":"x","title":"A"}"#).unwrap(); + assert!(a.downloadable); + assert!( + !a.downloaded, + "downloaded is local-only, never from the wire" + ); + assert!(a.tracks.is_none()); + assert!(a.purchased_at.is_none()); + } + + // ── §2.5: leniency is load-bearing, and has a documented limit ──────────── + + /// Wrong-typed optional fields degrade to `None` instead of failing the item. + #[test] + fn wrong_typed_optionals_degrade_to_none() { + let json = r#"{ + "id": 7, "title": "T", + "media_number": "not a number", + "maximum_sampling_rate": {"nope": true}, + "maximum_bit_depth": [], + "album": 12345, + "purchased_at": "yesterday", + "version": [] + }"#; + let t: PurchaseTrack = serde_json::from_str(json).unwrap(); + assert_eq!(t.id, 7); + assert_eq!(t.media_number, None); + assert_eq!(t.maximum_sampling_rate, None); + assert_eq!(t.maximum_bit_depth, None); + assert!(t.album.is_none()); + assert_eq!(t.purchased_at, None); + assert_eq!(t.version, None); + } + + /// A malformed page collapses to an EMPTY page while its sibling still + /// parses. This is why a 401/403 body shaped like JSON and "you own nothing" + /// are indistinguishable — stated here so nobody later mistakes the empty + /// state for proof that the request succeeded. + #[test] + fn a_malformed_page_empties_itself_without_taking_its_sibling() { + let json = r#"{ + "albums": "this is not a page", + "tracks": {"offset":0,"limit":50,"total":1,"items":[{"id":9,"title":"Nine"}]} + }"#; + let r: PurchaseResponse = serde_json::from_str(json).unwrap(); + assert!(r.albums.items.is_empty()); + assert_eq!(r.albums.total, 0); + assert_eq!(r.tracks.items.len(), 1); + assert_eq!(r.tracks.items[0].title, "Nine"); + } + + /// §2.5b-2, measured against a live account: `?type=albums` OMITS the + /// `tracks` key entirely — it is not present-and-zero. A port that models a + /// present-but-empty sibling page is modelling the wrong thing. + #[test] + fn typed_response_with_an_absent_sibling_page_parses() { + let json = r#"{ + "albums": {"offset":0,"limit":500,"total":0,"items":[]}, + "user": {"id": 1, "login": "someone"} + }"#; + let r: PurchaseResponse = serde_json::from_str(json).unwrap(); + assert!(r.albums.items.is_empty()); + assert!( + r.tracks.items.is_empty(), + "absent sibling defaults, never fails" + ); + assert_eq!(r.tracks.total, 0); + } + + /// §2.5b-3: the IDS envelope has a DIFFERENT page shape — `{total, items}` + /// with no `offset` and no `limit`. If those scalars were required, the + /// lenient page wrapper would swallow the failure and hand back an empty + /// page, and both tab counters would read 0 forever with nothing logged. + #[test] + fn ids_response_page_shape_preserves_total_without_offset_or_limit() { + let json = r#"{ + "albums": {"total": 42, "items": [1,2,3]}, + "tracks": {"total": 7, "items": []}, + "user": {"id": 1, "login": "someone"} + }"#; + let r: PurchaseIdsResponse = serde_json::from_str(json).unwrap(); + assert_eq!(r.albums.total, 42, "the tab counter comes from here"); + assert_eq!(r.tracks.total, 7); + assert_eq!(r.albums.offset, 0); + assert_eq!(r.albums.limit, 0); + } + + /// The documented LIMIT of the leniency: a non-object top level still fails. + /// Recorded so the empty-list-on-everything claim is not overstated. + /// The documented LIMIT of the leniency — MEASURED 2026-08-16, and it is + /// wider than the contract stated. + /// + /// The contract (§2.5) says "a transport error, a non-JSON body and an + /// invalid top-level shape still FAIL". The first two hold. The third does + /// not, for ARRAYS: serde's derived `Deserialize` accepts a struct from a + /// JSON sequence as well as a map, and since both fields carry + /// `#[serde(default)]` plus a lenient page wrapper, `[]` — and even + /// `[1, 2]`, whose elements are consumed positionally and swallowed — + /// deserialize into a perfectly valid EMPTY response. + /// + /// What genuinely fails is a SCALAR top level: string, number, bool, null. + /// + /// This does not change the contract's conclusion (a JSON-shaped 401/403 + /// body is an object, and it collapses to "you own nothing" exactly as + /// described) but it does widen the silent-empty surface by one shape, and + /// on a feature nobody can smoke-test the exact boundary is worth pinning + /// down rather than approximating. + #[test] + fn only_scalar_top_levels_fail_arrays_collapse_to_empty() { + for ok in ["{}", "[]", "[1,2]"] { + let parsed = serde_json::from_str::<PurchaseResponse>(ok) + .unwrap_or_else(|e| panic!("expected {ok} to collapse to empty, got {e}")); + assert!(parsed.albums.items.is_empty()); + assert!(parsed.tracks.items.is_empty()); + } + + for err in ["\"nope\"", "7", "null", "true"] { + assert!( + serde_json::from_str::<PurchaseResponse>(err).is_err(), + "a scalar top level must still fail: {err}" + ); + } + } + + // ── §10-C: the version field that closes the latent #360 regression ─────── + + #[test] + fn purchase_track_carries_a_version_when_the_catalog_supplies_one() { + let t: PurchaseTrack = + serde_json::from_str(r#"{"id":1,"title":"Song","version":"Live"}"#).unwrap(); + assert_eq!(t.version.as_deref(), Some("Live")); + } + + // ── §14.3: goodies must never take an album down ────────────────────────── + + /// The shape of a POPULATED goodies list has never been observed — it comes + /// back empty on every album nobody owns. So an unexpected shape has to + /// degrade to "no goodies", not to "this album will not open". `/album/get` + /// is parsed with the strict structs, which is exactly where that would bite. + #[test] + fn a_surprising_goodies_shape_does_not_fail_the_album() { + for weird in [ + r#"{"id":"1","title":"A","goodies":"a string"}"#, + r#"{"id":"1","title":"A","goodies":{"unexpected":"object"}}"#, + r#"{"id":"1","title":"A","goodies":[{"totally":"different"}]}"#, + r#"{"id":"1","title":"A","goodies":7}"#, + ] { + let album: Album = serde_json::from_str(weird) + .unwrap_or_else(|e| panic!("goodies shape took the album down: {weird} → {e}")); + assert_eq!(album.title, "A"); + } + } + + #[test] + fn a_well_formed_goodie_parses_and_reads_defensively() { + let album: Album = serde_json::from_str( + r#"{"id":"1","title":"A","goodies":[ + {"id":5,"name":"Booklet","url":"https://u/b.pdf","original_url":"https://o/b.pdf"} + ]}"#, + ) + .unwrap(); + let goodies = album.goodies.expect("goodies present"); + assert_eq!(goodies.len(), 1); + assert_eq!(goodies[0].best_url(), Some("https://o/b.pdf")); + assert_eq!(goodies[0].display_name(), "Booklet"); + } +} + +#[cfg(test)] +mod upcoming_tests { + use super::{upcoming_from, Track}; + use chrono::{TimeZone, Utc}; + + #[test] + fn future_streamable_at_wins_over_the_date() { + let now = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); + assert!(upcoming_from(Some(now.timestamp() + 60), None, now)); + assert!(upcoming_from( + Some(now.timestamp() + 60), + Some("2000-01-01"), + now + )); + assert!(!upcoming_from( + Some(now.timestamp() - 60), + Some("2099-01-01"), + now + )); + // The captured dead track: `streamable_at: null`, past release date. + assert!(!upcoming_from(None, Some("2013-06-21"), now)); + } + + #[test] + fn release_date_is_the_fallback_and_absence_is_not_upcoming() { + let now = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); + assert!(upcoming_from(None, Some("2026-10-16"), now)); + assert!( + !upcoming_from(None, Some("2026-09-02"), now), + "today is not upcoming" + ); + assert!(!upcoming_from(None, Some("not a date"), now)); + assert!(!upcoming_from(None, None, now)); + } + + #[test] + fn a_streamable_track_is_never_upcoming() { + let track = Track { + streamable: Some(true), + release_date_stream: Some("2099-01-01".into()), + ..Default::default() + }; + assert!(!track.is_upcoming()); + let track = Track { + streamable: Some(false), + release_date_stream: Some("2099-01-01".into()), + ..Default::default() + }; + assert!(track.is_upcoming()); + } } diff --git a/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-albums-populated.json b/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-albums-populated.json new file mode 100644 index 000000000..85aeba4bc --- /dev/null +++ b/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-albums-populated.json @@ -0,0 +1,287 @@ +{ + "albums": { + "items": [ + { + "articles": [], + "artist": { + "albums_count": 145, + "id": 121232, + "image": null, + "name": "Megadeth", + "picture": null, + "slug": "megadeth" + }, + "artists": [ + { + "id": 121232, + "name": "Megadeth", + "roles": [ + "main-artist" + ] + } + ], + "displayable": true, + "downloadable": true, + "downloadable_format_ids": [ + 55 + ], + "duration": 2439, + "genre": { + "color": "#0070ef", + "id": 116, + "name": "Metal", + "path": [ + 116 + ], + "slug": "metal" + }, + "hires": false, + "hires_purchased": true, + "hires_streamable": false, + "id": "y5vh6f9umemda", + "image": { + "back": null, + "large": "https://static.qobuz.com/images/covers/da/em/y5vh6f9umemda_600.jpg", + "small": "https://static.qobuz.com/images/covers/da/em/y5vh6f9umemda_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/da/em/y5vh6f9umemda_50.jpg" + }, + "label": { + "albums_count": 1427, + "id": 247708, + "name": "CAPITOL CATALOG MKT (C92)", + "slug": "capitol-catalog-mkt-c92", + "supplier_id": 1 + }, + "maximum_bit_depth": 16, + "maximum_channel_count": 2, + "maximum_sampling_rate": 44.1, + "media_count": 1, + "order_id": 0, + "order_line_id": 1, + "parental_warning": false, + "popularity": 0, + "previewable": true, + "purchasable": true, + "purchasable_at": 1783321200, + "purchased_at": 1700000000, + "qobuz_id": 189898762, + "release_date_download": "1990-09-24", + "release_date_original": "1990-09-24", + "release_date_stream": "1990-09-24", + "released_at": 654127200, + "sampleable": true, + "slug": "rust-in-peace-megadeth", + "streamable": true, + "streamable_at": 1783321200, + "title": "Rust In Peace", + "tracks_count": 9, + "upc": "0602547899347", + "url": "https://www.qobuz.com/fr-fr/album/rust-in-peace-megadeth/y5vh6f9umemda", + "version": null + }, + { + "articles": [], + "artist": { + "albums_count": 14, + "id": 319678, + "image": null, + "name": "Rosa Luxemburg", + "picture": null, + "slug": "rosa-luxemburg" + }, + "artists": [ + { + "id": 319678, + "name": "Rosa Luxemburg", + "roles": [ + "main-artist" + ] + } + ], + "displayable": true, + "downloadable": true, + "downloadable_format_ids": [ + 7, + 6, + 5 + ], + "duration": 2600, + "genre": { + "color": "#0070ef", + "id": 191, + "name": "J-Pop", + "path": [ + 94, + 97, + 190, + 191 + ], + "slug": "j-pop" + }, + "hires": true, + "hires_purchased": true, + "hires_streamable": true, + "id": "nyomvn23bkmdc", + "image": { + "back": null, + "large": "https://static.qobuz.com/images/covers/dc/km/nyomvn23bkmdc_600.jpg", + "small": "https://static.qobuz.com/images/covers/dc/km/nyomvn23bkmdc_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/dc/km/nyomvn23bkmdc_50.jpg" + }, + "label": { + "albums_count": 31, + "id": 7190238, + "name": "ミディ", + "slug": "-59663", + "supplier_id": 194 + }, + "maximum_bit_depth": 24, + "maximum_channel_count": 2, + "maximum_sampling_rate": 96, + "media_count": 1, + "order_id": 0, + "order_line_id": 2, + "parental_warning": false, + "popularity": 0, + "previewable": true, + "purchasable": true, + "purchasable_at": 1761548400, + "purchased_at": 1700000000, + "qobuz_id": 293430749, + "release_date_download": "2017-08-06", + "release_date_original": "2017-08-06", + "release_date_stream": "2017-08-06", + "released_at": 1501970400, + "sampleable": true, + "slug": "-", + "streamable": true, + "streamable_at": 1761548400, + "title": "ぷりぷり", + "tracks_count": 11, + "upc": "4582417941498", + "url": "https://www.qobuz.com/fr-fr/album/-/nyomvn23bkmdc", + "version": null + }, + { + "articles": [], + "artist": { + "albums_count": 1557890, + "id": 145383, + "image": null, + "name": "Various Artists", + "picture": null, + "slug": "various-artists" + }, + "artists": [ + { + "id": 31419, + "name": "Ben Webster", + "roles": [ + "main-artist" + ] + }, + { + "id": 65981, + "name": "Brian Torff", + "roles": [ + "main-artist" + ] + }, + { + "id": 56627, + "name": "Bucky Pizzarelli", + "roles": [ + "main-artist" + ] + }, + { + "id": 1492954, + "name": "Robert Len", + "roles": [ + "main-artist" + ] + }, + { + "id": 42234, + "name": "Jaco Pastorius", + "roles": [ + "main-artist" + ] + }, + { + "id": 125808, + "name": "Frédéric Alarie", + "roles": [ + "main-artist" + ] + } + ], + "displayable": true, + "downloadable": true, + "downloadable_format_ids": [ + 56 + ], + "duration": 3283, + "genre": { + "color": "#0070ef", + "id": 10, + "name": "Classical", + "path": [ + 10 + ], + "slug": "classique" + }, + "hires": true, + "hires_purchased": true, + "hires_streamable": true, + "id": "gbud7rotdkdqa", + "image": { + "back": null, + "large": "https://static.qobuz.com/images/covers/qa/kd/gbud7rotdkdqa_600.jpg", + "small": "https://static.qobuz.com/images/covers/qa/kd/gbud7rotdkdqa_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/qa/kd/gbud7rotdkdqa_50.jpg" + }, + "label": { + "albums_count": 74, + "id": 7507664, + "name": "2xHD", + "slug": "2xhd-1", + "supplier_id": 41 + }, + "maximum_bit_depth": 24, + "maximum_channel_count": 2, + "maximum_sampling_rate": 352.8, + "media_count": 1, + "order_id": 0, + "order_line_id": 3, + "parental_warning": false, + "popularity": 0, + "previewable": true, + "purchasable": true, + "purchasable_at": 1783062000, + "purchased_at": 1700000000, + "qobuz_id": 313319237, + "release_date_download": "2024-01-05", + "release_date_original": "2024-01-05", + "release_date_stream": "2024-01-05", + "released_at": 1704409200, + "sampleable": true, + "slug": "audiophile-analog-collection-vol3-various-artists-ben-webster-brian-torff-bucky-pizzarelli-robert-len-jaco-pastorius-and-frederic-alarie", + "streamable": true, + "streamable_at": null, + "title": "Audiophile Analog Collection Vol.3", + "tracks_count": 14, + "upc": "0762765868942", + "url": "https://www.qobuz.com/fr-fr/album/audiophile-analog-collection-vol3-various-artists-ben-webster-brian-torff-bucky-pizzarelli-robert-len-jaco-pastorius-and-frederic-alarie/gbud7rotdkdqa", + "version": null + } + ], + "limit": 500, + "offset": 0, + "total": 3 + }, + "user": { + "id": 0, + "login": "redacted@example.invalid" + } +} diff --git a/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-noparams.json b/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-noparams.json new file mode 100644 index 000000000..848e76893 --- /dev/null +++ b/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-noparams.json @@ -0,0 +1,18 @@ +{ + "albums": { + "offset": 0, + "limit": 50, + "total": 0, + "items": [] + }, + "tracks": { + "offset": 0, + "limit": 50, + "total": 0, + "items": [] + }, + "user": { + "id": "<REDACTED>", + "login": "<REDACTED>" + } +} diff --git a/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-type-albums.json b/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-type-albums.json new file mode 100644 index 000000000..65aa531e5 --- /dev/null +++ b/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-type-albums.json @@ -0,0 +1,12 @@ +{ + "albums": { + "offset": 0, + "limit": 500, + "total": 0, + "items": [] + }, + "user": { + "id": "<REDACTED>", + "login": "<REDACTED>" + } +} diff --git a/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-type-tracks.json b/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-type-tracks.json new file mode 100644 index 000000000..033cf88b7 --- /dev/null +++ b/crates/qbz-models/tests/fixtures/purchases/getUserPurchases-type-tracks.json @@ -0,0 +1,12 @@ +{ + "tracks": { + "offset": 0, + "limit": 500, + "total": 0, + "items": [] + }, + "user": { + "id": "<REDACTED>", + "login": "<REDACTED>" + } +} diff --git a/crates/qbz-models/tests/fixtures/purchases/getUserPurchasesIds-noparams.json b/crates/qbz-models/tests/fixtures/purchases/getUserPurchasesIds-noparams.json new file mode 100644 index 000000000..f81af4739 --- /dev/null +++ b/crates/qbz-models/tests/fixtures/purchases/getUserPurchasesIds-noparams.json @@ -0,0 +1,14 @@ +{ + "albums": { + "total": 0, + "items": [] + }, + "tracks": { + "total": 0, + "items": [] + }, + "user": { + "id": "<REDACTED>", + "login": "<REDACTED>" + } +} diff --git a/crates/qbz-models/tests/fixtures/purchases/getUserPurchasesIds-type-albums.json b/crates/qbz-models/tests/fixtures/purchases/getUserPurchasesIds-type-albums.json new file mode 100644 index 000000000..fb30d776d --- /dev/null +++ b/crates/qbz-models/tests/fixtures/purchases/getUserPurchasesIds-type-albums.json @@ -0,0 +1,10 @@ +{ + "albums": { + "total": 0, + "items": [] + }, + "user": { + "id": "<REDACTED>", + "login": "<REDACTED>" + } +} diff --git a/crates/qbz-models/tests/purchase_wire_fixtures.rs b/crates/qbz-models/tests/purchase_wire_fixtures.rs new file mode 100644 index 000000000..5a7199d77 --- /dev/null +++ b/crates/qbz-models/tests/purchase_wire_fixtures.rs @@ -0,0 +1,203 @@ +//! The purchase envelopes, checked against REAL CAPTURED BYTES. +//! +//! # Why these are not the synthetic tests in `types.rs` +//! +//! The unit tests beside the models assert the shapes we BELIEVE Qobuz sends. +//! These assert the shapes it ACTUALLY sent. The files in `tests/fixtures/purchases/` +//! were captured on 2026-08-15 against the owner's own account, read-only and with +//! their authorisation, and the ids and login were redacted afterwards. They +//! replace the inferred dummy response this project started from — before them, +//! every claim about the purchase envelope was a reading of somebody else's code. +//! +//! They matter more than usual here because nobody on this team can smoke-test +//! Purchases: Qobuz does not sell it in the owner's region, so no screen will +//! ever be exercised locally. A fixture is the only thing in the loop that +//! cannot drift from what the server really does. +//! +//! # What they can and cannot prove +//! +//! Every capture is from an account that owns NOTHING, so `items` is empty in +//! all of them. They pin the ENVELOPE — which keys exist, which are absent, what +//! the pagination scalars look like — and they cannot say anything about a +//! populated item. That gap is real and is why the contract's §11-1 question +//! could not be answered from this account. + +use qbz_models::{PurchaseIdsResponse, PurchaseResponse}; + +fn fixture(name: &str) -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/purchases") + .join(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("missing capture {}: {e}", path.display())) +} + +/// The unfiltered call returns BOTH pages, and the server's default `limit` is +/// 50 — not 500, which is what the client asks for when it paginates. +#[test] +fn unfiltered_purchases_carry_both_pages_and_a_default_limit_of_50() { + let r: PurchaseResponse = serde_json::from_str(&fixture("getUserPurchases-noparams.json")) + .expect("the captured envelope must deserialize"); + + assert_eq!(r.albums.limit, 50, "the server's default page size"); + assert_eq!(r.tracks.limit, 50); + assert_eq!(r.albums.offset, 0); + assert_eq!(r.albums.total, 0); + assert!(r.albums.items.is_empty()); + assert!(r.tracks.items.is_empty()); +} + +/// THE correction that mattered: `?type=albums` **omits the `tracks` key +/// entirely**. It is not present-and-zeroed, which is what the first reading of +/// the reference concluded from a function that zeroes the sibling total. +/// +/// The observable effect is the same — an empty sibling page — which is why the +/// two-`getUserPurchasesIds`-calls requirement stands either way. But a port that +/// models a present-but-empty sibling is modelling something the server does not +/// send, and it is exactly the assumption that made the reference's album +/// "downloaded" predicate unsatisfiable (contract §11-1). +#[test] +fn a_typed_albums_response_omits_the_tracks_key_entirely() { + let raw = fixture("getUserPurchases-type-albums.json"); + assert!( + !raw.contains("\"tracks\""), + "the capture must NOT contain a tracks key — that absence is the point" + ); + + let r: PurchaseResponse = serde_json::from_str(&raw).expect("must still deserialize"); + assert_eq!( + r.albums.limit, 500, + "the client's requested page size comes back" + ); + assert!( + r.tracks.items.is_empty(), + "the absent sibling defaults, never fails" + ); + assert_eq!(r.tracks.total, 0); +} + +/// And symmetrically for tracks, so nobody assumes the omission is albums-only. +#[test] +fn a_typed_tracks_response_omits_the_albums_key_entirely() { + let raw = fixture("getUserPurchases-type-tracks.json"); + assert!(!raw.contains("\"albums\"")); + + let r: PurchaseResponse = serde_json::from_str(&raw).expect("must still deserialize"); + assert_eq!(r.tracks.limit, 500); + assert!(r.albums.items.is_empty()); +} + +/// The ids envelope is a DIFFERENT page shape: `{total, items}` with no `offset` +/// and no `limit`. +/// +/// This is the one that would have failed loudly and been misdiagnosed. Both tab +/// counters are read from `.total` here, and the page sits behind a lenient +/// deserializer that swallows a parse failure into an EMPTY page. Had `offset` +/// and `limit` been required fields, both counters would have read 0 forever with +/// nothing logged, and the obvious conclusion would have been "the user owns +/// nothing" rather than "the page did not parse". +#[test] +fn the_ids_envelope_has_no_offset_or_limit_and_still_yields_its_total() { + let raw = fixture("getUserPurchasesIds-noparams.json"); + assert!( + !raw.contains("\"offset\""), + "the ids page carries no offset" + ); + assert!(!raw.contains("\"limit\""), "nor a limit"); + + let r: PurchaseIdsResponse = serde_json::from_str(&raw).expect("must deserialize"); + assert_eq!(r.albums.total, 0); + assert_eq!(r.tracks.total, 0); + assert_eq!(r.albums.offset, 0, "defaulted, not sent"); + assert_eq!(r.albums.limit, 0, "defaulted, not sent"); +} + +/// The typed ids call drops the sibling too — so the per-type totals genuinely +/// need one call each, which is what the reference does and what the contract +/// requires. A two-parameter port that fires a single combined call reads one +/// type's total and silently reports 0 for the other. +#[test] +fn a_typed_ids_response_omits_its_sibling_which_is_why_two_calls_are_needed() { + let raw = fixture("getUserPurchasesIds-type-albums.json"); + assert!(!raw.contains("\"tracks\"")); + + let r: PurchaseIdsResponse = serde_json::from_str(&raw).expect("must deserialize"); + assert_eq!(r.albums.total, 0); + assert_eq!( + r.tracks.total, 0, + "absent sibling defaults to 0, never fails" + ); +} + +/// Every capture carries a top-level `user` block that no model declares, and +/// none of them may fail because of it. Unknown top-level keys must be ignored — +/// if Qobuz adds another one tomorrow, the purchases screen must not go blank. +#[test] +fn an_unknown_top_level_key_never_fails_a_response() { + for name in [ + "getUserPurchases-noparams.json", + "getUserPurchases-type-albums.json", + "getUserPurchases-type-tracks.json", + ] { + let raw = fixture(name); + assert!( + raw.contains("\"user\""), + "{name} should carry the user block" + ); + serde_json::from_str::<PurchaseResponse>(&raw) + .unwrap_or_else(|e| panic!("{name} must ignore the unknown `user` key: {e}")); + } + for name in [ + "getUserPurchasesIds-noparams.json", + "getUserPurchasesIds-type-albums.json", + ] { + serde_json::from_str::<PurchaseIdsResponse>(&fixture(name)) + .unwrap_or_else(|e| panic!("{name} must ignore the unknown `user` key: {e}")); + } +} + +/// FIRST capture from an account that OWNS albums (2026-09-01, USA Sublime +/// account, read-only, owner-authorised). Every prior fixture had empty items, +/// so this is the first proof of the POPULATED shape. If the whole page +/// deserializes to zero items while the raw JSON plainly has three, the bug is +/// in a per-item field the strict struct rejects — not downstream. +#[test] +fn a_populated_albums_page_yields_its_three_items() { + let raw = fixture("getUserPurchases-albums-populated.json"); + let r: PurchaseResponse = + serde_json::from_str(&raw).expect("the populated envelope must deserialize"); + assert_eq!( + r.albums.items.len(), + 3, + "expected 3 purchased albums, got {} (total={})", + r.albums.items.len(), + r.albums.total + ); +} + +/// The same capture is the first proof of the ENTITLEMENT fields. The DSD64 +/// album streams as 16/44.1 (`hires:false`) yet is purchased as `[55]` with +/// `hires_purchased:true` — the streaming quality says nothing about what was +/// bought, so the ids must survive the parse verbatim, in wire order. +#[test] +fn a_populated_albums_page_carries_the_downloadable_format_ids() { + let raw = fixture("getUserPurchases-albums-populated.json"); + let r: PurchaseResponse = serde_json::from_str(&raw).expect("deserializes"); + let by_title: std::collections::HashMap<&str, &qbz_models::PurchaseAlbum> = r + .albums + .items + .iter() + .map(|a| (a.title.as_str(), a)) + .collect(); + + let dsd64 = by_title["Rust In Peace"]; + assert_eq!(dsd64.downloadable_format_ids, vec![55]); + assert!(!dsd64.hires, "the streaming catalog is CD quality"); + assert!(dsd64.hires_purchased, "…but the purchase is hi-res"); + + let flac = by_title["ぷりぷり"]; + assert_eq!(flac.downloadable_format_ids, vec![7, 6, 5]); + + let dsd128 = by_title["Audiophile Analog Collection Vol.3"]; + assert_eq!(dsd128.downloadable_format_ids, vec![56]); +} diff --git a/crates/qbz-offline-cache/Cargo.toml b/crates/qbz-offline-cache/Cargo.toml index afe546f25..38dceb87b 100644 --- a/crates/qbz-offline-cache/Cargo.toml +++ b/crates/qbz-offline-cache/Cargo.toml @@ -14,7 +14,7 @@ qbz-secrets = { path = "../qbz-secrets" } qbz-library = { path = "../qbz-library" } # SQLite index (match qbz-library's version/features) -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } # Async runtime + HTTP (download pipeline + artwork) tokio = { workspace = true } @@ -22,7 +22,7 @@ reqwest = { workspace = true } futures-util = { workspace = true } # FLAC tag + artwork embedding (legacy-format post-processing) -lofty = "0.23" +lofty.workspace = true # Platform cache dir (default offline-root location) dirs = "6" @@ -36,3 +36,15 @@ log = { workspace = true } [dev-dependencies] tempfile = "3" +# The purchase download harness (`tests/purchase_download_harness.rs`) stands up +# a real local HTTP server and drives the pipeline with real bytes. Integration +# tests do not inherit `[dependencies]`, so the crates it names are repeated here. +tokio = { workspace = true } +lofty.workspace = true +qbz-models = { path = "../qbz-models" } +qbz-library = { path = "../qbz-library" } +# reqwest is built with `rustls-tls-webpki-roots-no-provider`, so building ANY +# client panics with "No provider set" until a process-level CryptoProvider is +# installed. The app does that at startup (`qbz_app::ensure_crypto_provider`); +# a test binary has no startup, so the harness installs it itself. +rustls = { version = "0.23", features = ["aws_lc_rs"] } diff --git a/crates/qbz-offline-cache/src/cmaf_store.rs b/crates/qbz-offline-cache/src/cmaf_store.rs index d80ba38d4..e23a6e52d 100644 --- a/crates/qbz-offline-cache/src/cmaf_store.rs +++ b/crates/qbz-offline-cache/src/cmaf_store.rs @@ -236,3 +236,79 @@ fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> { fs::rename(&tmp, path)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn raw_bundle() -> CmafRawBundle { + CmafRawBundle { + init_bytes: b"init-segment-bytes".to_vec(), + segments: vec![b"segment-one".to_vec(), b"segment-two".to_vec()], + content_key: [7u8; 16], + infos: "infos".to_string(), + format_id: 6, + sampling_rate: Some(44100), + bit_depth: Some(16), + n_segments: 2, + } + } + + /// The bundle is found by deriving its location from the root the cache is + /// open at, so an application directory that moves underneath it costs + /// nothing. iOS reassigns an app's data container on reinstall, which is + /// what makes this more than theoretical: every absolute path recorded at + /// download time names a directory that no longer exists, while the bundle + /// itself is untouched. + #[test] + fn a_bundle_is_found_after_its_root_moves() { + let old_root = tempfile::tempdir().unwrap(); + let (written, _) = persist_bundle(old_root.path(), 42, &raw_bundle()).unwrap(); + + // What a reinstall does: same bytes, same layout, different prefix. + let new_parent = tempfile::tempdir().unwrap(); + let new_root = new_parent.path().join("audio"); + std::fs::rename(old_root.path(), &new_root).unwrap(); + assert!( + !written.segments_path.exists(), + "the recorded path is stale" + ); + + let loaded = read_bundle(&BundleLayout::new(&new_root, 42)).unwrap(); + + assert_eq!(loaded.init_bytes, b"init-segment-bytes"); + assert_eq!(loaded.segments.len(), 2); + assert_eq!(loaded.segments[1], b"segment-two"); + } + + #[test] + fn the_layout_is_the_same_one_persist_wrote_to() { + let root = tempfile::tempdir().unwrap(); + persist_bundle(root.path(), 7, &raw_bundle()).unwrap(); + + // Playback derives rather than reading the row back, so the two must + // agree for every track: this is what makes deriving safe. + // + // Asserted against the filesystem rather than against what + // `persist_bundle` returned. That value is built by the same pure + // function this derives from, so comparing the two compares a value + // with itself: it stays green even when both are wrong, which is no + // guard at all. + let derived = BundleLayout::new(root.path(), 7); + assert!( + derived.init_path.exists(), + "nothing at the derived init path {:?}", + derived.init_path + ); + assert!( + derived.segments_path.exists(), + "nothing at the derived segments path {:?}", + derived.segments_path + ); + assert!( + derived.manifest_path.exists(), + "nothing at the derived manifest path {:?}", + derived.manifest_path + ); + } +} diff --git a/crates/qbz-offline-cache/src/metadata.rs b/crates/qbz-offline-cache/src/metadata.rs index 0a4584b94..9c53a874d 100644 --- a/crates/qbz-offline-cache/src/metadata.rs +++ b/crates/qbz-offline-cache/src/metadata.rs @@ -254,9 +254,26 @@ pub fn sanitize_filename(name: &str) -> String { // Trim dashes and whitespace from ends sanitized = sanitized.trim_matches('-').trim().to_string(); - // Limit length to 200 chars (leaving room for extension and path) + // Limit length to 200 BYTES (leaving room for extension and path). + // + // The cap is deliberately in bytes, not chars: it is what the shipped + // implementation used, and these strings become on-disk path segments that + // the library-to-registry join matches by EXACT string equality + // (`database.rs`, the `qobuz_purchase` source stamp). Recomputing the cap in + // chars would silently relocate every already-downloaded non-ASCII album. + // + // `String::truncate` panics when the index is not a char boundary, and that + // is reachable here rather than theoretical: the mapping above keeps every + // Unicode ALPHANUMERIC (`c.is_alphanumeric()` is Unicode-aware), so accented + // Latin, Greek, Cyrillic and CJK titles all survive as multibyte characters. + // A 200-byte cut lands mid-character for any such title long enough to hit + // it. Walk back to the nearest boundary instead. if sanitized.len() > 200 { - sanitized.truncate(200); + let end = (0..=200) + .rev() + .find(|&i| sanitized.is_char_boundary(i)) + .unwrap_or(0); + sanitized.truncate(end); sanitized = sanitized.trim_matches('-').trim().to_string(); } diff --git a/crates/qbz-offline-cache/src/playback.rs b/crates/qbz-offline-cache/src/playback.rs index c4c0014a8..c15ae0f1d 100644 --- a/crates/qbz-offline-cache/src/playback.rs +++ b/crates/qbz-offline-cache/src/playback.rs @@ -64,88 +64,18 @@ pub async fn load_cmaf_bundle_with_ui_events( /// caller should treat `None` as a cache miss — continue to the next /// tier or the network. /// -/// `offline_root_path` is only used to locate the secret vault's -/// install UUID file; it must match the path used at download time. +/// `offline_root_path` is what the bundle itself is located under, and what +/// locates the secret vault's install UUID file. It is the current root rather +/// than the one the row recorded, which is the whole point of this change: iOS +/// reassigns an app's data container on reinstall, so a recorded absolute path +/// stops existing while the bytes are still on disk. /// Passing `OfflineCacheState::get_cache_path()` is correct. pub fn load_cmaf_bundle( track_id: u64, row: &CmafBundleRow, offline_root_path: &Path, ) -> Option<Vec<u8>> { - if row.cache_format != 2 { - return None; - } - - let init_path = row.init_path.as_ref().or_else(|| { - log::warn!( - "[OfflineCache/Play] Track {} cache_format=2 but init_path is null", - track_id - ); - None - })?; - let content_key_wrapped = row.content_key_wrapped.as_ref().or_else(|| { - log::warn!( - "[OfflineCache/Play] Track {} cache_format=2 but content_key_wrapped is null", - track_id - ); - None - })?; - - let segments_path = std::path::PathBuf::from(&row.segments_path); - let track_dir = segments_path.parent()?.to_path_buf(); - let layout = BundleLayout { - track_dir, - init_path: std::path::PathBuf::from(init_path), - segments_path: segments_path.clone(), - manifest_path: segments_path.with_file_name("manifest.json"), - }; - - let loaded = match cmaf_store::read_bundle(&layout) { - Ok(lb) => lb, - Err(e) => { - log::warn!( - "[OfflineCache/Play] Track {} failed to read CMAF bundle: {}", - track_id, - e - ); - return None; - } - }; - - let vault = match secret_vault::get_or_init(offline_root_path) { - Ok(v) => v, - Err(e) => { - log::warn!( - "[OfflineCache/Play] Track {} SecretBox init failed: {}", - track_id, - e - ); - return None; - } - }; - let unwrapped = match vault.unwrap(content_key_wrapped) { - Ok(k) => k, - Err(e) => { - log::warn!( - "[OfflineCache/Play] Track {} content_key unwrap failed: {}", - track_id, - e - ); - return None; - } - }; - if unwrapped.len() != 16 { - log::warn!( - "[OfflineCache/Play] Track {} unwrapped content_key wrong size ({} bytes)", - track_id, - unwrapped.len() - ); - return None; - } - let mut content_key = [0u8; 16]; - content_key.copy_from_slice(&unwrapped); - - match loaded.decrypt_to_flac(&content_key) { + match load_bundle(track_id, row, offline_root_path) { Ok(flac_bytes) => { log::info!( "[OfflineCache/Play] Track {} unwrapped + decrypted ({:.2} MB FLAC)", @@ -154,13 +84,190 @@ pub fn load_cmaf_bundle( ); Some(flac_bytes) } + // A v1 row is not a failure, it is simply not this function's business. + Err(BundleLoadError::NotCmaf) => None, Err(e) => { - log::warn!( - "[OfflineCache/Play] Track {} CMAF decrypt failed: {}", - track_id, - e - ); + log::warn!("[OfflineCache/Play] Track {} {}", track_id, e); None } } } + +/// Why a bundle could not be turned back into FLAC. +/// +/// The public entry point collapses all of these to `None`, because to a caller +/// they mean the same thing: fall through to the next tier. They are kept apart +/// **so a test can tell which stage failed** — without that, an implementation +/// that looked for the bundle in the wrong place would be indistinguishable from +/// one that simply could not decrypt it (see +/// `resolution_looks_under_the_current_root_not_the_recorded_path`). +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum BundleLoadError { + /// A v1 (plain FLAC) row. The caller reads `file_path` itself. + NotCmaf, + /// The row was written before the bundle finished landing. + IncompleteRow(&'static str), + Read(String), + Vault(String), + Unwrap(String), + KeySize(usize), + Decrypt(String), +} + +impl std::fmt::Display for BundleLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotCmaf => write!(f, "not a CMAF bundle"), + Self::IncompleteRow(col) => write!(f, "cache_format=2 but {col} is null"), + Self::Read(e) => write!(f, "failed to read CMAF bundle: {e}"), + Self::Vault(e) => write!(f, "SecretBox init failed: {e}"), + Self::Unwrap(e) => write!(f, "content_key unwrap failed: {e}"), + Self::KeySize(n) => write!(f, "unwrapped content_key wrong size ({n} bytes)"), + Self::Decrypt(e) => write!(f, "CMAF decrypt failed: {e}"), + } + } +} + +fn load_bundle( + track_id: u64, + row: &CmafBundleRow, + offline_root_path: &Path, +) -> Result<Vec<u8>, BundleLoadError> { + if row.cache_format != 2 { + return Err(BundleLoadError::NotCmaf); + } + // The column is not read for its value — the layout below is derived — but a + // null here means the row was written before the bundle was complete, and + // that is the one thing it still distinguishes. + if row.init_path.is_none() { + return Err(BundleLoadError::IncompleteRow("init_path")); + } + let content_key_wrapped = row + .content_key_wrapped + .as_ref() + .ok_or(BundleLoadError::IncompleteRow("content_key_wrapped"))?; + + // Derived from the root the cache is open at, not from the absolute paths + // the row recorded. The two agree until the application directory moves, + // and then only the recorded ones are wrong: iOS reassigns an app's data + // container on reinstall (and can on restore or migration), which leaves + // every stored path naming a directory that no longer exists while the + // bundle itself sits untouched under the new one. + // + // Deriving is safe because the index lives *inside* the root + // (`<root>/index.db`), so a row can only ever describe a bundle under that + // same root, and `persist_bundle` writes nowhere else. Removal and eviction + // already resolve this way — see `maintenance::remove_album_cached_tracks`. + let layout = BundleLayout::new(offline_root_path, track_id); + if layout.segments_path != std::path::Path::new(&row.segments_path) { + log::info!( + "[OfflineCache/Play] Track {} bundle resolved under the current root; \ + the recorded path predates a move", + track_id + ); + } + + let loaded = cmaf_store::read_bundle(&layout).map_err(BundleLoadError::Read)?; + + let vault = secret_vault::get_or_init(offline_root_path) + .map_err(|e| BundleLoadError::Vault(e.to_string()))?; + let unwrapped = vault + .unwrap(content_key_wrapped) + .map_err(|e| BundleLoadError::Unwrap(e.to_string()))?; + if unwrapped.len() != 16 { + return Err(BundleLoadError::KeySize(unwrapped.len())); + } + let mut content_key = [0u8; 16]; + content_key.copy_from_slice(&unwrapped); + + loaded + .decrypt_to_flac(&content_key) + .map_err(BundleLoadError::Decrypt) +} + +#[cfg(test)] +mod tests { + use super::*; + fn row_recording(segments_path: &str) -> CmafBundleRow { + CmafBundleRow { + cache_format: 2, + segments_path: segments_path.to_string(), + init_path: Some("/gone/init.mp4".to_string()), + // Deliberately not a real wrapped key: the point of these tests is + // which stage is *reached*, and the key stage is past the read. + content_key_wrapped: Some(vec![0u8; 8]), + infos_wrapped: None, + format_id: Some(6), + n_segments: Some(1), + } + } + + /// The regression guard for the container-move bug. + /// + /// The row records a path that no longer exists — which is exactly what iOS + /// leaves behind when it reassigns an app's data container. With nothing on + /// disk either way this fails at the read, and `read_bundle` names the path + /// it tried, so the message is the assertion: it must be the root the cache + /// is open at, never the one the row recorded. + /// + /// Deliberately stops at the read rather than persisting a bundle and + /// checking it got further. Getting further means reaching the secret vault, + /// and that opens the OS keyring under the *production* service name — a + /// unit test has no business touching a developer's login keychain. + #[test] + fn resolution_looks_under_the_current_root_not_the_recorded_path() { + let root = tempfile::tempdir().unwrap(); + let row = row_recording("/recorded-elsewhere/tracks-cmaf/42/segments.bin"); + + let err = load_bundle(42, &row, root.path()).unwrap_err(); + + let BundleLoadError::Read(message) = &err else { + panic!("expected a read failure with nothing on disk, got {err:?}"); + }; + // The *whole* derived path, not just the root prefix. Matching the + // prefix alone leaves the guard blind to the two ways this can still be + // wrong while looking under the right root: the wrong subdirectory, and + // the wrong track id. + let expected = BundleLayout::new(root.path(), 42); + assert!( + message.contains(expected.init_path.to_str().unwrap()), + "the read should have been attempted at {:?}, got: {message}", + expected.init_path + ); + assert!( + !message.contains("/recorded-elsewhere/"), + "the recorded path was used, which is the bug this guards: {message}" + ); + } + + #[test] + fn a_v1_row_is_not_this_functions_business() { + let root = tempfile::tempdir().unwrap(); + let mut row = row_recording("/gone/x.flac"); + row.cache_format = 1; + + assert_eq!( + load_bundle(1, &row, root.path()).unwrap_err(), + BundleLoadError::NotCmaf + ); + } + + #[test] + fn a_row_written_before_the_bundle_landed_is_reported_as_incomplete() { + let root = tempfile::tempdir().unwrap(); + + let mut row = row_recording("/gone/tracks-cmaf/42/segments.bin"); + row.init_path = None; + assert_eq!( + load_bundle(42, &row, root.path()).unwrap_err(), + BundleLoadError::IncompleteRow("init_path") + ); + + let mut row = row_recording("/gone/tracks-cmaf/42/segments.bin"); + row.content_key_wrapped = None; + assert_eq!( + load_bundle(42, &row, root.path()).unwrap_err(), + BundleLoadError::IncompleteRow("content_key_wrapped") + ); + } +} diff --git a/crates/qbz-offline-cache/src/purchases_service.rs b/crates/qbz-offline-cache/src/purchases_service.rs index bda56b46c..9188368c9 100644 --- a/crates/qbz-offline-cache/src/purchases_service.rs +++ b/crates/qbz-offline-cache/src/purchases_service.rs @@ -28,12 +28,15 @@ //! — this crate only exposes the single-track primitive. use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; -use qbz_library::LibraryDatabase; +use qbz_library::{write_purchase_tags, LibraryDatabase, PurchaseTagWrite}; use qbz_models::{ Album, PurchaseAlbum, PurchaseFormatOption, PurchaseResponse, PurchaseTrack, SearchResultsPage, + Track, }; +use qbz_qobuz::purchases::{purchase_page_step, PurchasePageStep}; use qbz_qobuz::QobuzClient; use qbz_qobuz::Result as QobuzResult; @@ -63,6 +66,128 @@ pub async fn get_user_purchases_all(client: &QobuzClient) -> QobuzResult<Purchas client.get_user_purchases_all().await } +/// Fetch the complete account entitlement with hard HTTP status checks. This +/// is deliberately separate from the historical list loader: AlbumView and +/// every new-download gate must distinguish an error from a fresh empty list. +pub async fn get_entitlement_purchases_all(client: &QobuzClient) -> QobuzResult<PurchaseResponse> { + let page_limit = 500; + let mut albums = Vec::new(); + let mut tracks = Vec::new(); + + for kind in ["albums", "tracks"] { + let mut offset = 0; + let mut total = 0; + loop { + let page = client + .get_user_purchases_page_typed_checked(Some(kind), page_limit, offset) + .await?; + let (got, items_total) = if kind == "albums" { + let got = page.albums.items.len() as u32; + albums.extend(page.albums.items); + (got, page.albums.total) + } else { + let got = page.tracks.items.len() as u32; + tracks.extend(page.tracks.items); + (got, page.tracks.total) + }; + if offset == 0 { + total = items_total; + } + match purchase_page_step(offset, got, total) { + PurchasePageStep::Stop => break, + PurchasePageStep::Continue { next_offset } => offset = next_offset, + } + } + } + + Ok(PurchaseResponse { + albums: SearchResultsPage { + total: albums.len() as u32, + offset: 0, + limit: page_limit, + items: albums, + }, + tracks: SearchResultsPage { + total: tracks.len() as u32, + offset: 0, + limit: page_limit, + items: tracks, + }, + }) +} + +#[derive(Debug, Clone, Default)] +pub struct PurchaseEntitlementIndex { + albums: HashMap<String, PurchaseAlbum>, + track_ids_by_album: HashMap<String, HashSet<u64>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PurchaseEntitlement { + NotOwned, + FullAlbum { + downloadable: bool, + downloadable_format_ids: Vec<u32>, + }, + Tracks { + track_ids: HashSet<u64>, + }, +} + +impl PurchaseEntitlementIndex { + pub fn from_response(response: &PurchaseResponse) -> Self { + let albums = response + .albums + .items + .iter() + .cloned() + .map(|album| (album.id.clone(), album)) + .collect(); + let mut track_ids_by_album: HashMap<String, HashSet<u64>> = HashMap::new(); + for track in &response.tracks.items { + let Some(album_id) = track + .album + .as_ref() + .map(|album| album.id.trim()) + .filter(|album_id| !album_id.is_empty()) + else { + continue; + }; + track_ids_by_album + .entry(album_id.to_string()) + .or_default() + .insert(track.id); + } + Self { + albums, + track_ids_by_album, + } + } + + pub fn entitlement_for(&self, album_id: &str) -> PurchaseEntitlement { + if let Some(album) = self.albums.get(album_id) { + return PurchaseEntitlement::FullAlbum { + downloadable: album.downloadable, + downloadable_format_ids: album.downloadable_format_ids.clone(), + }; + } + match self.track_ids_by_album.get(album_id) { + Some(track_ids) if !track_ids.is_empty() => PurchaseEntitlement::Tracks { + track_ids: track_ids.clone(), + }, + _ => PurchaseEntitlement::NotOwned, + } + } + + /// Exact album row used by the defensive download gate. The format list + /// must be explicit: catalog synthesis is not entitlement evidence. + pub fn downloadable_album(&self, album_id: &str) -> Option<&PurchaseAlbum> { + self.albums + .get(album_id) + .filter(|album| album.downloadable && !album.downloadable_format_ids.is_empty()) + } +} + /// Fetch ALL purchases for a SINGLE type by paginating (`"albums"` / /// `"tracks"`). Pass-through to `get_user_purchases_all_typed` — the primary /// per-tab list-load path (command #3). The OTHER type's `total` is forced to 0 @@ -76,18 +201,21 @@ pub async fn get_user_purchases_by_type( client.get_user_purchases_all_typed(purchase_type).await } -/// Read the per-type purchase TOTAL via a single `getUserPurchasesIds` -/// page (`limit=1, offset=0, type`). The items are opaque; only `.total` for -/// the matching type is read. Returns `None` on any error (the controller falls -/// back to 0 / the response length — `loadPurchasesMetadata`'s `.catch(()=>null)`). +/// The per-tab counter for one purchase type (`"albums"` / `"tracks"`). /// -/// GOTCHA (per-type totals): this MUST be called once per type. A single -/// unfiltered `limit=1` ids call carries only the FIRST type's total, so the -/// controller fires two of these — `get_purchase_total(client, "albums")` and -/// `get_purchase_total(client, "tracks")` — never one combined call. +/// From `getUserPurchases?type=…&limit=1`, NOT from `getUserPurchasesIds`: +/// the ids endpoint's `tracks.total` counts every purchased track INCLUDING +/// the ones that came with an album (measured 2026-09-01: three album +/// purchases, zero standalone tracks, `getUserPurchasesIds` said 34 while +/// `getUserPurchases?type=tracks` said 0). The Tracks tab lists standalone +/// track purchases only, so its badge must count the same thing. Qobuz keeps +/// the two kinds apart on the wire and so does this screen. +/// +/// One typed page never carries the sibling type's total (that key is simply +/// absent), which is why there are two calls and not one. pub async fn get_purchase_total(client: &QobuzClient, purchase_type: &str) -> Option<u32> { match client - .get_user_purchases_ids_page_typed(Some(purchase_type), 1, 0) + .get_user_purchases_page_typed(Some(purchase_type), 1, 0) .await { Ok(resp) => match purchase_type { @@ -185,6 +313,7 @@ pub fn synth_formats(album: &Album) -> Vec<PurchaseFormatOption> { label: "[FLAC][24-bit,192kHz]".to_string(), bit_depth: Some(24), sampling_rate: Some(192.0), + streaming: false, }); } @@ -194,6 +323,7 @@ pub fn synth_formats(album: &Album) -> Vec<PurchaseFormatOption> { label: "[FLAC][24-bit,96kHz]".to_string(), bit_depth: Some(24), sampling_rate: Some(96.0), + streaming: false, }); } @@ -202,6 +332,7 @@ pub fn synth_formats(album: &Album) -> Vec<PurchaseFormatOption> { label: "[FLAC][16-bit,44.1kHz]".to_string(), bit_depth: Some(16), sampling_rate: Some(44.1), + streaming: false, }); formats.push(PurchaseFormatOption { @@ -209,11 +340,139 @@ pub fn synth_formats(album: &Album) -> Vec<PurchaseFormatOption> { label: "[MP3][320kbps]".to_string(), bit_depth: None, sampling_rate: None, + streaming: false, }); formats } +/// Rank of a purchase `format_id` — HIGHEST quality first. Orders the detail +/// menu (which default-selects `formats[0]`) and, on the playback side, picks +/// which downloaded copy of a track plays when several formats are on disk. +/// Unknown ids rank below every known one so a new Qobuz id is still offered, +/// never hidden. +pub fn format_rank(id: u32) -> u32 { + match id { + 56 => 6, // DSD128 + 55 => 5, // DSD64 + 27 => 4, // FLAC 24/192 + 7 => 3, // FLAC 24/96 + 6 => 2, // FLAC 16/44.1 + 5 => 1, // MP3 320 + _ => 0, + } +} + +/// One download-menu option for a `format_id` the entitlement declared. +/// +/// The 27/7/6/5 labels are the exact strings `synth_formats` has always +/// produced — they become the on-disk quality folder, so they are frozen. 55 +/// and 56 were first observed on a real account 2026-09-01 (a DSD64 and a +/// DSD128 purchase) and are labelled by what the CDN serves for them +/// (`audio/x-dsf`). An id this table does not know is still offered, under a +/// neutral label: the entitlement is the authority, not this list. +pub fn format_option(id: u32) -> PurchaseFormatOption { + let (label, bit_depth, sampling_rate) = match id { + 56 => ("[DSF][DSD128]", Some(1), Some(5644.8)), + 55 => ("[DSF][DSD64]", Some(1), Some(2822.4)), + 27 => ("[FLAC][24-bit,192kHz]", Some(24), Some(192.0)), + 7 => ("[FLAC][24-bit,96kHz]", Some(24), Some(96.0)), + 6 => ("[FLAC][16-bit,44.1kHz]", Some(16), Some(44.1)), + 5 => ("[MP3][320kbps]", None, None), + other => { + return PurchaseFormatOption { + id: other, + label: format!("[Format {other}]"), + bit_depth: None, + sampling_rate: None, + streaming: false, + } + } + }; + PurchaseFormatOption { + id, + label: label.to_string(), + bit_depth, + sampling_rate, + streaming: false, + } +} + +/// The download menu from the ENTITLEMENT (`downloadable_format_ids`): +/// deduplicated, highest quality first. Empty in → empty out; the caller +/// decides whether anything else may stand in. +pub fn entitlement_formats(ids: &[u32]) -> Vec<PurchaseFormatOption> { + let mut seen = HashSet::new(); + let mut ids: Vec<u32> = ids.iter().copied().filter(|id| seen.insert(*id)).collect(); + // Stable, so two unknown ids keep their wire order relative to each other. + ids.sort_by(|a, b| format_rank(*b).cmp(&format_rank(*a))); + ids.into_iter().map(format_option).collect() +} + +/// The download menu for a purchased album. +/// +/// The ENTITLEMENT comes first, highest quality on top: those are the ids the +/// store grants through `getFileUrl(intent=download)` — measured 2026-09-01 +/// on the live account, it grants exactly them and denies everything else +/// (a DSD64 purchase: 55 only; a hi-res FLAC purchase: 7, 6, 5 only). +/// +/// BELOW it, the streaming ladder for whatever the entitlement does not +/// already cover at a LOWER quality: the same 27/7/6/5 the catalog's own +/// fields synthesize, fetched through the streaming grant (`intent=stream`) +/// — the route the Slint and Tauri builds used for every purchase download. +/// It is kept only downward, so a DSD purchase can still be saved as a CD +/// FLAC for the car, and never upward, so nothing is offered that the +/// account did not buy and could not stream in that quality anyway. Each +/// option carries its route (`streaming`), and the download path signs the +/// grant accordingly. +/// +/// A listing without the field (older wire) gets the catalog synthesis alone, +/// all of it through the purchase grant as before. +pub fn purchase_formats(album: &Album, entitlement_ids: &[u32]) -> Vec<PurchaseFormatOption> { + let mut menu = entitlement_formats(entitlement_ids); + if menu.is_empty() { + log::info!( + "[Purchases] album {}: no downloadable_format_ids on the wire, synthesizing from the catalog", + album.id + ); + return synth_formats(album); + } + // A purchase may remain downloadable after its catalog edition stops + // streaming (purchase-only, regional withdrawal, or label takedown). The + // entitlement still grants its exact master, but there is no streaming + // ladder to append in that state. + if !album.is_streamable() { + return menu; + } + let top = menu.iter().map(|f| format_rank(f.id)).max().unwrap_or(0); + for mut extra in synth_formats(album) { + let owned = menu.iter().any(|f| f.id == extra.id); + // The hi-res rungs (27/7) exist on the streaming side only when the + // catalog STREAMS hi-res. `album.hires` alone is not that: the live + // DSD album is hires:true with a 16/44.1 stream, and offering + // "[FLAC][24-bit,192kHz]" there wrote a folder by that name full of + // CD-quality files (smoke 2026-09-02). + let streams = !matches!(extra.id, 27 | 7) || album.hires_streamable; + if !owned && streams && format_rank(extra.id) < top { + extra.streaming = true; + menu.push(extra); + } + } + menu +} + +/// The entitlement ids of one album in a purchases listing (`[]` when the +/// album is not listed, or the wire predates the field). +pub fn entitlement_format_ids(purchases: &PurchaseResponse, album_id: &str) -> Vec<u32> { + purchases + .albums + .items + .iter() + .find(|item| item.id == album_id) + .map(|item| item.downloadable_format_ids.clone()) + .unwrap_or_default() +} + /// Annotate a `PurchaseResponse` in-place with frontend-computed download flags /// (§3.4 `v2_apply_purchase_download_flags`, `legacy_compat.rs:2594-2625`, used /// by commands #1 / #4). Pure — no I/O. The frontend OVERRIDES any backend @@ -223,13 +482,45 @@ pub fn synth_formats(album: &Album) -> Vec<PurchaseFormatOption> { /// * `downloaded = downloaded_ids.contains(track.id)`; /// * `downloaded_format_ids = format_map.get(track.id).cloned().unwrap_or_default()`. /// -/// Per album (the all-mode / by-type path where albums and tracks are sibling -/// pages): collect the ids of `response.tracks.items` whose -/// `track.album.id == album.id`; then +/// Per album: **DELIBERATE DIVERGENCE from the reference, contract §11-1, ruled +/// by the owner on 2026-08-16.** +/// +/// The reference collected the ids of `response.tracks.items` whose +/// `track.album.id == album.id` and set /// `album.downloaded = !ids.is_empty() && all ids ∈ downloaded_ids`. -/// An album with NO matching tracks in this response → `downloaded = false` -/// (the empty-set rule; partial-page albums may flip to not-downloaded — this -/// is the documented page-mode gotcha and is replicated verbatim). +/// That predicate is UNSATISFIABLE on the screen that uses it: the Albums tab +/// loads `getUserPurchases?type=albums`, and that response omits the `tracks` +/// key entirely (measured against a live account, §2.5b). With no sibling +/// tracks page the id set is always empty, so the empty-set rule pins +/// `downloaded` to `false` — forever, and silently, because the field is +/// optional behind a lenient deserializer. The visible consequence in the +/// reference is that no album card can ever show as downloaded and the +/// "Hide downloaded" filter can never hide anything. The guide-dog user would +/// have had no way to notice a filter that simply does nothing. +/// +/// So the album rule reads the LOCAL REGISTRY instead, which is the only party +/// that actually knows: `album_counts` maps a purchase album id to its count of +/// DISTINCT downloaded tracks (`get_downloaded_purchase_album_counts`), and an +/// album is downloaded when that count covers its `tracks_count`. +/// +/// Two properties worth keeping in mind: +/// * it is format-AGNOSTIC, matching the reference's list-level semantics (the +/// detail screen is the format-scoped one); +/// * an absent or zero `tracks_count` yields `false`. `PurchaseAlbum.tracks_count` +/// is an `Option`, and without it there is no denominator — "every track is +/// downloaded" is unprovable, so the answer is no. That is the safe direction +/// (it under-claims rather than showing a green mark for an album that may be +/// half on disk) and it preserves the reference's empty-set rule. +/// +/// **Status, stated so this comment does not describe behaviour that does not +/// exist yet:** as of 2026-08-16 this function has NO production caller. The Qt +/// controller that will call it on every list path — so that browsing and +/// searching cannot disagree about the same album — is not written. The shipping +/// Slint controller does NOT use this function; it computes album state itself +/// with its own copy of the old nested-tracks predicate +/// (`crates/qbz/src/purchases.rs`, `enrich_albums`), and therefore still shows +/// the unsatisfiable behaviour described above. Until the Qt side lands the two +/// frontends will disagree, and that is expected rather than a bug to chase. /// /// `downloaded_ids`/`format_map` are keyed by `i64` (registry track ids); track /// ids are `u64` and compared via `track.id as i64`, exactly as the source. @@ -237,6 +528,7 @@ pub fn apply_download_flags( response: &mut PurchaseResponse, downloaded_ids: &HashSet<i64>, format_map: &HashMap<i64, Vec<u32>>, + album_counts: &HashMap<String, u32>, ) { for track in &mut response.tracks.items { let tid = track.id as i64; @@ -245,24 +537,24 @@ pub fn apply_download_flags( } for album in &mut response.albums.items { - let album_track_ids: Vec<i64> = response - .tracks - .items - .iter() - .filter(|track| { - track - .album - .as_ref() - .map(|album_ref| album_ref.id == album.id) - .unwrap_or(false) - }) - .map(|track| track.id as i64) - .collect(); + album.downloaded = + album_downloaded_from_registry(&album.id, album.tracks_count, album_counts); + } +} - album.downloaded = !album_track_ids.is_empty() - && album_track_ids - .iter() - .all(|track_id| downloaded_ids.contains(track_id)); +/// The shared "is this purchased album fully downloaded" predicate (§11-1). +/// Pure, so both the list annotation and the detail builder can agree, and so it +/// is unit-testable without a database. +pub fn album_downloaded_from_registry( + album_id: &str, + tracks_count: Option<u32>, + album_counts: &HashMap<String, u32>, +) -> bool { + match tracks_count { + Some(expected) if expected > 0 => { + album_counts.get(album_id).copied().unwrap_or(0) >= expected + } + _ => false, } } @@ -275,9 +567,14 @@ pub fn apply_download_flags( /// /// Mapping rules (verbatim from command #5): /// * the nested track list comes from `album.tracks.items` mapped to -/// `PurchaseTrack` — the `version`/subtitle is DROPPED (purchases have no -/// `version` field, §4.6); `performer` defaults when the catalog track has -/// none; per-track `purchased_at` is the album-level meta value; +/// `PurchaseTrack`; `performer` defaults when the catalog track has none; +/// per-track `purchased_at` is the album-level meta value; +/// * the `version`/subtitle IS carried across. This is a deliberate divergence +/// from the reference: Tauri's frontend type declares `version` and its detail +/// view renders `formatTrackTitle(title, version)`, but the mapping here never +/// populated it, so every purchased track lost its subtitle (issue #360). The +/// catalog track does carry it (`qbz_models::Track::version`), so the fix is a +/// field copy. Contract §10-C rules this in. /// * `downloadable = purchase_meta.map(|m| m.downloadable).unwrap_or(true)` /// (defaults TRUE when the album is not found in the purchases listing); /// * `purchased_at = purchase_meta.and_then(|m| m.purchased_at)`; @@ -310,6 +607,7 @@ pub fn build_purchase_album( .map(|track| PurchaseTrack { id: track.id, title: track.title.clone(), + version: track.version.clone(), track_number: track.track_number, media_number: track.media_number, duration: track.duration, @@ -318,10 +616,22 @@ pub fn build_purchase_album( hires: track.hires, maximum_sampling_rate: track.maximum_sampling_rate, maximum_bit_depth: track.maximum_bit_depth, - streamable: track.streamable, + // `is_streamable()`, and the choice is load-bearing here: + // `PurchaseTrack.streamable` defaults TRUE on purpose (the + // §2.6 split in `qbz-models`) so a terse purchases payload + // never makes a purchased row unclickable — and this bridge + // was handing it the catalog `bool`, whose absence meant + // `false`, re-introducing exactly that inversion through + // the back door. Nobody would have caught it by clicking: + // Qobuz Purchases is not sold in the owner's region. + streamable: track.is_streamable(), // Server-derived; set below from the registry. downloaded: false, downloaded_format_ids: Vec::new(), + // An album purchase covers every track in the same formats. + downloadable_format_ids: purchase_meta + .map(|item| item.downloadable_format_ids.clone()) + .unwrap_or_default(), purchased_at: purchase_meta.and_then(|item| item.purchased_at), }) .collect() @@ -356,6 +666,12 @@ pub fn build_purchase_album( maximum_sampling_rate: album.maximum_sampling_rate, maximum_bit_depth: album.maximum_bit_depth, downloadable: purchase_meta.map(|item| item.downloadable).unwrap_or(true), + downloadable_format_ids: purchase_meta + .map(|item| item.downloadable_format_ids.clone()) + .unwrap_or_default(), + hires_purchased: purchase_meta + .map(|item| item.hires_purchased) + .unwrap_or(false), downloaded: album_downloaded, purchased_at: purchase_meta.and_then(|item| item.purchased_at), tracks: Some(SearchResultsPage { @@ -368,11 +684,14 @@ pub fn build_purchase_album( } /// Pick the on-disk file extension for a purchased track from the RESPONSE -/// stream's `format_id` / `mime_type` (§7.1.5, ported byte-for-byte from -/// `v2_purchase_extension` `legacy_compat.rs:2553-2559`): -/// * `"mp3"` if the served `format_id == 5` OR the served `mime_type` -/// contains `"mpeg"`; -/// * `"flac"` otherwise. +/// stream's `format_id` / `mime_type`. Historically (`v2_purchase_extension`, +/// `legacy_compat.rs:2553-2559`) the rule was "mp3 if `format_id == 5` or the +/// MIME says mpeg, else flac". That rule wrote a DSD purchase as `.flac`: the +/// CDN serves format 55/56 as `audio/x-dsf` (measured 2026-09-01), and a DSF +/// under a `.flac` name is unplayable by anything, including our own scanner, +/// which classifies by extension. So the served container decides, across the +/// whole set Qobuz documents for purchases (DSF, DFF, FLAC, ALAC, WAV, AIFF, +/// MP3), and the format id only breaks a tie for a missing MIME. /// /// IMPORTANT (Addendum B.2): the extension keys off the RESPONSE's served /// format, NOT the requested one. If Qobuz downgrades (e.g. you asked for 27 but @@ -380,13 +699,92 @@ pub fn build_purchase_album( /// records the REQUESTED `format_id` (see `download_purchase_track`). Do NOT /// reconcile the two — the Tauri app does not. pub fn purchase_extension(format_id: u32, mime_type: &str) -> &'static str { - if format_id == 5 || mime_type.contains("mpeg") { + let mime = mime_type.to_ascii_lowercase(); + if format_id == 5 || mime.contains("mpeg") { "mp3" + } else if mime.contains("dsf") { + "dsf" + } else if mime.contains("dff") || mime.contains("dsdiff") { + "dff" + } else if mime.contains("wav") { + "wav" + } else if mime.contains("aiff") || mime.contains("x-aif") { + "aiff" + } else if mime.contains("mp4") || mime.contains("m4a") || mime.contains("alac") { + "m4a" + } else if mime.is_empty() && matches!(format_id, 55 | 56) { + // No MIME at all: the DSD ids are DSF on this CDN. + "dsf" } else { "flac" } } +/// Does the head of a served file look like the container its extension +/// claims? A DSF begins `DSD `, a DFF `FRM8`, a FLAC `fLaC` (or an ID3v2 +/// prefix, which lofty and every player accept), an MP3 an ID3v2 tag or a +/// frame sync, WAV `RIFF`, AIFF `FORM`, and an M4A carries `ftyp` at offset 4. +/// Anything else is an HTML error page or a truncated body wearing an audio +/// name — the exact file a registry row must never point at. Unknown +/// extensions are not judged. +pub fn served_magic_matches(extension: &str, head: &[u8]) -> bool { + let starts = |m: &[u8]| head.len() >= m.len() && &head[..m.len()] == m; + match extension { + "dsf" => starts(b"DSD "), + "dff" => starts(b"FRM8"), + "flac" => starts(b"fLaC") || starts(b"ID3"), + "mp3" => starts(b"ID3") || (head.len() >= 2 && head[0] == 0xFF && head[1] & 0xE0 == 0xE0), + "wav" => starts(b"RIFF"), + "aiff" => starts(b"FORM"), + "m4a" => head.len() >= 8 && &head[4..8] == b"ftyp", + _ => true, + } +} + +/// Read the first bytes of a just-written `.part` and refuse a body that is +/// not the container the extension promises. +fn verify_served_container(path: &std::path::Path, extension: &str) -> Result<(), String> { + use std::io::Read; + let mut head = [0u8; 12]; + let mut file = + std::fs::File::open(path).map_err(|e| format!("Failed to reopen downloaded file: {e}"))?; + let n = file + .read(&mut head) + .map_err(|e| format!("Failed to read downloaded file: {e}"))?; + if served_magic_matches(extension, &head[..n]) { + Ok(()) + } else { + Err(format!( + "Served file is not a {} (first bytes {:02x?})", + extension.to_ascii_uppercase(), + &head[..n.min(4)] + )) + } +} + +/// Only containers whose tag write lofty has been driven end to end here +/// (the download harness writes and re-reads FLAC and MP3). DSF carries an +/// ID3v2 chunk lofty can address, but nothing in this tree has proven that +/// write on a real 600 MB master, and a tagging failure that corrupts a DSD +/// purchase is the one outcome worse than an untagged file. +fn tags_supported(extension: &str) -> bool { + matches!(extension, "flac" | "mp3") +} + +/// The artist FOLDER of a downloaded track: the album artist when the caller +/// supplied the album context, else the track's own performer, else the +/// reference's `"Unknown Artist"`. The ARTIST tag is a different question and +/// keeps the performer (`tag_downloaded_file`): a Various-Artists compilation +/// is one folder on disk and fourteen performers in its tags. +pub fn path_artist(album_artist: Option<&str>, performer: Option<&str>) -> String { + album_artist + .map(str::trim) + .filter(|s| !s.is_empty()) + .or_else(|| performer.map(str::trim).filter(|s| !s.is_empty())) + .unwrap_or("Unknown Artist") + .to_string() +} + /// Build the deterministic on-disk target path for a purchased track /// (§7.3, ported byte-for-byte from `v2_purchase_target_path` /// `legacy_compat.rs:2561-2592`): @@ -437,6 +835,100 @@ pub fn target_path( .join(file_name) } +/// A new copy is rooted at a folder selected by the user; a continued copy is +/// already bound to its final album leaf and must never pass through the +/// artist/album path builder again. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PurchaseDownloadDestination { + NewRoot { + root: String, + resolved_album_folder: String, + }, + ExistingAlbumFolder(String), +} + +impl PurchaseDownloadDestination { + pub fn album_folder(&self, artist_name: &str, album_title: &str, quality_dir: &str) -> PathBuf { + match self { + Self::NewRoot { + root, + resolved_album_folder, + } => { + if resolved_album_folder.is_empty() { + target_path( + root, + artist_name, + album_title, + quality_dir, + 1, + "placeholder", + "tmp", + ) + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(root)) + } else { + PathBuf::from(resolved_album_folder) + } + } + Self::ExistingAlbumFolder(folder) => PathBuf::from(folder), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PurchaseDownloadReceipt { + pub file_path: String, + pub album_id: Option<String>, + pub mime_type: String, + pub container: String, + pub sample_rate_hz: Option<u32>, + pub bit_depth: Option<u32>, + pub channels: Option<u16>, + pub file_size: u64, + pub modified_ns: Option<u128>, + pub last_verified_at: i64, +} + +fn purchase_track_file_name( + media_number: u32, + track_number: u32, + track_title: &str, + extension: &str, +) -> String { + let title = sanitize_filename(track_title); + if media_number > 1 && track_number > 0 { + format!( + "{:02}-{:02} - {}.{}", + media_number, track_number, title, extension + ) + } else if track_number > 0 { + format!("{:02} - {}.{}", track_number, title, extension) + } else { + format!("{}.{}", title, extension) + } +} + +fn purchase_track_target( + destination: &PurchaseDownloadDestination, + artist_name: &str, + album_title: &str, + quality_dir: &str, + media_number: u32, + track_number: u32, + track_title: &str, + extension: &str, +) -> PathBuf { + destination + .album_folder(artist_name, album_title, quality_dir) + .join(purchase_track_file_name( + media_number, + track_number, + track_title, + extension, + )) +} + /// I/O tail of the single-track download: given the already-fetched audio /// `data` and the resolved track/stream metadata, derive the extension from the /// RESPONSE format, build the target path, `create_dir_all`, write the `.part` @@ -502,16 +994,23 @@ fn write_track_file( // Addendum B.3: write `.part`, then rename — no overwrite preflight. let temp_path = target.with_extension(format!("{}.part", extension)); - std::fs::write(&temp_path, data).map_err(|e| format!("Failed to write temporary file: {}", e))?; + std::fs::write(&temp_path, data) + .map_err(|e| format!("Failed to write temporary file: {}", e))?; std::fs::rename(&temp_path, &target).map_err(|e| format!("Failed to finalize file: {}", e))?; Ok(target.to_string_lossy().to_string()) } +/// PUBLIC so the download harness (`tests/purchase_download_harness.rs`) can +/// drive the write + registry tail with real bytes without standing up a +/// `QobuzClient`. That harness is the only thing that executes this code before +/// a user does — Purchases cannot be smoke-tested here — so reachability from a +/// test is worth the wider surface. #[allow(clippy::too_many_arguments)] -fn write_and_register_track( +pub fn write_and_register_track( db: &LibraryDatabase, track_id: u64, + album_id: Option<&str>, requested_format_id: u32, data: &[u8], artist_name: &str, @@ -536,11 +1035,18 @@ fn write_and_register_track( )?; // Addendum B.1/B.2: registry write AFTER the file is on disk, with the - // REQUESTED format_id (album_id None for single-track downloads). A registry - // failure here returns Err while the file stays on disk (orphaned). + // REQUESTED format_id. A registry failure here returns Err while the file + // stays on disk (orphaned) — the reference does the same and does not roll back. + // + // `album_id` is folded into THIS write rather than left to a follow-up call. + // The reference wrote `None` here and then backfilled the album id from the + // frontend (`markTrackDownloaded(...).catch(() => {})`, both in the album loop + // and in the single-track path). Collapsing the two writes reaches the same end + // state with one statement and no window in which the column is null — which + // matters now that the column has a reader (the album-downloaded rule below). db.mark_purchase_downloaded( track_id as i64, - None, + album_id, &file_path, requested_format_id as i64, ) @@ -549,25 +1055,353 @@ fn write_and_register_track( Ok(file_path) } +// ─── Scope expansion §14: tags, covers, goodies ────────────────────────────── +// +// Everything in this block is ADDITIVE over the reference, which wrote no tags, +// no cover and no goodies. Nothing here changes a request or the entitlement +// boundary, and every part degrades to reference behaviour when it fails: the +// audio file is the deliverable, and it is already on disk and registered before +// any of this runs. + +/// Album-level facts a track download needs in order to tag itself, gathered +/// once by the caller before the album loop rather than re-derived per track. +/// +/// `cover_jpeg` is carried as bytes because it is embedded into EVERY track of +/// the album; fetching it per track would issue one CDN request per file for a +/// value that never changes. +#[derive(Debug, Clone, Default)] +pub struct PurchaseAlbumContext { + pub album_artist: String, + pub year: Option<u32>, + pub genre: Option<String>, + pub label: Option<String>, + pub cover_jpeg: Option<Vec<u8>>, +} + +impl PurchaseAlbumContext { + /// Build the context from the catalog album the detail screen already holds. + /// The cover is fetched separately (it is I/O) and attached with + /// [`Self::with_cover`]. + pub fn from_album(album: &Album) -> Self { + // The V2 nested `dates.original` wins over the flat + // `release_date_original` when present — the model says so on the field + // itself, and on a V2-shaped album the flat field is absent, which would + // silently drop the DATE tag from every track of that release. + let date = album + .dates + .as_ref() + .and_then(|d| d.original.as_deref()) + .or(album.release_date_original.as_deref()); + + Self { + album_artist: album.artist.name.clone(), + year: parse_release_year(date), + genre: album.genre.as_ref().map(|g| g.name.clone()), + label: album.label.as_ref().map(|l| l.name.clone()), + cover_jpeg: None, + } + } + + pub fn with_cover(mut self, cover_jpeg: Option<Vec<u8>>) -> Self { + self.cover_jpeg = cover_jpeg; + self + } +} + +/// Pull the year out of a Qobuz `release_date_original` (`"2019-05-31"`). +/// Anything that does not start with four digits yields `None` rather than a +/// wrong year — a wrong DATE tag is worse than an absent one. +pub fn parse_release_year(date: Option<&str>) -> Option<u32> { + let date = date?; + let head: String = date.chars().take(4).collect(); + if head.len() == 4 && head.chars().all(|c| c.is_ascii_digit()) { + head.parse().ok() + } else { + None + } +} + +/// The largest asset this will pull into memory. Covers are ~100 KB and booklets +/// a few MB; the ceiling exists so a hostile or wrong `Content-Length` cannot +/// decide our allocation. +const MAX_ASSET_BYTES: usize = 32 * 1024 * 1024; + +/// How long a single asset may take, end to end. +const ASSET_TIMEOUT_SECS: u64 = 60; + +/// Fetch an image or extra asset over HTTP, returning `None` on any failure. +/// +/// Deliberately infallible-by-return: every caller treats a missing cover or +/// goodie as "skip it", never as a download failure. +/// +/// **This does NOT reuse `QobuzClient::download_audio`, and the difference is +/// deliberate.** That function is tuned for one job — a multi-minute hi-res +/// track from the Qobuz CDN — and three of its properties are wrong here: +/// * it sizes its buffer with `Vec::with_capacity(content_length)`, i.e. from +/// a number the remote chose. For a track that is a trusted CDN; for a +/// goodie it is an arbitrary URL out of an album payload whose item shape has +/// never been observed. A bogus length would attempt the allocation +/// immediately, and an allocation failure ABORTS the process — on a machine +/// this project's own build rules describe as hard-freezing under memory +/// pressure; +/// * it sets no total timeout, because a large track legitimately exceeds any +/// fixed budget. A cover that never finishes would hang the album loop with +/// nothing to cancel it; +/// * it logs every fetch as "Downloading audio", which is simply wrong for a +/// PDF and makes the logs lie during exactly the investigation that needs +/// them. +/// +/// So assets get their own client: a hard size ceiling, a total timeout, and a +/// capacity hint that is `min(content_length, ceiling)`. +pub async fn fetch_asset_bytes(url: &str) -> Option<Vec<u8>> { + use std::time::Duration; + + let url = url.trim(); + if url.is_empty() { + return None; + } + + let client = match reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(ASSET_TIMEOUT_SECS)) + .build() + { + Ok(client) => client, + Err(e) => { + log::warn!("[Purchases] could not build the asset client: {e}"); + return None; + } + }; + + let response = match client + .get(url) + .header("User-Agent", "Mozilla/5.0") + .send() + .await + { + Ok(response) => response, + Err(e) => { + log::warn!("[Purchases] asset fetch failed ({url}): {e}"); + return None; + } + }; + + if !response.status().is_success() { + log::warn!( + "[Purchases] asset fetch got HTTP {} ({url})", + response.status() + ); + return None; + } + + // Refuse oversized assets on the ANNOUNCED length before reading a byte, and + // then again on the actual bytes, because the announcement is not binding. + if let Some(len) = response.content_length() { + if len as usize > MAX_ASSET_BYTES { + log::warn!("[Purchases] asset is {len} bytes, over the {MAX_ASSET_BYTES} cap ({url})"); + return None; + } + } + + let bytes = match response.bytes().await { + Ok(bytes) => bytes, + Err(e) => { + log::warn!("[Purchases] asset body failed ({url}): {e}"); + return None; + } + }; + + if bytes.is_empty() { + log::warn!("[Purchases] asset fetch returned 0 bytes: {url}"); + return None; + } + if bytes.len() > MAX_ASSET_BYTES { + log::warn!( + "[Purchases] asset body was {} bytes despite its headers, over the cap ({url})", + bytes.len() + ); + return None; + } + + Some(bytes.to_vec()) +} + +/// Write `cover.jpg` / `back.jpg` / `large_cover.jpg` beside the album's tracks (§14.2). +/// +/// `album/get`'s `image` object carries exactly `small`, `thumbnail`, `large` +/// and `back` — measured 2026-08-15; there is no `mega`, matching the earlier +/// finding that Qobuz album art tops out around 600 px in practice. So `large` +/// is the best available and `back` is a bonus nothing else in QBZ uses. +/// +/// Best-effort by contract: a write failure is logged and swallowed. +pub fn write_album_cover_files( + album_dir: &std::path::Path, + cover_jpeg: Option<&[u8]>, + back_jpeg: Option<&[u8]>, + large_cover_jpeg: Option<&[u8]>, +) { + // `large_cover.jpg` is the name the Qobuz desktop client writes beside its + // own `folder.jpg` (Finder, 2026-09-02): the `_max` variant, the master's + // own size. `cover.jpg` stays the 600 px one every scanner picks up. + for (bytes, name) in [ + (cover_jpeg, "cover.jpg"), + (back_jpeg, "back.jpg"), + (large_cover_jpeg, "large_cover.jpg"), + ] { + let Some(bytes) = bytes.filter(|b| !b.is_empty()) else { + continue; + }; + let path = album_dir.join(name); + if let Err(e) = std::fs::write(&path, bytes) { + log::warn!("[Purchases] failed to write {}: {e}", path.display()); + } + } +} + +/// Tag a file that has just been downloaded (§14.1). Never fails the download — +/// it returns nothing and logs on error, so a caller cannot accidentally +/// propagate it. +pub fn tag_downloaded_file(file_path: &str, track: &Track, ctx: &PurchaseAlbumContext) { + let meta = PurchaseTagWrite { + title: track.title.clone(), + version: track.version.clone(), + // The track's OWN performer, straight from the API. This is the whole + // reason purchases do not reuse the editor's writer, whose artist rule + // reads the file — and a just-downloaded file has nothing to read. + artist: track + .performer + .as_ref() + .map(|a| a.name.clone()) + .unwrap_or_else(|| ctx.album_artist.clone()), + // `"Singles"` matches what the PATH builder uses for a track with no + // album (`download_purchase_track`). Defaulting to an empty string here + // instead would file the track in a folder called `Singles` while + // stamping `ALBUM=` on it — the folder and the tag disagreeing about the + // same track. + album_title: track + .album + .as_ref() + .map(|a| a.title.clone()) + .unwrap_or_else(|| "Singles".to_string()), + album_artist: ctx.album_artist.clone(), + track_number: Some(track.track_number), + disc_number: track.media_number, + year: ctx.year, + genre: ctx.genre.clone(), + label: ctx.label.clone(), + isrc: track.isrc.clone(), + composer: track.composer.as_ref().map(|c| c.name.clone()), + copyright: track.copyright.clone(), + }; + + if let Err(e) = write_purchase_tags(file_path, &meta, ctx.cover_jpeg.as_deref()) { + log::warn!("[Purchases] failed to tag {file_path}: {e}"); + } +} + +/// Download one album goodie (booklet PDF, video, …) into the album folder +/// (§14.3). +/// +/// The item shape is UNVERIFIED — goodies come back as an empty array on albums +/// nobody owns, and we cannot capture a populated one without owning a purchase. +/// So this reads the URL and name through the defensive accessors and gives up +/// quietly on anything it cannot make sense of. Goodies are counted separately +/// from track progress (a goodie is not a track) and a goodie failure never +/// fails the album. +pub async fn download_goodie( + goody: &qbz_models::Goody, + album_dir: &std::path::Path, +) -> Option<String> { + let url = goody.best_url()?; + + // Create the folder BEFORE spending the download. Doing it afterwards throws + // away however many megabytes were just fetched if the directory cannot be + // made. + if let Err(e) = std::fs::create_dir_all(album_dir) { + log::warn!("[Purchases] failed to create the goodie folder: {e}"); + return None; + } + + let path = goodie_target_path(album_dir, &goody.display_name(), url); + let bytes = fetch_asset_bytes(url).await?; + + match std::fs::write(&path, &bytes) { + Ok(()) => Some(path.to_string_lossy().to_string()), + Err(e) => { + log::warn!("[Purchases] failed to write goodie {}: {e}", path.display()); + None + } + } +} + +/// Pick a safe, non-colliding on-disk name for a goodie. Pure, so the naming +/// rules are testable without a download. +/// +/// Three hazards, all handled here: +/// * **the extension must be read from the PATH, not the whole URL.** Query and +/// fragment are stripped FIRST. Doing it the other way round — splitting on +/// the last `.` and then removing the query — turns a signed +/// `…/booklet.pdf?sig=a.b3f` into a file called `Booklet.b3f`; +/// * **a leading dot would hide the file.** `sanitize_filename` keeps ASCII +/// `.`, so a goodie named `.notes` would land as a dotfile the user never +/// sees. (It also maps `/` and `\` to `-`, which is what makes escaping the +/// album folder impossible — that part needs no help here.) +/// * **two goodies can sanitize to the same name.** Rather than let the second +/// silently overwrite the first, later ones get a ` (2)`, ` (3)` suffix. +fn goodie_target_path(album_dir: &std::path::Path, display_name: &str, url: &str) -> PathBuf { + let path_part = url.split(['?', '#']).next().unwrap_or(url); + let ext = path_part + .rsplit('/') + .next() + .and_then(|segment| segment.rsplit_once('.')) + .map(|(_, ext)| ext) + // Booklets are the only goodie kind ever observed, and a wrong extension + // is recoverable while a missing file is not. + .filter(|ext| !ext.is_empty() && ext.len() <= 5 && ext.chars().all(char::is_alphanumeric)) + .unwrap_or("pdf"); + + let mut stem = sanitize_filename(display_name); + if stem.starts_with('.') { + stem.insert(0, '_'); + } + + let mut candidate = album_dir.join(format!("{stem}.{ext}")); + let mut n = 2; + while candidate.exists() && n < 100 { + candidate = album_dir.join(format!("{stem} ({n}).{ext}")); + n += 1; + } + candidate +} + /// The single canonical single-track download primitive (Slice 5). /// /// Ported from `v2_download_purchase_track_impl` (`legacy_compat.rs:2651-2702`) /// combined with the registry write of `v2_purchases_download_track` -/// (`:3013-3022`). Sequence: +/// (`:3013-3022`), then re-plumbed for the contract of 2026-09-01 +/// (`qt-frontend/2026-09-01-purchases-local-playback/00-CONTRACT.md` §4): /// 1. `client.get_track(track_id)` → metadata. Error → `"Failed to fetch track /// {id}: {e}"`. -/// 2. `client.get_track_file_url_by_format(track_id, format_id)` → SIGNED -/// `StreamUrl` (intent=stream). Error → `"Failed to get download URL for -/// track {id}: {e}"`. (In the reference the client lock is dropped here; in -/// this crate the caller holds the `QobuzClient` by `&`, so there is no lock -/// to drop — the read guard is released by the controller before the -/// multi-minute CDN fetch. No behavioral divergence in the bytes path.) -/// 3. `QobuzClient::download_audio(&stream.url)` → `Vec<u8>` (HTTP/1.1-only, no -/// total timeout — see `qbz-qobuz`). -/// 4. Resolve names: artist = `track.performer.name` else `"Unknown Artist"`; -/// album = `track.album.title` else `"Singles"`. -/// 5. Extension from RESPONSE `stream.format_id`/`mime_type` (B.2); path via -/// `target_path`; `.part`→rename; registry write with REQUESTED `format_id`. +/// 2. The grant. `streaming == false`: `get_purchase_file_url` — SIGNED +/// `getFileUrl` with **`intent=download`**, the purchased master; the +/// store grants it only for entitled ids (a DSD entitlement answers 400 +/// to `intent=stream`). `streaming == true`: `get_track_file_url_by_format` +/// — the streaming grant, for a below-the-purchase quality the store +/// does not sell (`purchase_formats`). Error → `"Failed to get download +/// URL for track {id}: {e}"`. +/// 3. The body is STREAMED straight into the `.part` file +/// (`QobuzClient::download_audio_to_path`). A DSD128 track is ~650 MB; the +/// old `Vec<u8>` round trip held every byte in memory before the first +/// write. +/// 4. Resolve names: the artist FOLDER is the album artist from `ctx` +/// (`path_artist`), so a compilation lands in ONE folder — the reference +/// used `track.performer.name` here and split "Audiophile Analog +/// Collection Vol.3" into fourteen artist folders of one track each +/// (smoke 2026-09-01); the performer is only the fallback for a call +/// without a context. Album = `track.album.title` else `"Singles"`. +/// 5. Extension from the RESPONSE `format_id`/`mime_type` (B.2); the served +/// container's magic is checked before the `.part`→final rename; registry +/// write with the REQUESTED `format_id`. /// /// `quality_dir` is the UI-selected format label with `'/'→'-'` already applied /// (§7.5); it becomes the album-folder quality suffix AND the registry's quality @@ -575,118 +1409,173 @@ fn write_and_register_track( /// controller uses the FIRST track's returned path to rewrite the album-download /// destination to the album folder — Slice 7). /// -/// Addendum B.5: only `stream.url` / `stream.format_id` / `stream.mime_type` are -/// consumed; `stream.restrictions` is IGNORED (no restriction-based blocking). -pub async fn download_purchase_track( - client: &QobuzClient, - db: &LibraryDatabase, - track_id: u64, - format_id: u32, - destination: &str, - quality_dir: &str, -) -> Result<String, String> { - let track = client - .get_track(track_id) - .await - .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?; - let stream = client - .get_track_file_url_by_format(track_id, format_id) - .await - .map_err(|e| format!("Failed to get download URL for track {}: {}", track_id, e))?; - - let data = QobuzClient::download_audio(&stream.url).await?; - - let artist_name = track - .performer - .as_ref() - .map(|artist| artist.name.clone()) - .unwrap_or_else(|| "Unknown Artist".to_string()); - let album_title = track - .album - .as_ref() - .map(|album| album.title.clone()) - .unwrap_or_else(|| "Singles".to_string()); - - write_and_register_track( - db, - track_id, - format_id, - &data, - &artist_name, - &album_title, - quality_dir, - track.track_number, - &track.title, - stream.format_id, - &stream.mime_type, - destination, - ) -} - -/// Download-ONLY variant of the single-track primitive: identical CDN fetch + -/// `.part`→rename pipeline as `download_purchase_track`, but it does **NOT** -/// write the `downloaded_purchases` registry. Returns the final on-disk path. -/// -/// This exists for the ALBUM loop (`qbz-slint::execute_album_download`), which -/// matches Svelte `executeAlbumDownload` (`purchaseDownloadStore.ts:130-147`): -/// the album loop marks the track `'complete'` on download success FIRST, then -/// performs the registry write as a SEPARATE best-effort step that SWALLOWS -/// failure (`await markTrackDownloaded(...).catch(() => {})`). So a registry-write -/// failure during an album download leaves the track `'complete'` (file on disk, -/// just unregistered) — UNLIKE the single-track path, where a registry error -/// propagates and the track shows `'failed'` (§B.1). -/// -/// The caller is responsible for the best-effort registry write afterwards -/// (`db.mark_purchase_downloaded(track_id, Some(album_id), &file_path, -/// format_id)`), ignoring its error. Addendum B.2 (extension from RESPONSE, -/// registry/qualityDir from REQUESTED), B.3 (silent overwrite), and B.5 -/// (restrictions ignored) all hold identically to `download_purchase_track`. +/// Addendum B.5: only `url` / `format_id` / `mime_type` of the grant are +/// consumed; `restrictions` is IGNORED (no restriction-based blocking). The +/// grant's signed URL lives in this frame only — it is never logged or stored. pub async fn download_purchase_track_file_only( client: &QobuzClient, track_id: u64, format_id: u32, - destination: &str, + streaming: bool, + destination: &PurchaseDownloadDestination, quality_dir: &str, -) -> Result<String, String> { + ctx: Option<&PurchaseAlbumContext>, +) -> Result<PurchaseDownloadReceipt, String> { + // UNSIGNED on the purchase path (contract §11-6): the vendor's own desktop + // client sends `/track/get` without a signature, and so did the reference. + // The entitlement proof is the signature on `getFileUrl` below. let track = client - .get_track(track_id) + .get_track_for_purchase(track_id) .await .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?; - let stream = client - .get_track_file_url_by_format(track_id, format_id) - .await - .map_err(|e| format!("Failed to get download URL for track {}: {}", track_id, e))?; + // The route is the option's, not a guess: a purchased id goes through the + // purchase grant; a below-the-purchase quality the store does not sell + // goes through the streaming grant (`purchase_formats`). + let grant = if streaming { + client + .get_track_file_url_by_format(track_id, format_id) + .await + } else { + client.get_purchase_file_url(track_id, format_id).await + } + .map_err(|e| format!("Failed to get download URL for track {}: {}", track_id, e))?; - let data = QobuzClient::download_audio(&stream.url).await?; - - let artist_name = track - .performer - .as_ref() - .map(|artist| artist.name.clone()) - .unwrap_or_else(|| "Unknown Artist".to_string()); + let artist_name = path_artist( + ctx.map(|c| c.album_artist.as_str()), + track.performer.as_ref().map(|artist| artist.name.as_str()), + ); let album_title = track .album .as_ref() .map(|album| album.title.clone()) .unwrap_or_else(|| "Singles".to_string()); - write_track_file( - &data, + // Fall back to the album the TRACK itself reports. `album_id` is what makes + // an album's downloaded state answerable at all (§11-1), and the reference + // only ever populated it from the album loop — so a user who downloaded a + // release one track at a time produced rows the rule can never attribute, + // and their album stayed un-downloaded forever with no way to notice. The + // `/track/get` payload fetched above already carries the id, so there is + // no reason for any path to write NULL. + let resolved_album_id = track.album.as_ref().map(|album| album.id.clone()); + + // Addendum B.2: extension derives from the RESPONSE's served format. + let extension = purchase_extension(grant.format_id, &grant.mime_type); + let target = purchase_track_target( + destination, &artist_name, &album_title, quality_dir, + track.media_number.unwrap_or(1), track.track_number, &track.title, - stream.format_id, - &stream.mime_type, - destination, + extension, + ); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create destination folder: {}", e))?; + } + + // Addendum B.3: write `.part`, then rename — no overwrite preflight. A + // failed or truncated fetch leaves NO `.part` behind; a final file that + // already exists is never touched by a failure. + let temp_path = target.with_extension(format!("{}.part", extension)); + let staged = QobuzClient::download_audio_to_path(&grant.url, &temp_path) + .await + .and_then(|_| verify_served_container(&temp_path, extension)); + if let Err(e) = staged { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + std::fs::rename(&temp_path, &target).map_err(|e| format!("Failed to finalize file: {}", e))?; + let file_path = target.to_string_lossy().to_string(); + + // §14.1, additive: tag AFTER the file is on disk and registered, so the + // deliverable is already durable and a tagging failure can only cost tags. + // `None` reproduces reference behaviour exactly (no tags written at all). + if let Some(ctx) = ctx { + if tags_supported(extension) { + tag_downloaded_file(&file_path, &track, ctx); + } else { + log::info!("[Purchases] {file_path}: {extension} container left untagged by design"); + } + } + + let metadata = std::fs::metadata(&target) + .map_err(|error| format!("Failed to inspect downloaded file: {error}"))?; + let modified_ns = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_nanos()); + let sample_rate_hz = (grant.sampling_rate > 0.0).then(|| { + (grant.sampling_rate * 1000.0) + .round() + .clamp(0.0, u32::MAX as f64) as u32 + }); + let last_verified_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .min(i64::MAX as u64) as i64; + + Ok(PurchaseDownloadReceipt { + file_path, + album_id: resolved_album_id, + mime_type: grant.mime_type, + container: extension.to_string(), + sample_rate_hz, + bit_depth: grant.bit_depth, + channels: None, + file_size: metadata.len(), + modified_ns, + last_verified_at, + }) +} + +/// Compatibility wrapper for historical callers. New copy-aware album flows +/// use [`download_purchase_track_file_only`] and promote the legacy registry +/// only after one concrete copy covers the full album. +#[allow(clippy::too_many_arguments)] +pub async fn download_purchase_track( + client: &QobuzClient, + db: &LibraryDatabase, + track_id: u64, + album_id: Option<&str>, + format_id: u32, + streaming: bool, + destination: &str, + quality_dir: &str, + ctx: Option<&PurchaseAlbumContext>, +) -> Result<String, String> { + let destination = PurchaseDownloadDestination::NewRoot { + root: destination.to_string(), + resolved_album_folder: String::new(), + }; + let receipt = download_purchase_track_file_only( + client, + track_id, + format_id, + streaming, + &destination, + quality_dir, + ctx, ) + .await?; + db.mark_purchase_downloaded( + track_id as i64, + album_id.or(receipt.album_id.as_deref()), + &receipt.file_path, + format_id as i64, + ) + .map_err(|error| error.to_string())?; + Ok(receipt.file_path) } #[cfg(test)] mod tests { use super::*; - use qbz_models::{Artist, AlbumSummary, PurchaseAlbum, PurchaseTrack, SearchResultsPage}; + use qbz_models::{AlbumSummary, Artist, PurchaseAlbum, PurchaseTrack, SearchResultsPage}; fn album(title: &str, artist: &str) -> PurchaseAlbum { PurchaseAlbum { @@ -712,6 +1601,11 @@ mod tests { image: Default::default(), label: None, genre: None, + artist: None, + version: None, + streamable: None, + streamable_at: None, + release_date_stream: None, }), ..Default::default() } @@ -737,7 +1631,10 @@ mod tests { #[test] fn filter_matches_album_title_case_insensitive() { let resp = response( - vec![album("Kind of Blue", "Miles Davis"), album("Thriller", "Michael Jackson")], + vec![ + album("Kind of Blue", "Miles Davis"), + album("Thriller", "Michael Jackson"), + ], vec![], ); let out = filter_purchase_response(resp, "BLUE"); @@ -810,6 +1707,222 @@ mod tests { assert_eq!(out.tracks.total, 0); } + // ── The entitlement menu (contract 2026-09-01 §1.4) ─────────────────── + + #[test] + fn entitlement_formats_put_the_highest_quality_first() { + let ids: Vec<u32> = entitlement_formats(&[5, 55, 6]) + .iter() + .map(|f| f.id) + .collect(); + assert_eq!(ids, vec![55, 6, 5]); + let ids: Vec<u32> = entitlement_formats(&[7, 6, 5]) + .iter() + .map(|f| f.id) + .collect(); + assert_eq!( + ids, + vec![7, 6, 5], + "the live hi-res wire order is already right" + ); + let ids: Vec<u32> = entitlement_formats(&[55, 56]) + .iter() + .map(|f| f.id) + .collect(); + assert_eq!(ids, vec![56, 55]); + } + + #[test] + fn entitlement_formats_dedupe_and_still_offer_an_unknown_id() { + let fmts = entitlement_formats(&[7, 7, 99]); + assert_eq!(fmts.len(), 2); + assert_eq!(fmts[0].id, 7); + assert_eq!( + fmts[1].id, 99, + "an id this build does not know sorts last, never disappears" + ); + assert_eq!(fmts[1].label, "[Format 99]"); + assert!(fmts[1].bit_depth.is_none() && fmts[1].sampling_rate.is_none()); + } + + #[test] + fn the_dsd_options_are_named_by_what_the_cdn_serves() { + let dsd64 = format_option(55); + assert_eq!(dsd64.label, "[DSF][DSD64]"); + assert_eq!(dsd64.bit_depth, Some(1)); + let dsd128 = format_option(56); + assert_eq!(dsd128.label, "[DSF][DSD128]"); + assert!(dsd128.sampling_rate.unwrap() > dsd64.sampling_rate.unwrap()); + // The FLAC/MP3 labels are frozen — they are on-disk folder names. + assert_eq!(format_option(27).label, "[FLAC][24-bit,192kHz]"); + assert_eq!(format_option(7).label, "[FLAC][24-bit,96kHz]"); + assert_eq!(format_option(6).label, "[FLAC][16-bit,44.1kHz]"); + assert_eq!(format_option(5).label, "[MP3][320kbps]"); + } + + #[test] + fn a_dsd64_purchase_streaming_at_cd_quality_adds_lower_stream_formats() { + // Megadeth, Rust In Peace on the live account: hires:false, 16/44.1 + // catalog, downloadable_format_ids [55]. The synthesis would say "CD + // FLAC + MP3"; the entitlement says DSD64 and nothing else. + let album = album_with(false, Some(44.1)); + let menu = purchase_formats(&album, &[55]); + let ids: Vec<(u32, bool)> = menu.iter().map(|f| (f.id, f.streaming)).collect(); + assert_eq!( + ids, + vec![(55, false), (6, true), (5, true)], + "the purchase on top through the purchase grant; the CD/MP3 the \ + catalog streams below it, through the streaming grant" + ); + assert_eq!(menu[0].label, "[DSF][DSD64]"); + } + + #[test] + fn the_streaming_ladder_only_reaches_below_the_purchase_and_never_duplicates_it() { + // A hi-res FLAC purchase already owns 7/6/5: nothing to add, and 27 + // (above the purchase) is never offered even on a 24/192 catalog. + let album = album_with(true, Some(192.0)); + let menu = purchase_formats(&album, &[7, 6, 5]); + let ids: Vec<(u32, bool)> = menu.iter().map(|f| (f.id, f.streaming)).collect(); + assert_eq!(ids, vec![(7, false), (6, false), (5, false)]); + // A DSD128 purchase on a catalog that STREAMS hi-res: 27/7/6/5 all + // sit below it. + let hires_streams = album_streaming(true, Some(192.0), true); + let menu = purchase_formats(&hires_streams, &[56]); + let ids: Vec<(u32, bool)> = menu.iter().map(|f| (f.id, f.streaming)).collect(); + assert_eq!( + ids, + vec![(56, false), (27, true), (7, true), (6, true), (5, true)] + ); + // The same purchase on a catalog that streams CD only (the live DSD + // album: hires:true, hires_streamable:false): no 27/7 — the CDN would + // serve 16/44.1 under a "24-bit,192kHz" folder name. + let cd_streams = album_streaming(true, Some(352.8), false); + let menu = purchase_formats(&cd_streams, &[56]); + let ids: Vec<(u32, bool)> = menu.iter().map(|f| (f.id, f.streaming)).collect(); + assert_eq!(ids, vec![(56, false), (6, true), (5, true)]); + } + + #[test] + fn a_purchase_only_album_offers_only_its_entitled_master() { + let mut album = album_streaming(true, Some(192.0), true); + album.streamable = Some(false); + let menu = purchase_formats(&album, &[56]); + let ids: Vec<(u32, bool)> = menu.iter().map(|f| (f.id, f.streaming)).collect(); + assert_eq!(ids, vec![(56, false)]); + } + + #[test] + fn an_album_without_entitlement_ids_falls_back_to_the_catalog_synthesis() { + let album = album_with(false, Some(44.1)); + let ids: Vec<u32> = purchase_formats(&album, &[]).iter().map(|f| f.id).collect(); + assert_eq!(ids, vec![6, 5], "the pre-2026-09-01 ladder still stands in"); + } + + #[test] + fn entitlement_format_ids_are_read_off_the_listed_album() { + let purchases = PurchaseResponse { + albums: SearchResultsPage { + items: vec![PurchaseAlbum { + id: "dsd".to_string(), + downloadable_format_ids: vec![56], + ..Default::default() + }], + ..Default::default() + }, + tracks: SearchResultsPage::default(), + }; + assert_eq!(entitlement_format_ids(&purchases, "dsd"), vec![56]); + assert!(entitlement_format_ids(&purchases, "not-listed").is_empty()); + } + + #[test] + fn build_purchase_album_carries_the_entitlement_to_the_album_and_its_tracks() { + let album = catalog_album("dsd", true, &[10, 20]); + let purchases = PurchaseResponse { + albums: SearchResultsPage { + items: vec![PurchaseAlbum { + id: "dsd".to_string(), + downloadable_format_ids: vec![55], + hires_purchased: true, + ..Default::default() + }], + ..Default::default() + }, + tracks: SearchResultsPage::default(), + }; + let built = build_purchase_album(&album, &purchases, &HashSet::new(), &HashMap::new()); + assert_eq!(built.downloadable_format_ids, vec![55]); + assert!(built.hires_purchased); + for t in &built.tracks.unwrap().items { + assert_eq!(t.downloadable_format_ids, vec![55]); + } + } + + // ── One folder per album, whoever performs the track ────────────────── + + #[test] + fn the_artist_folder_is_the_album_artist_not_the_performer() { + assert_eq!( + path_artist(Some("Various Artists"), Some("Ben Webster")), + "Various Artists" + ); + assert_eq!(path_artist(Some(" "), Some("Ben Webster")), "Ben Webster"); + assert_eq!(path_artist(None, Some("Ben Webster")), "Ben Webster"); + assert_eq!(path_artist(None, None), "Unknown Artist"); + } + + // ── The served container decides the extension ──────────────────────── + + #[test] + fn purchase_extension_follows_the_served_container() { + assert_eq!(purchase_extension(55, "audio/x-dsf"), "dsf"); + assert_eq!(purchase_extension(56, "audio/x-dsf"), "dsf"); + assert_eq!( + purchase_extension(56, ""), + "dsf", + "a DSD id with no MIME is still DSF" + ); + assert_eq!(purchase_extension(7, "audio/x-dff"), "dff"); + assert_eq!(purchase_extension(7, "audio/flac"), "flac"); + assert_eq!(purchase_extension(7, ""), "flac"); + assert_eq!( + purchase_extension(5, "audio/flac"), + "mp3", + "id 5 wins as it always did" + ); + assert_eq!(purchase_extension(6, "audio/mpeg"), "mp3"); + assert_eq!(purchase_extension(7, "audio/wav"), "wav"); + assert_eq!(purchase_extension(7, "audio/x-aiff"), "aiff"); + assert_eq!(purchase_extension(7, "audio/mp4"), "m4a"); + } + + #[test] + fn served_magic_is_checked_per_container() { + assert!(served_magic_matches("dsf", b"DSD \x1c\x00\x00\x00")); + assert!(!served_magic_matches("dsf", b"fLaC\x00\x00")); + assert!(served_magic_matches("dff", b"FRM8\x00\x00")); + assert!(served_magic_matches("flac", b"fLaC\x00\x00")); + assert!( + served_magic_matches("flac", b"ID3\x04\x00"), + "an ID3v2 prefix on a FLAC is legal" + ); + assert!(served_magic_matches("mp3", b"ID3\x03")); + assert!(served_magic_matches("mp3", &[0xFF, 0xFB, 0x90])); + assert!(served_magic_matches("wav", b"RIFF\x00\x00\x00\x00WAVE")); + assert!(served_magic_matches("aiff", b"FORM\x00\x00")); + assert!(served_magic_matches("m4a", b"\x00\x00\x00\x18ftypM4A ")); + assert!(!served_magic_matches("m4a", b"fLaC\x00\x00\x00\x00")); + assert!( + !served_magic_matches("flac", b"<!DOCTYPE html>"), + "an error page is not a master" + ); + assert!( + served_magic_matches("ogg", b"whatever"), + "unknown containers are not judged" + ); + } + // ── Slice 4: synth_formats ─────────────────────────────────────────── // `Album` has no `Default`; build the minimal shape from JSON (relying on @@ -822,6 +1935,14 @@ mod tests { serde_json::from_str(&json).expect("minimal Album JSON deserializes") } + /// Like `album_with`, with the catalog's own word on whether hi-res + /// STREAMS (`hires_streamable`), which the streaming ladder reads. + fn album_streaming(hires: bool, max_sr: Option<f64>, hires_streamable: bool) -> Album { + let mut album = album_with(hires, max_sr); + album.hires_streamable = hires_streamable; + album + } + #[test] fn synth_formats_24_192_yields_four_options_in_order() { // hires + max_sr > 96 → all four, highest first, index 0 = the 192k default. @@ -835,9 +1956,18 @@ mod tests { assert_eq!(fmts[2].label, "[FLAC][16-bit,44.1kHz]"); assert_eq!(fmts[3].label, "[MP3][320kbps]"); // bit_depth / sampling_rate carried verbatim. - assert_eq!((fmts[0].bit_depth, fmts[0].sampling_rate), (Some(24), Some(192.0))); - assert_eq!((fmts[1].bit_depth, fmts[1].sampling_rate), (Some(24), Some(96.0))); - assert_eq!((fmts[2].bit_depth, fmts[2].sampling_rate), (Some(16), Some(44.1))); + assert_eq!( + (fmts[0].bit_depth, fmts[0].sampling_rate), + (Some(24), Some(192.0)) + ); + assert_eq!( + (fmts[1].bit_depth, fmts[1].sampling_rate), + (Some(24), Some(96.0)) + ); + assert_eq!( + (fmts[2].bit_depth, fmts[2].sampling_rate), + (Some(16), Some(44.1)) + ); assert_eq!((fmts[3].bit_depth, fmts[3].sampling_rate), (None, None)); // default-select is index 0 (highest available). assert_eq!(fmts[0].id, 27); @@ -878,6 +2008,21 @@ mod tests { } } + /// An album that declares how many tracks it has — the denominator the + /// registry-backed downloaded rule needs (§11-1). + fn album_with_tracks(id: &str, tracks_count: u32) -> PurchaseAlbum { + PurchaseAlbum { + id: id.to_string(), + tracks_count: Some(tracks_count), + ..Default::default() + } + } + + /// Registry counts: purchase album id → DISTINCT downloaded track count. + fn reg(pairs: &[(&str, u32)]) -> HashMap<String, u32> { + pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() + } + fn track_for_album(id: u64, album_id: &str) -> PurchaseTrack { PurchaseTrack { id, @@ -887,6 +2032,11 @@ mod tests { image: Default::default(), label: None, genre: None, + artist: None, + version: None, + streamable: None, + streamable_at: None, + release_date_stream: None, }), ..Default::default() } @@ -898,12 +2048,15 @@ mod tests { #[test] fn apply_flags_marks_track_downloaded_and_records_format_ids() { - let mut resp = response(vec![], vec![track_for_album(10, "a1"), track_for_album(20, "a1")]); + let mut resp = response( + vec![], + vec![track_for_album(10, "a1"), track_for_album(20, "a1")], + ); let downloaded = dl_ids(&[10]); let mut format_map: HashMap<i64, Vec<u32>> = HashMap::new(); format_map.insert(10, vec![27, 6]); - apply_download_flags(&mut resp, &downloaded, &format_map); + apply_download_flags(&mut resp, &downloaded, &format_map, &HashMap::new()); assert!(resp.tracks.items[0].downloaded); assert_eq!(resp.tracks.items[0].downloaded_format_ids, vec![27, 6]); @@ -912,48 +2065,72 @@ mod tests { assert!(resp.tracks.items[1].downloaded_format_ids.is_empty()); } + // The three album tests below were rewritten on 2026-08-16. They used to + // assert the reference's nested-tracks predicate (every track whose + // `album.id` matches must be in `downloaded_ids`). That predicate was + // replaced under contract §11-1 because the screen that uses it never + // receives a tracks page, so it could only ever answer `false`. The INTENT + // of each test is preserved — complete, partial, unknown — but the source of + // truth is now the local registry. + #[test] - fn apply_flags_album_downloaded_when_all_nested_track_ids_present() { - // Every track whose album.id == "a1" is in dlIds → album downloaded. + fn apply_flags_album_downloaded_when_the_registry_covers_it() { let mut resp = response( - vec![album_id("a1")], + vec![album_with_tracks("a1", 2)], vec![track_for_album(10, "a1"), track_for_album(20, "a1")], ); - apply_download_flags(&mut resp, &dl_ids(&[10, 20]), &HashMap::new()); + apply_download_flags( + &mut resp, + &dl_ids(&[10, 20]), + &HashMap::new(), + ®(&[("a1", 2)]), + ); assert!(resp.albums.items[0].downloaded); } #[test] fn apply_flags_album_not_downloaded_when_partially_owned() { - // One of the two album tracks missing from dlIds → album NOT downloaded. let mut resp = response( - vec![album_id("a1")], + vec![album_with_tracks("a1", 2)], vec![track_for_album(10, "a1"), track_for_album(20, "a1")], ); - apply_download_flags(&mut resp, &dl_ids(&[10]), &HashMap::new()); + apply_download_flags( + &mut resp, + &dl_ids(&[10]), + &HashMap::new(), + ®(&[("a1", 1)]), + ); assert!(!resp.albums.items[0].downloaded); } #[test] - fn apply_flags_album_with_no_matching_tracks_is_not_downloaded() { - // No tracks reference this album (empty set rule) → false, never panic. - let mut resp = response(vec![album_id("a1")], vec![track_for_album(10, "other")]); - apply_download_flags(&mut resp, &dl_ids(&[10]), &HashMap::new()); + fn apply_flags_album_absent_from_the_registry_is_not_downloaded() { + // Nothing of this album has been downloaded → false, never panic. + let mut resp = response( + vec![album_with_tracks("a1", 2)], + vec![track_for_album(10, "other")], + ); + apply_download_flags( + &mut resp, + &dl_ids(&[10]), + &HashMap::new(), + ®(&[("other", 1)]), + ); assert!(!resp.albums.items[0].downloaded); } #[test] fn apply_flags_frontend_overrides_stale_backend_downloaded() { - // Backend wrongly set downloaded=true; frontend recomputes to false. + // Backend wrongly set downloaded=true; the annotation recomputes to false. let mut track = track_for_album(10, "a1"); track.downloaded = true; track.downloaded_format_ids = vec![99]; - let mut backend_true_album = album_id("a1"); + let mut backend_true_album = album_with_tracks("a1", 1); backend_true_album.downloaded = true; let mut resp = response(vec![backend_true_album], vec![track]); - // dlIds empty → both must be overridden to false / cleared. - apply_download_flags(&mut resp, &dl_ids(&[]), &HashMap::new()); + // Nothing downloaded → both must be overridden to false / cleared. + apply_download_flags(&mut resp, &dl_ids(&[]), &HashMap::new(), &HashMap::new()); assert!(!resp.tracks.items[0].downloaded); assert!(resp.tracks.items[0].downloaded_format_ids.is_empty()); assert!(!resp.albums.items[0].downloaded); @@ -994,8 +2171,7 @@ mod tests { meta.purchased_at = Some(1_700_000_000); let purchases = response(vec![meta], vec![]); - let detail = - build_purchase_album(&album, &purchases, &dl_ids(&[]), &HashMap::new()); + let detail = build_purchase_album(&album, &purchases, &dl_ids(&[]), &HashMap::new()); assert_eq!(detail.id, "alb1"); assert!(detail.downloadable); @@ -1007,7 +2183,10 @@ mod tests { assert_eq!(tracks.total, 3); assert_eq!(tracks.limit, 3); // per-track purchased_at copies the album-level meta. - assert!(tracks.items.iter().all(|t| t.purchased_at == Some(1_700_000_000))); + assert!(tracks + .items + .iter() + .all(|t| t.purchased_at == Some(1_700_000_000))); } #[test] @@ -1015,8 +2194,7 @@ mod tests { // No matching purchase meta → downloadable defaults TRUE, purchased_at None. let album = catalog_album("missing", false, &[1]); let purchases = response(vec![album_id("other")], vec![]); - let detail = - build_purchase_album(&album, &purchases, &dl_ids(&[]), &HashMap::new()); + let detail = build_purchase_album(&album, &purchases, &dl_ids(&[]), &HashMap::new()); // The catalog `downloadable:false` is IGNORED — the value comes from the // purchase meta (absent → unwrap_or(true)). assert!(detail.downloadable); @@ -1031,8 +2209,7 @@ mod tests { format_map.insert(10, vec![7]); // Both nested track ids owned → album downloaded; per-track flags set. - let detail = - build_purchase_album(&album, &purchases, &dl_ids(&[10, 20]), &format_map); + let detail = build_purchase_album(&album, &purchases, &dl_ids(&[10, 20]), &format_map); assert!(detail.downloaded); let tracks = detail.tracks.unwrap(); assert!(tracks.items[0].downloaded); @@ -1046,8 +2223,7 @@ mod tests { let album = catalog_album("alb1", true, &[10, 20]); let purchases = response(vec![album_id("alb1")], vec![]); // Only one of two nested tracks owned → album NOT downloaded (all-rule). - let detail = - build_purchase_album(&album, &purchases, &dl_ids(&[10]), &HashMap::new()); + let detail = build_purchase_album(&album, &purchases, &dl_ids(&[10]), &HashMap::new()); assert!(!detail.downloaded); let tracks = detail.tracks.unwrap(); assert!(tracks.items[0].downloaded); @@ -1059,8 +2235,7 @@ mod tests { // No nested tracks → empty-set rule → album downloaded = false. let album = catalog_album("alb1", true, &[]); let purchases = response(vec![album_id("alb1")], vec![]); - let detail = - build_purchase_album(&album, &purchases, &dl_ids(&[]), &HashMap::new()); + let detail = build_purchase_album(&album, &purchases, &dl_ids(&[]), &HashMap::new()); assert!(!detail.downloaded); assert_eq!(detail.tracks.unwrap().items.len(), 0); } @@ -1104,7 +2279,10 @@ mod tests { // sanitize: "[FLAC][24-bit,96kHz]" → brackets→`-`, collapsed/trimmed. let expected = PathBuf::from("/music") .join("Miles Davis") - .join(format!("Kind of Blue {}", sanitize_filename("[FLAC][24-bit,96kHz]"))) + .join(format!( + "Kind of Blue {}", + sanitize_filename("[FLAC][24-bit,96kHz]") + )) .join("03 - So What.flac"); assert_eq!(p, expected); // zero-padding is two digits. @@ -1114,13 +2292,25 @@ mod tests { #[test] fn target_path_no_quality_dir_uses_bare_album_folder() { let p = target_path("/d", "Artist", "Album", "", 1, "Title", "flac"); - assert_eq!(p, PathBuf::from("/d").join("Artist").join("Album").join("01 - Title.flac")); + assert_eq!( + p, + PathBuf::from("/d") + .join("Artist") + .join("Album") + .join("01 - Title.flac") + ); } #[test] fn target_path_zero_track_number_drops_number_prefix() { let p = target_path("/d", "Artist", "Album", "", 0, "Title", "mp3"); - assert_eq!(p, PathBuf::from("/d").join("Artist").join("Album").join("Title.mp3")); + assert_eq!( + p, + PathBuf::from("/d") + .join("Artist") + .join("Album") + .join("Title.mp3") + ); } #[test] @@ -1128,10 +2318,21 @@ mod tests { // The "Unknown Artist"/"Singles" fallbacks are applied by the caller; // here verify they round-trip through sanitize unchanged (ASCII alnum + // spaces survive). - let p = target_path("/d", "Unknown Artist", "Singles", "", 0, "Loose Track", "flac"); + let p = target_path( + "/d", + "Unknown Artist", + "Singles", + "", + 0, + "Loose Track", + "flac", + ); assert_eq!( p, - PathBuf::from("/d").join("Unknown Artist").join("Singles").join("Loose Track.flac") + PathBuf::from("/d") + .join("Unknown Artist") + .join("Singles") + .join("Loose Track.flac") ); } @@ -1152,6 +2353,7 @@ mod tests { let path = write_and_register_track( &db, /*track_id*/ 4242, + /*album_id*/ Some("alb-4242"), /*requested_format_id*/ 27, &data, "Miles Davis", @@ -1170,12 +2372,18 @@ mod tests { let final_path = PathBuf::from(&path); assert!(final_path.exists(), "final file must exist"); assert!(final_path.to_string_lossy().ends_with("05 - So What.flac")); - assert!(!final_path.with_extension("flac.part").exists(), "`.part` removed after rename"); + assert!( + !final_path.with_extension("flac.part").exists(), + "`.part` removed after rename" + ); assert_eq!(std::fs::read(&final_path).unwrap(), data); // Registry recorded the REQUESTED format (27), NOT the served 6 (B.2). let formats = db.get_downloaded_purchase_formats().unwrap(); - assert!(formats.contains(&(4242, 27)), "registry keys off REQUESTED format: {formats:?}"); + assert!( + formats.contains(&(4242, 27)), + "registry keys off REQUESTED format: {formats:?}" + ); assert!(!formats.iter().any(|&(tid, fid)| tid == 4242 && fid == 6)); } @@ -1190,6 +2398,7 @@ mod tests { let path = write_and_register_track( &db, 1, + /*album_id*/ None, /*requested*/ 7, b"x", "A", @@ -1202,9 +2411,15 @@ mod tests { dest.to_str().unwrap(), ) .unwrap(); - assert!(path.ends_with("01 - T.mp3"), "served mp3 → .mp3 extension: {path}"); + assert!( + path.ends_with("01 - T.mp3"), + "served mp3 → .mp3 extension: {path}" + ); let formats = db.get_downloaded_purchase_formats().unwrap(); - assert!(formats.contains(&(1, 7)), "requested format 7 recorded: {formats:?}"); + assert!( + formats.contains(&(1, 7)), + "requested format 7 recorded: {formats:?}" + ); } #[test] @@ -1216,14 +2431,38 @@ mod tests { let dest = tmp.path().join("dl"); let first = write_and_register_track( - &db, 9, 6, b"old", "A", "Alb", "", 2, "Song", 6, "audio/flac", dest.to_str().unwrap(), + &db, + 9, + None, + 6, + b"old", + "A", + "Alb", + "", + 2, + "Song", + 6, + "audio/flac", + dest.to_str().unwrap(), ) .unwrap(); assert_eq!(std::fs::read(&first).unwrap(), b"old"); // Second write to the SAME deterministic path with new bytes overwrites. let second = write_and_register_track( - &db, 9, 6, b"new", "A", "Alb", "", 2, "Song", 6, "audio/flac", dest.to_str().unwrap(), + &db, + 9, + None, + 6, + b"new", + "A", + "Alb", + "", + 2, + "Song", + 6, + "audio/flac", + dest.to_str().unwrap(), ) .unwrap(); assert_eq!(first, second, "same deterministic target path"); @@ -1256,14 +2495,518 @@ mod tests { let dest = tmp.path().join("downloads"); let res = write_and_register_track( - &db, 77, 6, b"bytes", "A", "Alb", "", 1, "Song", 6, "audio/flac", dest.to_str().unwrap(), + &db, + 77, + None, + 6, + b"bytes", + "A", + "Alb", + "", + 1, + "Song", + 6, + "audio/flac", + dest.to_str().unwrap(), ); // Registry INSERT failed (no such table) → Err. - assert!(res.is_err(), "registry write failure must surface as Err: {res:?}"); + assert!( + res.is_err(), + "registry write failure must surface as Err: {res:?}" + ); // ...but the file write happened BEFORE the registry write → orphaned file. let orphan = dest.join("A").join("Alb").join("01 - Song.flac"); - assert!(orphan.exists(), "file left on disk after registry failure (orphaned, B.1)"); - assert!(!orphan.with_extension("flac.part").exists(), "`.part` already renamed away"); + assert!( + orphan.exists(), + "file left on disk after registry failure (orphaned, B.1)" + ); + assert!( + !orphan.with_extension("flac.part").exists(), + "`.part` already renamed away" + ); + } +} + +// ─── §12-3: the path builder and sanitizer, against the inputs that break them ── +#[cfg(test)] +mod path_contract_tests { + use super::*; + use crate::metadata::sanitize_filename; + + #[test] + fn continuing_a_copy_writes_directly_into_its_existing_leaf() { + let destination = PurchaseDownloadDestination::ExistingAlbumFolder( + "/backup/Album [DSF][DSD64]".to_string(), + ); + let target = purchase_track_target( + &destination, + "Ignored Artist", + "Ignored Album", + "[DSF][DSD64]", + 1, + 3, + "Song", + "dsf", + ); + assert_eq!( + target, + PathBuf::from("/backup/Album [DSF][DSD64]/03 - Song.dsf") + ); + } + + #[test] + fn later_discs_cannot_collide_with_disc_one() { + let destination = PurchaseDownloadDestination::ExistingAlbumFolder("/music/Album".into()); + let first = + purchase_track_target(&destination, "Artist", "Album", "", 1, 1, "Opening", "flac"); + let second = + purchase_track_target(&destination, "Artist", "Album", "", 2, 1, "Opening", "flac"); + assert_ne!(first, second); + assert_eq!(second.file_name().unwrap(), "02-01 - Opening.flac"); + } + + /// §4.5. `char::is_alphanumeric` is Unicode-aware, so accented Latin, Greek, + /// Cyrillic and CJK all SURVIVE sanitization — only non-ASCII + /// NON-alphanumerics (em dash, curly quotes, ™, ♭) become `-`. + /// + /// This matters far beyond aesthetics: the library-to-registry join is exact + /// `file_path` string equality, so an implementer who "knows" that non-ASCII + /// becomes dashes writes different paths for most non-English releases and + /// the gold purchase badge silently stops working for them. + #[test] + fn sanitize_keeps_unicode_alphanumerics() { + assert_eq!(sanitize_filename("Björk"), "Björk"); + assert_eq!(sanitize_filename("Café Tacvba"), "Café Tacvba"); + assert_eq!(sanitize_filename("Ελλάδα"), "Ελλάδα"); + assert_eq!(sanitize_filename("Кино"), "Кино"); + assert_eq!(sanitize_filename("日本語"), "日本語"); + } + + /// The other half of the same rule: non-ASCII punctuation does NOT survive. + #[test] + fn sanitize_replaces_non_ascii_punctuation() { + // Em dash and curly apostrophe are non-ASCII and non-alphanumeric. + assert_eq!(sanitize_filename("A—B"), "A-B"); + assert_eq!(sanitize_filename("Don’t"), "Don-t"); + // Consecutive dashes collapse, ends are trimmed. + assert_eq!(sanitize_filename("™™Hits™™"), "Hits"); + } + + /// Filesystem-invalid ASCII still becomes `-`; ASCII brackets do NOT. + /// + /// The bracket half is load-bearing and was mis-documented: the quality + /// folder suffix is literally `[FLAC][24-bit,96kHz]`, and `[` is ASCII, so + /// it is kept. "Fixing" the brackets would relocate every downloaded album. + #[test] + fn sanitize_keeps_ascii_brackets_but_replaces_path_separators() { + assert_eq!( + sanitize_filename("[FLAC][24-bit,96kHz]"), + "[FLAC][24-bit,96kHz]" + ); + assert_eq!(sanitize_filename("AC/DC"), "AC-DC"); + assert_eq!(sanitize_filename("a:b*c?d\"e<f>g|h"), "a-b-c-d-e-f-g-h"); + } + + /// §4.5 / §10-E: the 200 cap is in BYTES and `String::truncate` panics on a + /// non-char-boundary index — reachable exactly BECAUSE multibyte characters + /// survive the mapping above. + /// + /// Which characters actually crash is worth stating precisely, because the + /// obvious guess is wrong. 200 is even, so a run of TWO-byte characters + /// (`é`, `Ω`, Cyrillic) lands on a boundary and truncates fine. The crash + /// belongs to the THREE-byte class — CJK and most of the BMP beyond Latin + /// and Cyrillic — where 200 % 3 == 2 always lands mid-character. Measured, + /// not reasoned: `'本'` and `'日'` panic under the old code, the rest do not. + /// The two-byte cases are kept here anyway as regression cover for the + /// boundary walk itself. + #[test] + fn sanitize_does_not_panic_truncating_multibyte_titles() { + // '本' and '日' are the ones that panicked before the fix. + for ch in ['é', 'Ω', '本', 'ы', '日'] { + let long: String = std::iter::repeat(ch).take(400).collect(); + let out = sanitize_filename(&long); + assert!(out.len() <= 200, "cap is bytes: {} bytes", out.len()); + assert!(!out.is_empty()); + // Still valid UTF-8 with no replacement damage: every char is the input char. + assert!(out.chars().all(|c| c == ch)); + } + } + + /// A byte cap must not silently become a char cap: pure ASCII is unchanged, + /// so already-downloaded libraries keep their paths. + #[test] + fn sanitize_ascii_cap_is_exactly_200() { + let long = "a".repeat(400); + assert_eq!(sanitize_filename(&long).len(), 200); + } + + #[test] + fn sanitize_empty_result_falls_back() { + assert_eq!(sanitize_filename("—"), "track"); + assert_eq!(sanitize_filename(""), "track"); + } + + /// §4.4: an EMPTY quality dir drops the album folder's trailing space too. + /// Copying the formula literally yields `"Album "` and a different directory. + #[test] + fn target_path_empty_quality_dir_has_no_trailing_space() { + let with_quality = + target_path("/d", "A", "Album", "[FLAC][16-bit,44.1kHz]", 1, "T", "flac"); + let without = target_path("/d", "A", "Album", "", 1, "T", "flac"); + + assert_eq!( + without, + std::path::PathBuf::from("/d/A/Album/01 - T.flac"), + "no trailing space when the quality dir is empty" + ); + assert_eq!( + with_quality, + std::path::PathBuf::from("/d/A/Album [FLAC][16-bit,44.1kHz]/01 - T.flac") + ); + } + + /// §4.4, recorded as a KNOWN reference behaviour rather than a bug to fix: + /// `media_number` never reaches the path, so two discs of one release collide + /// on track number and `fs::rename` overwrites silently. Disc 2 track 1 + /// lands on top of disc 1 track 1. + /// + /// This test exists to make the collision explicit and to fail loudly if + /// someone changes the scheme without deciding to — the scheme is what the + /// reference shipped and what existing users' folders look like. + #[test] + fn target_path_multi_disc_collides_on_track_number() { + let disc1 = target_path("/d", "A", "Album", "", 1, "Opening", "flac"); + let disc2 = target_path("/d", "A", "Album", "", 1, "Opening", "flac"); + assert_eq!( + disc1, disc2, + "documented reference behaviour: no media_number in the path" + ); + + // Different titles still collide on the NUMBER prefix only, not the name, + // so the practical collision needs the same title as well. + let other_title = target_path("/d", "A", "Album", "", 1, "Reprise", "flac"); + assert_ne!(disc1, other_title); + } + + /// Non-ASCII flows through the whole builder, not just the sanitizer. + #[test] + fn target_path_preserves_unicode_end_to_end() { + let p = target_path( + "/music", + "Café Tacvba", + "Ré", + "[FLAC][24-bit,96kHz]", + 7, + "El Ciclón", + "flac", + ); + assert_eq!( + p, + std::path::PathBuf::from( + "/music/Café Tacvba/Ré [FLAC][24-bit,96kHz]/07 - El Ciclón.flac" + ) + ); + } + + /// §4.4: the extension comes from the SERVED format, the folder suffix from + /// the REQUESTED label — they are allowed to disagree, and do when Qobuz + /// downgrades a request. + #[test] + fn extension_follows_the_served_format_not_the_request() { + assert_eq!(purchase_extension(5, "audio/flac"), "mp3"); + assert_eq!(purchase_extension(27, "audio/mpeg"), "mp3"); + assert_eq!(purchase_extension(27, "audio/flac"), "flac"); + assert_eq!(purchase_extension(6, ""), "flac"); + } +} + +// ─── §11-1: the album-downloaded rule, which no UI test could reach ──────────── +#[cfg(test)] +mod album_downloaded_tests { + use super::*; + + fn counts(pairs: &[(&str, u32)]) -> HashMap<String, u32> { + pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() + } + + #[test] + fn album_is_downloaded_when_the_registry_covers_every_track() { + let c = counts(&[("alb1", 12)]); + assert!(album_downloaded_from_registry("alb1", Some(12), &c)); + // Over-count (same track in two formats is already de-duplicated by the + // query, but a stale row must not un-mark the album). + assert!(album_downloaded_from_registry("alb1", Some(10), &c)); + } + + #[test] + fn partial_downloads_do_not_mark_the_album() { + let c = counts(&[("alb1", 11)]); + assert!(!album_downloaded_from_registry("alb1", Some(12), &c)); + } + + #[test] + fn unknown_album_is_not_downloaded() { + let c = counts(&[("alb1", 12)]); + assert!(!album_downloaded_from_registry("other", Some(12), &c)); + } + + /// No denominator means the answer is NO. Showing a green mark for an album + /// that might be half on disk is the worse failure. + #[test] + fn absent_or_zero_track_count_is_never_downloaded() { + let c = counts(&[("alb1", 99)]); + assert!(!album_downloaded_from_registry("alb1", None, &c)); + assert!(!album_downloaded_from_registry("alb1", Some(0), &c)); + } + + /// The regression this rule exists to prevent: with the reference's + /// predicate, an albums-only response (which carries NO tracks page) pinned + /// every album to `false`. Here the registry answers instead. + #[test] + fn albums_only_response_can_still_report_downloaded() { + let mut response = PurchaseResponse { + albums: SearchResultsPage { + items: vec![PurchaseAlbum { + id: "alb1".to_string(), + tracks_count: Some(2), + ..Default::default() + }], + total: 1, + offset: 0, + limit: 500, + }, + // `?type=albums` omits the tracks key entirely — this is that shape. + tracks: SearchResultsPage { + items: vec![], + total: 0, + offset: 0, + limit: 0, + }, + }; + + apply_download_flags( + &mut response, + &HashSet::new(), + &HashMap::new(), + &counts(&[("alb1", 2)]), + ); + + assert!( + response.albums.items[0].downloaded, + "the registry knows this album is complete even with no tracks page" + ); + } + + #[test] + fn track_flags_are_still_format_scoped_and_registry_driven() { + let mut response = PurchaseResponse { + albums: SearchResultsPage { + items: vec![], + total: 0, + offset: 0, + limit: 0, + }, + tracks: SearchResultsPage { + items: vec![ + PurchaseTrack { + id: 10, + ..Default::default() + }, + PurchaseTrack { + id: 11, + ..Default::default() + }, + ], + total: 2, + offset: 0, + limit: 500, + }, + }; + + let downloaded: HashSet<i64> = [10i64].into_iter().collect(); + let mut formats: HashMap<i64, Vec<u32>> = HashMap::new(); + formats.insert(10, vec![6, 27]); + + apply_download_flags(&mut response, &downloaded, &formats, &HashMap::new()); + + assert!(response.tracks.items[0].downloaded); + assert_eq!(response.tracks.items[0].downloaded_format_ids, vec![6, 27]); + assert!(!response.tracks.items[1].downloaded); + assert!(response.tracks.items[1].downloaded_format_ids.is_empty()); + } +} + +// ─── §14: the scope-expansion helpers ────────────────────────────────────────── +#[cfg(test)] +mod scope_expansion_tests { + use super::*; + + #[test] + fn release_year_parses_only_a_real_leading_year() { + assert_eq!(parse_release_year(Some("2019-05-31")), Some(2019)); + assert_eq!(parse_release_year(Some("1971")), Some(1971)); + // A wrong year is worse than no year. + assert_eq!(parse_release_year(Some("n/a")), None); + assert_eq!(parse_release_year(Some("")), None); + assert_eq!(parse_release_year(Some("19-05-31")), None); + assert_eq!(parse_release_year(None), None); + } + + #[test] + fn goody_url_falls_back_through_every_known_spelling() { + use qbz_models::Goody; + + let original = Goody { + original_url: "https://o/a.pdf".into(), + url: "https://u/a.pdf".into(), + ..goody_blank() + }; + assert_eq!(original.best_url(), Some("https://o/a.pdf")); + + let only_url = Goody { + url: "https://u/a.pdf".into(), + ..goody_blank() + }; + assert_eq!(only_url.best_url(), Some("https://u/a.pdf")); + + let only_file_url = Goody { + file_url: Some("https://f/a.pdf".into()), + ..goody_blank() + }; + assert_eq!(only_file_url.best_url(), Some("https://f/a.pdf")); + + // Nothing usable → skip the item, never fail the album. + assert_eq!(goody_blank().best_url(), None); + let whitespace = Goody { + url: " ".into(), + ..goody_blank() + }; + assert_eq!(whitespace.best_url(), None); + } + + #[test] + fn goody_display_name_is_never_empty() { + use qbz_models::Goody; + + let named = Goody { + name: "Booklet".into(), + ..goody_blank() + }; + assert_eq!(named.display_name(), "Booklet"); + + let described = Goody { + description: Some("Liner notes".into()), + ..goody_blank() + }; + assert_eq!(described.display_name(), "Liner notes"); + + let bare = Goody { + id: 42, + ..goody_blank() + }; + assert_eq!(bare.display_name(), "Goody 42"); + } + + fn goody_blank() -> qbz_models::Goody { + qbz_models::Goody { + id: 0, + name: String::new(), + url: String::new(), + original_url: String::new(), + file_url: None, + file_format_id: None, + description: None, + } + } +} + +#[cfg(test)] +mod entitlement_index_tests { + use super::*; + use qbz_models::AlbumSummary; + + fn response(albums: Vec<PurchaseAlbum>, tracks: Vec<PurchaseTrack>) -> PurchaseResponse { + PurchaseResponse { + albums: SearchResultsPage { + total: albums.len() as u32, + items: albums, + ..Default::default() + }, + tracks: SearchResultsPage { + total: tracks.len() as u32, + items: tracks, + ..Default::default() + }, + } + } + + #[test] + fn exact_full_album_ownership_never_matches_a_similar_id() { + let index = PurchaseEntitlementIndex::from_response(&response( + vec![PurchaseAlbum { + id: "album-10".into(), + downloadable: true, + downloadable_format_ids: vec![55], + ..Default::default() + }], + vec![], + )); + assert!(matches!( + index.entitlement_for("album-10"), + PurchaseEntitlement::FullAlbum { .. } + )); + assert_eq!( + index.entitlement_for("album-1"), + PurchaseEntitlement::NotOwned + ); + } + + #[test] + fn track_ownership_is_not_promoted_to_album_ownership() { + let index = PurchaseEntitlementIndex::from_response(&response( + vec![], + vec![PurchaseTrack { + id: 7, + album: Some(AlbumSummary { + id: "album".into(), + title: String::new(), + image: Default::default(), + label: None, + genre: None, + artist: None, + version: None, + streamable: None, + streamable_at: None, + release_date_stream: None, + }), + ..Default::default() + }], + )); + assert!(matches!( + index.entitlement_for("album"), + PurchaseEntitlement::Tracks { track_ids } if track_ids == HashSet::from([7]) + )); + assert!(index.downloadable_album("album").is_none()); + } + + #[test] + fn new_download_requires_availability_and_explicit_formats() { + for (downloadable, formats, expected) in [ + (true, vec![55], true), + (false, vec![55], false), + (true, vec![], false), + ] { + let index = PurchaseEntitlementIndex::from_response(&response( + vec![PurchaseAlbum { + id: "album".into(), + downloadable, + downloadable_format_ids: formats, + ..Default::default() + }], + vec![], + )); + assert_eq!(index.downloadable_album("album").is_some(), expected); + } } } diff --git a/crates/qbz-offline-cache/tests/purchase_download_harness.rs b/crates/qbz-offline-cache/tests/purchase_download_harness.rs new file mode 100644 index 000000000..f1b212541 --- /dev/null +++ b/crates/qbz-offline-cache/tests/purchase_download_harness.rs @@ -0,0 +1,593 @@ +//! The Purchases download harness (contract §12, owner-approved 2026-08-16). +//! +//! # Why this file exists +//! +//! Nobody on this team can smoke-test Purchases. Qobuz does not sell it in the +//! owner's region, so their account returns `{total: 0, items: []}` forever and +//! the populated screens — and every line of the download pipeline — will first +//! run on a stranger's machine. The contract's §12 established that the Tauri +//! build shipped no harness either: its "mock backend at :8787" turned out to be +//! unserved `fetch` stubs behind `import.meta.env.DEV`, with no server, no +//! fixtures and no npm script. So there is nothing to port, and this is new work. +//! It is also the only thing that turns the feature from ship-blind into +//! ship-testable. +//! +//! # What it covers, and what it does NOT +//! +//! It exercises the DOWNLOAD half for real: a live local HTTP server stands in +//! for the Qobuz CDN, real bytes travel over a real socket through the real +//! client, and the result lands in a real temp directory and a real SQLite +//! database. Path building, the `.part`→rename dance, the registry write, tag +//! writing, cover files and goodies are genuinely executed rather than inspected. +//! +//! It does NOT cover the LIST half. `endpoints::BASE_URL` is a `const`, so +//! `getUserPurchases` cannot be pointed at a local server without changing +//! production code — and that is not worth doing, because the request shape is +//! already pinned by the signature and URL tests in `qbz-qobuz`, and the response +//! shapes by the deserializer tests in `qbz-models` against payloads captured +//! from a live account. Said plainly so nobody reads this file as proof of more +//! than it proves. +//! +//! The server is hand-rolled on `std::net::TcpListener` — about forty lines — +//! rather than pulling a mock-HTTP crate in for one test. + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc; +use std::thread; + +use qbz_library::LibraryDatabase; +use qbz_models::{Goody, Track}; +use qbz_offline_cache::purchases_service as svc; + +// ─── The fixture server ─────────────────────────────────────────────────────── + +/// A tiny HTTP/1.1 server serving a fixed route table. Also records the paths it +/// was actually asked for, so a test can assert what was REQUESTED and not +/// merely what came back. +struct FixtureServer { + base: String, + hits: mpsc::Receiver<String>, +} + +impl FixtureServer { + fn start(routes: HashMap<String, (u16, Vec<u8>)>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind an ephemeral port"); + let port = listener.local_addr().unwrap().port(); + let (tx, hits) = mpsc::channel(); + + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let routes = routes.clone(); + let tx = tx.clone(); + thread::spawn(move || serve_one(stream, &routes, &tx)); + } + }); + + FixtureServer { + base: format!("http://127.0.0.1:{port}"), + hits, + } + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base, path) + } + + fn requested_paths(&self) -> Vec<String> { + self.hits.try_iter().collect() + } +} + +fn serve_one( + mut stream: TcpStream, + routes: &HashMap<String, (u16, Vec<u8>)>, + tx: &mpsc::Sender<String>, +) { + let mut reader = BufReader::new(stream.try_clone().expect("clone the stream")); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + // Drain the headers so the client sees a well-formed exchange. + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) if line.trim().is_empty() => break, + Ok(_) => {} + Err(_) => break, + } + } + + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or("/") + .to_string(); + let _ = tx.send(path.clone()); + + // Route on the path WITHOUT its query string, so a test can register + // "/cdn/track.flac" and still be hit by a signed, query-carrying URL. + let bare = path.split('?').next().unwrap_or(&path); + let (status, body) = routes + .get(bare) + .cloned() + .unwrap_or((404, b"not found".to_vec())); + + let reason = if status == 200 { "OK" } else { "Error" }; + let head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); +} + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +/// A minimal but VALID FLAC stream: the `fLaC` marker plus a single, final +/// STREAMINFO block. It carries no audio frames, which is fine — the pipeline +/// only ever writes tags to it and never decodes it — and it is enough for lofty +/// to identify the container and create a Vorbis comment block. +fn minimal_flac() -> Vec<u8> { + let mut out = b"fLaC".to_vec(); + // Metadata block header: last-block flag (0x80) | type 0 (STREAMINFO), then + // a 24-bit big-endian length of 34. + out.extend_from_slice(&[0x80, 0x00, 0x00, 0x22]); + + let mut info = vec![0u8; 34]; + info[0..2].copy_from_slice(&4096u16.to_be_bytes()); // min block size + info[2..4].copy_from_slice(&4096u16.to_be_bytes()); // max block size + // Sample rate 44100 (20 bits), then channels-1 (3 bits) and bits-per-sample-1 + // (5 bits), packed across bytes 10..13 as the FLAC spec lays STREAMINFO out. + let sample_rate: u32 = 44_100; + info[10] = (sample_rate >> 12) as u8; + info[11] = ((sample_rate >> 4) & 0xFF) as u8; + info[12] = (((sample_rate & 0x0F) as u8) << 4) | (1 << 1); // ch-1 = 1 → stereo + info[13] = 0xF0; // bits-per-sample-1 = 15 → 16-bit + out.extend_from_slice(&info); + out +} + +/// A FLAC that arrives carrying an **ID3v2** tag in front of the `fLaC` marker. +/// +/// This is not a contrived shape — plenty of tooling stamps ID3v2 onto FLAC, and +/// lofty reads it — and it is the shape that exposes the worst failure mode this +/// pipeline had. See `a_flac_carrying_id3v2_is_still_tagged`. +fn flac_with_leading_id3v2() -> Vec<u8> { + // ID3v2.4 header: "ID3", version 4.0, no flags, then a SYNCSAFE size (7 bits + // per byte) covering the frames that follow. + let mut frame = b"TIT2".to_vec(); + let text = b"\x03OldTitle"; // 0x03 = UTF-8 encoding byte + frame.extend_from_slice(&(text.len() as u32).to_be_bytes()); + frame.extend_from_slice(&[0x00, 0x00]); // frame flags + frame.extend_from_slice(text); + + let size = frame.len() as u32; + let syncsafe = [ + ((size >> 21) & 0x7F) as u8, + ((size >> 14) & 0x7F) as u8, + ((size >> 7) & 0x7F) as u8, + (size & 0x7F) as u8, + ]; + + let mut out = b"ID3".to_vec(); + out.extend_from_slice(&[0x04, 0x00, 0x00]); + out.extend_from_slice(&syncsafe); + out.extend_from_slice(&frame); + out.extend_from_slice(&minimal_flac()); + out +} + +/// Opaque bytes for the asset routes, where only the round trip matters. +fn fake_jpeg() -> Vec<u8> { + let mut out = vec![0xFF, 0xD8, 0xFF, 0xE0]; // a JPEG SOI, so it is at least plausible + out.extend_from_slice(b"qbz-test-cover-bytes"); + out +} + +/// A catalog track built through the real deserializer, so the harness exercises +/// the same mapping the download path does rather than a hand-made struct. +fn catalog_track(json: &str) -> Track { + serde_json::from_str(json).expect("catalog track fixture deserializes") +} + +fn goody(name: &str, url: String) -> Goody { + Goody { + id: 5, + name: name.to_string(), + url, + original_url: String::new(), + file_url: None, + file_format_id: Some(21), + description: None, + } +} + +fn open_db(dir: &std::path::Path) -> LibraryDatabase { + LibraryDatabase::open(&dir.join("library.db")).expect("open a temp library db") +} + +/// Install the process-level rustls `CryptoProvider`, once. +/// +/// Not optional and not TLS-specific in effect: reqwest is built with +/// `rustls-tls-webpki-roots-no-provider`, so **constructing any client panics +/// with "No provider set"** until a provider is installed — even for a plain +/// `http://` URL, because the TLS backend is initialised at builder time rather +/// than at connect time. The application does this at startup +/// (`qbz_app::ensure_crypto_provider`); a test binary has no startup, so it does +/// it here. Worth knowing beyond this file: any future tool or test that reaches +/// for `download_audio` without an app around it will hit the same panic. +fn ensure_crypto_provider() { + use std::sync::Once; + static INIT: Once = Once::new(); + INIT.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); +} + +fn rt() -> tokio::runtime::Runtime { + ensure_crypto_provider(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build a test runtime") +} + +// ─── The tests ──────────────────────────────────────────────────────────────── + +/// The asset fetcher moves real bytes over a real socket, unchanged. +#[test] +fn asset_bytes_round_trip_over_a_real_socket() { + let cover = fake_jpeg(); + let mut routes = HashMap::new(); + routes.insert("/img/cover.jpg".to_string(), (200u16, cover.clone())); + let server = FixtureServer::start(routes); + + let got = rt().block_on(svc::fetch_asset_bytes(&server.url("/img/cover.jpg"))); + assert_eq!(got.as_deref(), Some(cover.as_slice())); + assert_eq!(server.requested_paths(), vec!["/img/cover.jpg".to_string()]); +} + +/// Every failure mode of the asset fetcher degrades to `None`. This is the +/// property the whole scope expansion rests on: a missing cover or goodie must +/// never fail a download that already succeeded. +#[test] +fn asset_failures_never_raise() { + let mut routes = HashMap::new(); + routes.insert("/empty".to_string(), (200u16, Vec::new())); + routes.insert("/boom".to_string(), (500u16, b"server on fire".to_vec())); + let server = FixtureServer::start(routes); + + let runtime = rt(); + assert_eq!( + runtime.block_on(svc::fetch_asset_bytes(&server.url("/empty"))), + None + ); + assert_eq!( + runtime.block_on(svc::fetch_asset_bytes(&server.url("/boom"))), + None + ); + assert_eq!( + runtime.block_on(svc::fetch_asset_bytes(&server.url("/nope"))), + None + ); + assert_eq!(runtime.block_on(svc::fetch_asset_bytes("")), None); + // Nothing is listening on port 1; the connect timeout bounds the attempt. + assert_eq!( + runtime.block_on(svc::fetch_asset_bytes("http://127.0.0.1:1/x")), + None + ); +} + +/// `cover.jpg` / `back.jpg` land beside the tracks; an absent one writes nothing. +#[test] +fn cover_files_are_written_beside_the_tracks() { + let tmp = tempfile::tempdir().unwrap(); + let album_dir = tmp.path().join("Artist").join("Album"); + std::fs::create_dir_all(&album_dir).unwrap(); + + let cover = fake_jpeg(); + svc::write_album_cover_files(&album_dir, Some(&cover), None, None); + assert_eq!(std::fs::read(album_dir.join("cover.jpg")).unwrap(), cover); + assert!( + !album_dir.join("back.jpg").exists(), + "an absent back cover must write nothing" + ); + assert!(!album_dir.join("large_cover.jpg").exists()); + + let back = b"back-cover-bytes".to_vec(); + let large = b"large-cover-bytes".to_vec(); + svc::write_album_cover_files(&album_dir, Some(&cover), Some(&back), Some(&large)); + assert_eq!(std::fs::read(album_dir.join("back.jpg")).unwrap(), back); + assert_eq!( + std::fs::read(album_dir.join("large_cover.jpg")).unwrap(), + large, + "the master-size cover lands under the desktop client's name" + ); + + // Empty slices count as absent, not as 0-byte files. + let empty_dir = tmp.path().join("empty"); + std::fs::create_dir_all(&empty_dir).unwrap(); + svc::write_album_cover_files(&empty_dir, Some(&[]), Some(&[]), Some(&[])); + assert!(!empty_dir.join("cover.jpg").exists()); + assert!(!empty_dir.join("back.jpg").exists()); + assert!(!empty_dir.join("large_cover.jpg").exists()); +} + +/// A goodie is fetched and written into the album folder under a sanitized name. +#[test] +fn a_goodie_is_downloaded_into_the_album_folder() { + let pdf = b"%PDF-1.4 fake booklet".to_vec(); + let mut routes = HashMap::new(); + routes.insert("/goodies/booklet.pdf".to_string(), (200u16, pdf.clone())); + let server = FixtureServer::start(routes); + + let tmp = tempfile::tempdir().unwrap(); + let album_dir = tmp.path().join("Album"); + + let path = rt() + .block_on(svc::download_goodie( + &goody("Digital Booklet", server.url("/goodies/booklet.pdf")), + &album_dir, + )) + .expect("the goodie downloads"); + + assert!(path.ends_with("Digital Booklet.pdf"), "got {path}"); + assert_eq!(std::fs::read(&path).unwrap(), pdf); +} + +/// A goodie name cannot escape the album folder. The name is the least +/// trustworthy field on the least verified struct in the feature — its populated +/// shape has never been observed — so containment is asserted, not assumed. +#[test] +fn a_goodie_name_cannot_escape_the_album_folder() { + let mut routes = HashMap::new(); + routes.insert("/g/x.pdf".to_string(), (200u16, b"x".to_vec())); + let server = FixtureServer::start(routes); + + let tmp = tempfile::tempdir().unwrap(); + let album_dir = tmp.path().join("Album"); + let runtime = rt(); + + for hostile in [ + "../../../../etc/passwd", + "/etc/passwd", + "..\\..\\windows\\system32", + "a/b/c", + ] { + let path = runtime + .block_on(svc::download_goodie( + &goody(hostile, server.url("/g/x.pdf")), + &album_dir, + )) + .expect("still downloads, just somewhere safe"); + assert_eq!( + std::path::Path::new(&path).parent(), + Some(album_dir.as_path()), + "{hostile:?} escaped to {path}" + ); + } +} + +/// A goodie with no usable URL is skipped, never fatal. +#[test] +fn a_goodie_without_a_url_is_skipped() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!( + rt().block_on(svc::download_goodie( + &goody("Nameless", " ".to_string()), + tmp.path() + )), + None + ); +} + +/// THE end-to-end one: real bytes over a real socket land at the contract's +/// deterministic path, register under the REQUESTED format, and carry tags taken +/// from the API payload plus an embedded cover. +#[test] +fn a_downloaded_file_is_written_registered_and_tagged() { + use lofty::prelude::*; + + let audio = minimal_flac(); + let cover = fake_jpeg(); + let mut routes = HashMap::new(); + routes.insert("/cdn/track.flac".to_string(), (200u16, audio.clone())); + let server = FixtureServer::start(routes); + + let tmp = tempfile::tempdir().unwrap(); + let db = open_db(tmp.path()); + let dest = tmp.path().join("Downloads"); + + // Fetch through the real client, exactly as the download path does. + let data = rt() + .block_on(svc::fetch_asset_bytes(&server.url("/cdn/track.flac"))) + .expect("the CDN stand-in serves the audio"); + assert_eq!(data, audio, "bytes survive the socket unchanged"); + + // The write + registry tail, with the album id folded into the one write. + // Requested format 27, SERVED format 6 — they are allowed to disagree. + let file_path = svc::write_and_register_track( + &db, + 4242, + Some("alb-1"), + 27, + &data, + "Café Tacvba", + "Ré", + "[FLAC][24-bit,192kHz]", + 7, + "El Ciclón", + 6, + "audio/flac", + dest.to_str().unwrap(), + ) + .expect("write and register"); + + // §4.4: deterministic path, Unicode survives, the quality suffix keeps its + // ASCII brackets, and the `.part` file is gone. + assert!( + file_path.ends_with("Café Tacvba/Ré [FLAC][24-bit,192kHz]/07 - El Ciclón.flac"), + "unexpected path: {file_path}" + ); + assert!(!std::path::Path::new(&format!("{file_path}.part")).exists()); + + // §3: the registry records the REQUESTED format, and `album_id` is set — + // which is what makes the album-downloaded rule answerable at all. + let formats = db.get_downloaded_purchase_formats().unwrap(); + assert!( + formats.contains(&(4242, 27)), + "registry must hold the REQUESTED format 27, not the served 6: {formats:?}" + ); + let counts = db.get_downloaded_purchase_album_counts().unwrap(); + assert_eq!(counts.get("alb-1"), Some(&1)); + + // §14.1: tags come from the API payload, including the per-track artist, + // and the cover is embedded exactly once. + let track = catalog_track( + r#"{ + "id": 4242, + "title": "El Ciclón", + "version": "Live", + "track_number": 7, + "media_number": 1, + "isrc": "MX1234567890", + "performer": {"id": 1, "name": "Café Tacvba"}, + "album": {"id": "alb-1", "title": "Ré"}, + "copyright": "1994 WEA" + }"#, + ); + let ctx = svc::PurchaseAlbumContext { + album_artist: "Café Tacvba".to_string(), + year: Some(1994), + genre: Some("Rock".to_string()), + label: Some("WEA".to_string()), + cover_jpeg: Some(cover), + }; + svc::tag_downloaded_file(&file_path, &track, &ctx); + + let tagged = lofty::read_from_path(std::path::Path::new(&file_path)).expect("re-read the file"); + let tag = tagged + .primary_tag() + .or_else(|| tagged.first_tag()) + .expect("a tag exists"); + + assert_eq!(tag.title().as_deref(), Some("El Ciclón")); + assert_eq!( + tag.artist().as_deref(), + Some("Café Tacvba"), + "the per-track performer must be written outright — the editor's rename \ + rule reads the file, and a fresh download has nothing to read" + ); + assert_eq!(tag.album().as_deref(), Some("Ré")); + assert_eq!(tag.track(), Some(7)); + assert_eq!(tag.disk(), Some(1)); + assert_eq!( + tag.pictures().len(), + 1, + "exactly one embedded cover, never a second copy" + ); + + // Tagging twice must not accumulate art — the guard that makes a retry safe. + svc::tag_downloaded_file(&file_path, &track, &ctx); + let retagged = lofty::read_from_path(std::path::Path::new(&file_path)).unwrap(); + let tag2 = retagged + .primary_tag() + .or_else(|| retagged.first_tag()) + .unwrap(); + assert_eq!( + tag2.pictures().len(), + 1, + "re-tagging must not duplicate the cover" + ); +} + +/// THE regression test for the worst failure this pipeline had, and the reason +/// the harness above was not enough on its own. +/// +/// The tag writer used to pick its target tag as "the primary one, or else the +/// first one I can find". For a FLAC that arrives carrying an ID3v2 tag, the +/// primary (VorbisComments) is absent while the first is that ID3v2 — so every +/// field went into a tag type lofty **refuses to write** for FLAC. And it +/// refuses silently: the save loop skips non-writable tags with `continue` +/// rather than erroring, so the call returned `Ok(())`, nothing was logged, and +/// the file came out with none of its metadata. On a feature nobody here can +/// smoke-test, that is a bug that reaches users untouched. +/// +/// The clean FLAC fixture used everywhere else in this file cannot catch it, +/// because with NO tag at all both accessors return `None` and the correct +/// branch runs by luck. +#[test] +fn a_flac_carrying_id3v2_is_still_tagged() { + use lofty::prelude::*; + + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("with-id3.flac"); + std::fs::write(&file, flac_with_leading_id3v2()).unwrap(); + + // Sanity: the fixture really does present an ID3v2 tag, and really does NOT + // present the Vorbis comments that FLAC actually writes. Without this the + // test could pass while proving nothing. + let before = lofty::read_from_path(&file).expect("the fixture is a readable FLAC"); + assert!( + before.tag(lofty::tag::TagType::Id3v2).is_some(), + "the fixture must carry an ID3v2 tag, or it does not exercise the bug" + ); + assert!( + before.tag(lofty::tag::TagType::VorbisComments).is_none(), + "the fixture must NOT already have Vorbis comments" + ); + + let track = catalog_track( + r#"{"id":1,"title":"New Title","track_number":3, + "performer":{"id":1,"name":"The Artist"}, + "album":{"id":"a","title":"The Album"}}"#, + ); + let ctx = svc::PurchaseAlbumContext { + album_artist: "The Artist".to_string(), + year: Some(2001), + genre: None, + label: None, + cover_jpeg: None, + }; + svc::tag_downloaded_file(&file.to_string_lossy(), &track, &ctx); + + let after = lofty::read_from_path(&file).expect("re-read"); + let vorbis = after + .tag(lofty::tag::TagType::VorbisComments) + .expect("the tags must land in VORBIS COMMENTS — the type FLAC can actually write"); + + assert_eq!(vorbis.title().as_deref(), Some("New Title")); + assert_eq!(vorbis.artist().as_deref(), Some("The Artist")); + assert_eq!(vorbis.album().as_deref(), Some("The Album")); + assert_eq!(vorbis.track(), Some(3)); +} + +/// A tagging failure must never be able to fail a download: the function returns +/// nothing, so a caller cannot propagate it even by accident. +#[test] +fn tagging_a_missing_or_unreadable_file_is_survivable() { + let tmp = tempfile::tempdir().unwrap(); + let track = catalog_track(r#"{"id":1,"title":"T","track_number":1}"#); + let ctx = svc::PurchaseAlbumContext::default(); + + // Missing file. + svc::tag_downloaded_file( + &tmp.path().join("nope.flac").to_string_lossy(), + &track, + &ctx, + ); + + // Present but not audio. + let junk = tmp.path().join("junk.flac"); + std::fs::write(&junk, b"this is not a flac stream").unwrap(); + svc::tag_downloaded_file(&junk.to_string_lossy(), &track, &ctx); + + // Reaching here at all is the assertion: neither call panicked or returned. + assert!(junk.exists(), "the file is left exactly as it was"); +} diff --git a/crates/qbz-player/Cargo.toml b/crates/qbz-player/Cargo.toml index 1a0212b82..0e2f00dd7 100644 --- a/crates/qbz-player/Cargo.toml +++ b/crates/qbz-player/Cargo.toml @@ -29,7 +29,7 @@ log = { workspace = true } thiserror = { workspace = true } # Random (for shuffle) -rand = "0.9" +rand = "0.10" # Audio decoding symphonia = { workspace = true } diff --git a/crates/qbz-player/src/lib.rs b/crates/qbz-player/src/lib.rs index 1df624a78..c90268838 100644 --- a/crates/qbz-player/src/lib.rs +++ b/crates/qbz-player/src/lib.rs @@ -32,11 +32,13 @@ //! ``` pub mod player; +pub mod remote_stream; pub mod queue; // Re-export main types pub use player::{ - BufferWriter, BufferedMediaSource, IncrementalStreamingSource, PlaybackEvent, PlaybackState, + BufferWriter, BufferedMediaSource, ExternalStreamHandle, IncrementalStreamingSource, + InMemorySource, PlaybackBufferState, PlaybackEvent, PlaybackState, Player, SharedState, StreamingConfig, }; -pub use queue::QueueManager; +pub use queue::{QueueAuthoritySnapshot, QueueManager}; diff --git a/crates/qbz-player/src/player/memory_tuning.rs b/crates/qbz-player/src/player/memory_tuning.rs new file mode 100644 index 000000000..1ecde2c13 --- /dev/null +++ b/crates/qbz-player/src/player/memory_tuning.rs @@ -0,0 +1,93 @@ +//! Process-wide memory tuning for the player. +//! +//! `qbz-core`'s `system_capabilities` detects the host's memory profile +//! (Normal vs LowMemory), but `qbz-core` depends on `qbz-player` — the +//! player cannot read the profile back without a dependency cycle. So the +//! frontend binaries (`qbz`, `qbzd`) read the profile once at startup and +//! push the derived values down here, exactly like the pre-existing +//! [`super::streaming_source::set_max_initial_buffer_bytes`] cap. +//! +//! Every knob defaults to today's hardcoded Normal behavior, so a consumer +//! that never calls [`apply_memory_tuning`] (tests, auxiliary tools) is +//! byte-identical to before. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +/// Whether the host profile is LowMemory (< 2 GB RAM, Raspberry Pi-class). +/// Default false = Normal — behavioral changes gated on this flag must keep +/// Normal hosts unchanged. +static LOW_MEMORY_CLASS: AtomicBool = AtomicBool::new(false); + +/// L1 (in-memory) audio-cache budget in bytes. Defaults to the historical +/// hardcoded 400 MB; the LowMemory profile lowers it to 50 MB so the cache +/// cannot eat 40 % of a 1 GB host (issue #660). +static AUDIO_CACHE_L1_MAX_BYTES: AtomicUsize = AtomicUsize::new(400 * 1024 * 1024); + +/// Push the host memory profile's derived values into the player. Called +/// once at process start by the frontend binaries; not designed for +/// mid-session mutation (the L1 cache reads its budget at construction). +pub fn apply_memory_tuning( + low_memory: bool, + l1_max_bytes: usize, + max_initial_buffer_bytes: usize, +) { + LOW_MEMORY_CLASS.store(low_memory, Ordering::Relaxed); + AUDIO_CACHE_L1_MAX_BYTES.store(l1_max_bytes, Ordering::Relaxed); + super::streaming_source::set_max_initial_buffer_bytes(max_initial_buffer_bytes); +} + +/// True when the host was classified LowMemory at startup. +pub fn is_low_memory_class() -> bool { + LOW_MEMORY_CLASS.load(Ordering::Relaxed) +} + +/// The configured L1 audio-cache budget in bytes. +pub fn audio_cache_l1_max_bytes() -> usize { + AUDIO_CACHE_L1_MAX_BYTES.load(Ordering::Relaxed) +} + +/// Pure predicate: a completed track of `len` bytes is "oversized" for an +/// L1 budget of `l1_max_bytes` when it exceeds a quarter of that budget. +/// +/// Rationale for the /4: the streaming buffer already holds one full copy +/// of the track in RAM for playback, so promoting an oversized track into +/// L1 (plus the promotion clone itself) transiently triples its footprint. +/// Anything beyond a quarter of the L1 budget would also evict every other +/// cached track on insert, so the cache stop pays for itself only for +/// smaller tracks. With the LowMemory 50 MB budget this flags tracks over +/// 12.5 MB; the predicate is only consulted on LowMemory hosts. +pub fn oversized_for_l1(len: usize, l1_max_bytes: usize) -> bool { + len > l1_max_bytes / 4 +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOW_L1: usize = 50 * 1024 * 1024; // LowMemory profile budget + + #[test] + fn oversized_boundary_is_quarter_of_budget() { + let threshold = LOW_L1 / 4; + assert!(!oversized_for_l1(threshold, LOW_L1)); // exactly at: still cached + assert!(oversized_for_l1(threshold + 1, LOW_L1)); + assert!(!oversized_for_l1(0, LOW_L1)); + } + + #[test] + fn normal_budget_flags_only_very_large_tracks() { + // The predicate is gated on the LowMemory class at the call sites, + // but with the Normal 400 MB budget the threshold would be 100 MB. + let normal = 400 * 1024 * 1024; + assert!(!oversized_for_l1(60 * 1024 * 1024, normal)); // typical HiRes + assert!(oversized_for_l1(200 * 1024 * 1024, normal)); // issue #660 track + } + + #[test] + fn defaults_match_historical_hardcoded_behavior() { + // A process that never applied tuning: Normal class, 400 MB L1 — + // identical to the pre-wiring hardcoded values. + assert!(!is_low_memory_class()); + assert_eq!(audio_cache_l1_max_bytes(), 400 * 1024 * 1024); + } +} diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index ed45e9442..ecb121012 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -13,6 +13,11 @@ mod playback_engine; mod streaming_source; +pub mod memory_tuning; + +pub use memory_tuning::{ + apply_memory_tuning, audio_cache_l1_max_bytes, is_low_memory_class, oversized_for_l1, +}; pub use streaming_source::{ max_initial_buffer_bytes, set_max_initial_buffer_bytes, BufferWriter, BufferedMediaSource, InMemorySource, IncrementalStreamingSource, StreamingConfig, @@ -32,7 +37,7 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use symphonia::core::audio::SampleBuffer; -use symphonia::core::codecs::DecoderOptions; +use symphonia::core::codecs::{CodecType, DecoderOptions}; use symphonia::core::errors::Error as SymphoniaError; use symphonia::core::formats::FormatOptions; use symphonia::core::io::{MediaSource, MediaSourceStream}; @@ -43,9 +48,9 @@ use symphonia::default::{get_codecs, get_probe}; use playback_engine::PlaybackEngine; use qbz_audio::{ calculate_gain_factor, db_to_linear, extract_replaygain, AnalyzerMessage, AnalyzerTap, - AudioBackendType, AudioDiagnostic, AudioSettings, BackendConfig, BackendManager, - BitPerfectMode, DiagnosticSource, DynamicAmplify, LoudnessAnalyzer, LoudnessCache, - TappedSource, VisualizerTap, + AnalyzerWaveformTrack, AudioBackendType, AudioDiagnostic, AudioSettings, BackendConfig, + BackendManager, BitPerfectMode, DiagnosticSource, DynamicAmplify, LoudnessAnalyzer, + LoudnessCache, TappedSource, VisualizerTap, }; use qbz_models::{AssetOrigin, ExternalStreamAsset, Quality, StreamQualityInfo}; use qbz_qobuz::QobuzClient; @@ -59,6 +64,7 @@ enum AudioCommand { duration_secs: u64, sample_rate: u32, channels: u16, + play_gen: u64, }, /// Play from streaming source (BufferedMediaSource) /// The download task should already be running and pushing to the source @@ -97,33 +103,75 @@ enum AudioCommand { /// stream (freeing an exclusive ALSA `hw:` grab + its D-Bus reservation) /// and un-suspends / un-forces anything QBZ parked, so PipeWire can /// reclaim a device QBZ was holding. User-triggered from settings. - ReleaseDevice, + ReleaseDevice { + completed: SyncSender<Result<(), String>>, + }, /// Append next track to current engine for gapless playback (Rodio only) PlayNext { data: Vec<u8>, track_id: u64, sample_rate: u32, channels: u16, + bit_depth: u32, + }, + /// Append an incrementally-fed successor to the current engine. Used by + /// Streaming only: the handoff depends on the initial buffer, not on + /// downloading a potentially very long track in full inside the 10 s + /// gapless window. + PlayNextStreaming { + source: Arc<BufferedMediaSource>, + track_id: u64, + sample_rate: u32, + channels: u16, + bit_depth: u32, + duration_secs: u64, }, /// Play a local DSD file via DoP (DSD over PCM) on ALSA direct (DSD plan /// Phase 2). The audio thread opens the demuxer + an S32 stream at the /// DoP carrier rate and feeds pre-packed words through the DoP engine. - PlayDsdDop { path: std::path::PathBuf, track_id: u64 }, + PlayDsdDop { + path: std::path::PathBuf, + track_id: u64, + }, /// Play a local DSD file NATIVELY (ALSA DSD_U32, DSD plan Phase 3) — /// requires the kernel to grant the device a DSD format (quirk table). - PlayDsdNative { path: std::path::PathBuf, track_id: u64 }, + PlayDsdNative { + path: std::path::PathBuf, + track_id: u64, + }, /// Queue the next DSD track on the ACTIVE DoP engine (gapless DSD). /// Ignored (with gapless_ready reset) when the engine isn't DoP or the /// carrier rate differs — the normal track-end advance then handles it. - PlayNextDsdDop { path: std::path::PathBuf, track_id: u64 }, + PlayNextDsdDop { + path: std::path::PathBuf, + track_id: u64, + }, } /// Pending gapless track data (queued for seamless transition) +enum GaplessMedia { + Buffered(Vec<u8>), + Streaming(Arc<BufferedMediaSource>), + Direct(DirectDsdMedia), +} + +#[derive(Clone)] +struct DirectDsdMedia { + path: std::path::PathBuf, + /// 1 = DoP, 2 = native DSD_U32_BE, 3 = native DSD_U32_LE. + mode: u8, +} + struct GaplessPending { track_id: u64, + play_generation: u64, duration_secs: u64, - data: Vec<u8>, + media: GaplessMedia, + sample_rate: u32, + channels: u16, + bit_depth: u32, normalization_gain: Option<f32>, + gain_atomic: Option<Arc<AtomicU32>>, } struct CursorMediaSource { @@ -268,10 +316,11 @@ fn is_isomp4(data: &[u8]) -> bool { /// This is much faster than decode_with_symphonia as it only reads headers. /// Audio metadata extracted from file headers #[allow(dead_code)] -struct AudioMetadata { - sample_rate: u32, - channels: u16, - bit_depth: Option<u32>, +pub(crate) struct AudioMetadata { + pub(crate) sample_rate: u32, + pub(crate) channels: u16, + pub(crate) bit_depth: Option<u32>, + pub(crate) codec: CodecType, } #[allow(dead_code)] @@ -280,7 +329,7 @@ fn extract_audio_metadata(data: &[u8]) -> Result<(u32, u16), String> { Ok((meta.sample_rate, meta.channels)) } -fn extract_audio_metadata_full(data: &[u8]) -> Result<AudioMetadata, String> { +pub(crate) fn extract_audio_metadata_full(data: &[u8]) -> Result<AudioMetadata, String> { // For non-isomp4 files (FLAC, etc.), try symphonia directly to get all metadata // Symphonia gives us bits_per_sample which rodio doesn't expose @@ -327,6 +376,7 @@ fn extract_audio_metadata_full(data: &[u8]) -> Result<AudioMetadata, String> { sample_rate, channels, bit_depth, + codec: track.codec_params.codec, }) } @@ -359,8 +409,17 @@ fn decode_with_fallback(data: &[u8]) -> Result<Box<dyn Source<Item = f32> + Send }); } + // Hand rodio the buffer length. `Decoder::new` leaves `byte_len` unset + // and the source non-seekable, and symphonia's MP3 demuxer only estimates + // the duration of a file without a Xing/Info/VBRI header when both are + // known — otherwise `total_duration()` is None and the local play path + // ends up with duration 0 (position clamped to 0 forever, #734). + let byte_len = data.len() as u64; let primary = panic::catch_unwind(AssertUnwindSafe(|| { - Decoder::new(BufReader::new(Cursor::new(data.to_vec()))) + Decoder::builder() + .with_data(BufReader::new(Cursor::new(data.to_vec()))) + .with_byte_len(byte_len) + .build() })); match primary { @@ -408,6 +467,7 @@ fn create_output_stream_with_config( sample_rate: u32, channels: u16, exclusive_mode: bool, + state: SharedState, ) -> Result<MixerDeviceSink, String> { log::info!( "Creating MixerDeviceSink: {}Hz, {} channels, exclusive: {}", @@ -478,9 +538,25 @@ fn create_output_stream_with_config( // Create MixerDeviceSink with custom config match DeviceSinkBuilder::from_device(device) { Ok(builder) => { + // rodio's default error callback only eprintln!s a live stream + // error (e.g. an ALSA buffer underrun mid-playback), so playback + // dies without the driver ever seeing a message to latch. Record + // it on the shared state instead so it drains to the event bus as + // a PlaybackError. Rate-limited: ALSA can repeat EPIPE every + // period on a wedged device, and each recorded message is one + // bus event (and one forked hook script) after the drain. + let mut last_reported: Option<std::time::Instant> = None; match builder .with_supported_config(&supported_config) .with_buffer_size(cpal_buffer_size) + .with_error_callback(move |err| { + log::error!("Audio stream error: {err}"); + let now = std::time::Instant::now(); + if last_reported.map_or(true, |t| now.duration_since(t).as_secs() >= 5) { + last_reported = Some(now); + state.record_stream_error(format!("Audio stream error: {err}")); + } + }) .open_stream() { Ok(mixer_sink) => { @@ -514,8 +590,14 @@ enum StreamType { #[cfg(target_os = "macos")] exclusive_guard: Option<qbz_audio::CoreAudioExclusiveGuard>, }, - #[cfg(target_os = "linux")] - AlsaDirect(Arc<qbz_audio::AlsaDirectStream>), + /// Any bit-perfect DIRECT sink: ALSA hw: on Linux, WASAPI exclusive on + /// Windows. One variant rather than one per platform, because everything + /// downstream treats them identically - see `qbz_audio::backend::DirectSink`. + /// + /// NOT cfg-gated: gating it to Linux is what made the Windows compiler + /// unable to check any of the arms that handle it, which is the whole + /// blind spot this port has to work around. + Direct(Arc<dyn qbz_audio::backend::DirectSink>), /// Native JACK output (#263 Tier 3). QBZ as a JACK client with stable ports; /// NOT bit-perfect (resampled to the graph rate). #[cfg(target_os = "linux")] @@ -565,6 +647,10 @@ impl StreamType { fn output_sample_rate(&self) -> u32 { match self { StreamType::Rodio { sink, .. } => sink.config().sample_rate().get(), + // `Direct` is deliberately not cfg-gated (see the enum), so this + // macOS-only match must name it: a direct sink reports the rate it + // was opened at. Without this arm the macOS build does not compile. + StreamType::Direct(sink) => sink.sample_rate(), } } } @@ -587,6 +673,87 @@ fn apply_engine_volume( engine.set_volume(volume); } +fn reported_volume_after_command( + requested: f32, + direct_output: bool, + hardware_volume_active: bool, +) -> f32 { + #[cfg(target_os = "linux")] + if direct_output && !hardware_volume_active { + return 1.0; + } + + #[cfg(not(target_os = "linux"))] + let _ = (direct_output, hardware_volume_active); + + requested +} + +fn selected_alsa_hardware_volume_control( + settings: &Arc<Mutex<AudioSettings>>, +) -> Option<qbz_audio::alsa_hardware_volume::AlsaMixerControlId> { + settings + .lock() + .ok() + .and_then(|settings| settings.selected_alsa_hardware_volume_control()) +} + +fn hardware_volume_event_callback( + state: &SharedState, + initially_active: bool, +) -> qbz_audio::alsa_hardware_volume::HardwareVolumeEventCallback { + use qbz_audio::alsa_hardware_volume::{HardwareVolumeEvent, HardwareVolumeEventCallback}; + + state.set_hardware_volume_active(initially_active); + let state = state.clone(); + // A session that has reported Unavailable must never be revived by an + // already-queued ctl Changed event. Serializing callbacks also guarantees + // that whichever event obtains this latch first completes before the next + // one updates SharedState. + let session_active = Mutex::new(initially_active); + let callback: HardwareVolumeEventCallback = Arc::new(move |event| { + let Ok(mut active) = session_active.lock() else { + state.set_hardware_volume_active(false); + state.volume.store(1.0_f32.to_bits(), Ordering::SeqCst); + state.record_stream_error( + "ALSA hardware volume event state became unavailable. Direct playback remains fixed at 100%." + .to_string(), + ); + return; + }; + match event { + HardwareVolumeEvent::Changed(snapshot) if *active => { + state + .volume + .store(snapshot.volume.to_bits(), Ordering::SeqCst); + state.set_hardware_volume_active(true); + } + HardwareVolumeEvent::Changed(_) => {} + HardwareVolumeEvent::Unavailable(error) => { + *active = false; + state.set_hardware_volume_active(false); + state.volume.store(1.0_f32.to_bits(), Ordering::SeqCst); + state.record_stream_error(format!( + "ALSA hardware volume became unavailable: {error}. Direct playback remains fixed at 100%." + )); + } + } + }); + callback +} + +fn direct_hardware_volume_binding( + settings: &Arc<Mutex<AudioSettings>>, + state: &SharedState, +) -> ( + Option<qbz_audio::alsa_hardware_volume::AlsaMixerControlId>, + qbz_audio::alsa_hardware_volume::HardwareVolumeEventCallback, +) { + let control = selected_alsa_hardware_volume_control(settings); + let callback = hardware_volume_event_callback(state, control.is_some()); + (control, callback) +} + #[cfg(target_os = "macos")] fn uses_coreaudio_system_default(settings: &AudioSettings) -> bool { settings @@ -851,6 +1018,57 @@ fn try_init_stream_with_backend( skip_sink_switch: audio_settings.skip_sink_switch, }; + // WASAPI EXCLUSIVE (Windows) - the bit-perfect path, tried before the + // shared CPAL stream exactly as ALSA Direct is on Linux. + // + // The device id is the WASAPI ENDPOINT ID, which is what the enumeration + // already publishes: Windows hands several endpoints the same friendly + // name, so the name cannot identify one. Without a chosen device there is + // nothing to open exclusively - falling through to shared is the honest + // answer, not picking the default DAC behind the user's back. + #[cfg(windows)] + if backend_type == AudioBackendType::WasapiExclusive { + match config.device_id.as_ref() { + None => { + log::info!( + "[WASAPI] Exclusive mode selected with no device chosen; using the shared stream. Pick an output device to get bit-perfect playback." + ); + } + Some(endpoint_id) => { + use qbz_audio::wasapi_direct::{WasapiDirectStream, WasapiTiming}; + match WasapiDirectStream::new( + endpoint_id, + sample_rate, + config.channels, + WasapiTiming::Events, + ) { + Ok(stream) => { + let mode = stream.bit_perfect_mode(); + let info = stream.open_info(); + log::info!( + "[WASAPI] Exclusive stream open: {} @ {} Hz, {:?}, mode {:?}", + info.endpoint_name, + info.rate, + info.rung, + mode + ); + state.set_bit_perfect_mode(Some(mode)); + return Some(Ok(StreamType::Direct(Arc::new(stream)))); + } + Err(e) => { + // NOT fatal: a device that refuses this rate in + // exclusive mode still plays through the shared path, + // and silence would be a worse answer than a + // resampled track. + log::warn!( + "[WASAPI] Exclusive open failed ({e}); falling back to the shared stream" + ); + } + } + } + } + } + // For ALSA backend with hw: devices, try direct ALSA first (Linux only) #[cfg(target_os = "linux")] if backend_type == AudioBackendType::Alsa { @@ -868,7 +1086,7 @@ fn try_init_stream_with_backend( return Some(result.map(|(stream, mode)| { log::info!("ALSA Direct stream created with mode: {:?}", mode); state.set_bit_perfect_mode(Some(mode)); - StreamType::AlsaDirect(Arc::new(stream)) + StreamType::Direct(Arc::new(stream)) })); } } @@ -920,6 +1138,75 @@ fn try_init_stream_with_backend( } } +/// A Qobuz track being downloaded WHILE an external renderer reads it +/// (`Player::open_external_stream`). Sending `cancel` aborts the download; +/// the buffer is shared with the media server's range readers. +pub struct ExternalStreamHandle { + pub source: Arc<BufferedMediaSource>, + /// Exact final FLAC byte length (Content-Length for the renderer). + pub total_bytes: u64, + pub sample_rate: Option<u32>, + pub bit_depth: Option<u32>, + pub cancel: tokio::sync::watch::Sender<bool>, +} + +/// Player-owned buffer lifecycle. This is intentionally independent from +/// QConnect: adapters map it to their wire protocol instead of teaching the +/// audio engine about renderer constants. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)] +pub enum PlaybackBufferState { + #[default] + Idle, + InitialBuffering, + Ready, + Underrun, + Error, +} + +#[derive(Debug, Clone, Copy)] +struct PlaybackBufferSlot { + state: PlaybackBufferState, + track_id: u64, + play_generation: u64, +} + +impl Default for PlaybackBufferSlot { + fn default() -> Self { + Self { + state: PlaybackBufferState::Idle, + track_id: 0, + play_generation: 0, + } + } +} + +/// Generation-scoped reporter carried by a live incremental decoder. +/// Reporting is a side channel only; it never changes PCM flow or scheduling. +#[derive(Clone)] +pub(super) struct PlaybackBufferReporter { + state: SharedState, + track_id: u64, + play_generation: u64, +} + +impl PlaybackBufferReporter { + fn new(state: SharedState, track_id: u64, play_generation: u64) -> Self { + Self { + state, + track_id, + play_generation, + } + } + + pub(super) fn report(&self, buffer_state: PlaybackBufferState) { + self.state.set_buffer_state_for_play( + buffer_state, + self.track_id, + self.play_generation, + ); + } +} + /// Event payload for playback state updates #[derive(Debug, Clone, serde::Serialize)] pub struct PlaybackEvent { @@ -928,6 +1215,10 @@ pub struct PlaybackEvent { pub duration: u64, pub track_id: u64, pub volume: f32, + /// Whether the exact persisted ALSA mixer control is active for this + /// session. False after a ctl/write/disconnect failure (fail closed). + #[serde(default)] + pub hardware_volume_active: bool, /// Actual sample rate of the current stream (Hz) pub sample_rate: Option<u32>, /// Actual bit depth of the current stream @@ -954,6 +1245,29 @@ pub struct PlaybackEvent { /// the track is fully buffered — drives the seek-bar cache overlay. #[serde(default)] pub buffer_progress: Option<f32>, + /// Instantaneous player-owned buffer lifecycle. Unlike `buffer_progress`, + /// this remains meaningful when content length is unknown or fully cached. + #[serde(default)] + pub buffer_state: PlaybackBufferState, + /// Track identity paired atomically with `buffer_state`. During initial + /// buffering it can lead `track_id`, which changes only after audio starts. + #[serde(default)] + pub buffer_track_id: u64, + /// Monotonic edge raised when the audio engine consumes every queued + /// source. Pollers compare generations instead of trying to sample a + /// potentially sub-tick `playing -> stopped` transition. + #[serde(default)] + pub engine_empty_generation: u64, + /// Track whose engine-empty edge raised `engine_empty_generation`. + #[serde(default)] + pub engine_empty_track_id: u64, + /// Monotonic edge raised after the live CMAF feeder exhausts its bounded + /// fetch retries. This also covers failure before playback ever starts. + #[serde(default)] + pub source_failure_generation: u64, + /// Track whose feeder raised `source_failure_generation`. + #[serde(default)] + pub source_failure_track_id: u64, } /// Shared state between main thread and audio thread @@ -977,6 +1291,9 @@ pub struct SharedState { /// `normalization_gain`; integer-percent storage quantized the volume /// to 1% on every re-apply) volume: Arc<AtomicU32>, + /// Session state, separate from the persisted preference. Any control + /// failure flips this off while direct samples remain at unity. + hardware_volume_active: Arc<AtomicBool>, /// Playback start time (Unix timestamp millis when started/resumed) playback_start_millis: Arc<AtomicU64>, /// Position when playback was started/resumed (in seconds) @@ -1001,6 +1318,9 @@ pub struct SharedState { gapless_next_track_id: Arc<AtomicU64>, /// Streaming buffer progress (0.0-1.0 stored as f32 bits, 0 = not streaming) buffer_progress: Arc<AtomicU32>, + /// Buffer state plus the play identity allowed to mutate it. A mutex keeps + /// the generation check and transition atomic with respect to a new play. + buffer_state: Arc<std::sync::Mutex<PlaybackBufferSlot>>, /// Current bit-perfect mode encoded as u8 (see `bit_perfect_mode_from_u8`). /// 0 = Unknown (no stream active yet), 1 = Disabled (CPAL/Rodio / shared /// system path), 2 = DirectHardware (ALSA hw:), 3 = PluginFallback (plughw:). @@ -1010,6 +1330,36 @@ pub struct SharedState { /// can detect that a queued `PlayStreaming` was superseded by a newer play /// and stop waiting on its initial buffer instead of blocking ~60s (#591). play_generation: Arc<AtomicU64>, + /// Monotonic engine-empty notification plus the track that raised it. + /// This is durable across polling intervals; a boolean pulse would have + /// the same sampling race as the old `was_playing` predicate. + engine_empty_generation: Arc<AtomicU64>, + engine_empty_track_id: Arc<AtomicU64>, + /// Monotonic terminal source-data failure notification. Kept separate + /// from `stream_error`, which also represents output-device failures that + /// must never cause the queue to skip a healthy track. + source_failure_generation: Arc<AtomicU64>, + source_failure_track_id: Arc<AtomicU64>, + /// Handle of the live CMAF segment feeder — the background task that + /// keeps downloading the currently-streaming track after playback has + /// started. Stored so a superseding play intent can stop the download + /// the user just abandoned (`cancel_stream_feeder`) and so the abandoned + /// buffer can be sealed once the new source is in place + /// (`seal_stream_feeder`). + stream_feeder: Arc<std::sync::Mutex<Option<StreamFeederSlot>>>, + /// Feeder for an incrementally-buffered gapless successor. It remains + /// separate from `stream_feeder` because both downloads can coexist for + /// the current track's final seconds. A normal play/stop cancels both; + /// the actual handoff promotes this slot to current ownership. + gapless_stream_feeder: Arc<std::sync::Mutex<Option<StreamFeederSlot>>>, +} + +/// A registered live segment feeder: which track it feeds, how to cancel +/// its download, and the writer into its playback buffer. +struct StreamFeederSlot { + track_id: u64, + cancel_tx: tokio::sync::watch::Sender<bool>, + writer: BufferWriter, } impl Default for SharedState { @@ -1028,6 +1378,7 @@ impl SharedState { dsd_direct: Arc::new(std::sync::atomic::AtomicU8::new(0)), has_loaded_audio: Arc::new(AtomicBool::new(false)), volume: Arc::new(AtomicU32::new(0.75f32.to_bits())), + hardware_volume_active: Arc::new(AtomicBool::new(false)), playback_start_millis: Arc::new(AtomicU64::new(0)), position_at_start: Arc::new(AtomicU64::new(0)), current_device: Arc::new(std::sync::RwLock::new(None)), @@ -1039,8 +1390,15 @@ impl SharedState { gapless_ready: Arc::new(AtomicBool::new(false)), gapless_next_track_id: Arc::new(AtomicU64::new(0)), buffer_progress: Arc::new(AtomicU32::new(0)), + buffer_state: Arc::new(std::sync::Mutex::new(PlaybackBufferSlot::default())), bit_perfect_mode: Arc::new(AtomicU8::new(0)), play_generation: Arc::new(AtomicU64::new(0)), + engine_empty_generation: Arc::new(AtomicU64::new(0)), + engine_empty_track_id: Arc::new(AtomicU64::new(0)), + source_failure_generation: Arc::new(AtomicU64::new(0)), + source_failure_track_id: Arc::new(AtomicU64::new(0)), + stream_feeder: Arc::new(std::sync::Mutex::new(None)), + gapless_stream_feeder: Arc::new(std::sync::Mutex::new(None)), } } @@ -1082,6 +1440,7 @@ impl SharedState { /// fires the toast exactly once per error. pub fn record_stream_error(&self, message: impl Into<String>) { self.stream_error.store(true, Ordering::SeqCst); + self.mark_current_buffer_error(); if let Ok(mut m) = self.stream_error_message.write() { *m = Some(message.into()); } @@ -1099,7 +1458,15 @@ impl SharedState { /// Start a new play intent; returns the generation token for this intent /// (see `Player::begin_play`). pub(crate) fn begin_play(&self) -> u64 { - self.play_generation.fetch_add(1, Ordering::SeqCst) + 1 + let generation = self.play_generation.fetch_add(1, Ordering::SeqCst) + 1; + if let Ok(mut slot) = self.buffer_state.lock() { + *slot = PlaybackBufferSlot { + state: PlaybackBufferState::Idle, + track_id: 0, + play_generation: generation, + }; + } + generation } /// The most recent play generation (the token a play command sent right @@ -1114,6 +1481,241 @@ impl SharedState { self.current_play_generation() == gen } + fn begin_buffering(&self, track_id: u64, play_generation: u64) { + if !self.is_current_play(play_generation) { + return; + } + if let Ok(mut slot) = self.buffer_state.lock() { + if self.is_current_play(play_generation) { + *slot = PlaybackBufferSlot { + state: PlaybackBufferState::InitialBuffering, + track_id, + play_generation, + }; + } + } + } + + fn set_buffer_state_for_play( + &self, + buffer_state: PlaybackBufferState, + track_id: u64, + play_generation: u64, + ) { + if !self.is_current_play(play_generation) { + return; + } + if let Ok(mut slot) = self.buffer_state.lock() { + if self.is_current_play(play_generation) + && slot.play_generation == play_generation + && slot.track_id == track_id + { + slot.state = buffer_state; + } + } + } + + fn adopt_buffered_track( + &self, + track_id: u64, + state: PlaybackBufferState, + play_generation: u64, + ) { + if !self.is_current_play(play_generation) { + return; + } + if let Ok(mut slot) = self.buffer_state.lock() { + if self.is_current_play(play_generation) + && slot.play_generation == play_generation + { + *slot = PlaybackBufferSlot { + state, + track_id, + play_generation, + }; + } + } + } + + fn mark_current_buffer_error(&self) { + let play_generation = self.current_play_generation(); + if let Ok(mut slot) = self.buffer_state.lock() { + if slot.play_generation == play_generation && slot.track_id != 0 { + slot.state = PlaybackBufferState::Error; + } + } + } + + pub fn playback_buffer_state(&self) -> PlaybackBufferState { + self.playback_buffer_snapshot().0 + } + + pub fn playback_buffer_snapshot(&self) -> (PlaybackBufferState, u64) { + self.buffer_state + .lock() + .map(|slot| (slot.state, slot.track_id)) + .unwrap_or((PlaybackBufferState::Error, 0)) + } + + /// Publish a durable engine-empty edge for frontend/headless pollers. + /// Store the identity first, then advance the generation so a reader that + /// observes the new generation also observes its matching track id. + fn record_engine_empty(&self, track_id: u64) { + self.engine_empty_track_id.store(track_id, Ordering::SeqCst); + self.engine_empty_generation.fetch_add(1, Ordering::SeqCst); + } + + /// Publish a terminal CMAF feeder failure only while its play intent is + /// still current. A superseded feeder is cancellation, not a bad track. + fn record_source_failure(&self, track_id: u64, play_gen: u64) { + if !self.is_current_play(play_gen) { + return; + } + self.set_buffer_state_for_play(PlaybackBufferState::Error, track_id, play_gen); + self.source_failure_track_id + .store(track_id, Ordering::SeqCst); + self.source_failure_generation + .fetch_add(1, Ordering::SeqCst); + } + + /// Cancel the in-flight segment feeder's download, if any. Called on + /// every new play intent (and on stop) via `Player::begin_play`. + /// + /// Without this, a superseded track's feeder kept downloading to + /// completion: on 2026-08-10 a shuffle&play pressed mid-stream measured + /// 5441 ms for the new track's init-segment fetch (vs 423-760 ms on an + /// idle link) because the abandoned track's segment loop still held the + /// link — the init's connection setup and TTFB queued behind ~15 MB of + /// in-flight segment data. + /// + /// The cancellation is cooperative (a watch flag the feeder selects on + /// between and during segment fetches) and deliberately does NOT error + /// or complete the abandoned buffer: the outgoing engine keeps playing + /// what is already buffered until the audio thread installs the new + /// source, which is exactly the hand-off the pre-cancel code had. The + /// buffer is sealed by `seal_stream_feeder` once the new source is in. + pub(crate) fn cancel_stream_feeder(&self) { + for slot in [&self.stream_feeder, &self.gapless_stream_feeder] { + let feeder = slot.lock().ok().and_then(|s| { + s.as_ref() + .map(|slot| (slot.track_id, slot.cancel_tx.clone())) + }); + if let Some((track_id, cancel_tx)) = feeder { + log::info!( + "[CMAF-STREAM] Cancelling feeder for superseded track {}", + track_id + ); + let _ = cancel_tx.send(true); + } + } + } + + /// Seal the abandoned feeder's buffer (`download_complete`) and drop the + /// slot. Called when a new source has just been handed to the audio + /// thread (and on stop): a reader that ran the old buffer dry and is + /// parked on its condvar wakes up and sees EOF instead of blocking + /// forever behind a feeder that no longer exists. For a reader still + /// working through the buffered remainder this is a no-op — the engine + /// has already been swapped by the command just sent. + pub(crate) fn seal_stream_feeder(&self) { + for feeder in [&self.stream_feeder, &self.gapless_stream_feeder] { + let slot = feeder.lock().ok().and_then(|mut s| s.take()); + if let Some(slot) = slot { + let _ = slot.writer.complete(); + } + } + } + + /// Register the feeder spawned for `gen`'s stream. The generation check + /// runs under the slot lock so a concurrent `begin_play` + cancel cannot + /// slip between the check and the store: if this intent was already + /// superseded, the new feeder is cancelled instead of registered. + pub(crate) fn register_stream_feeder( + &self, + track_id: u64, + cancel_tx: tokio::sync::watch::Sender<bool>, + writer: BufferWriter, + gen: u64, + ) { + if let Ok(mut s) = self.stream_feeder.lock() { + if !self.is_current_play(gen) { + drop(s); + let _ = cancel_tx.send(true); + return; + } + *s = Some(StreamFeederSlot { + track_id, + cancel_tx, + writer, + }); + } + } + + fn register_gapless_stream_feeder( + &self, + track_id: u64, + cancel_tx: tokio::sync::watch::Sender<bool>, + writer: BufferWriter, + gen: u64, + ) -> bool { + let Ok(mut slot) = self.gapless_stream_feeder.lock() else { + let _ = cancel_tx.send(true); + return false; + }; + if !self.is_current_play(gen) { + drop(slot); + let _ = cancel_tx.send(true); + return false; + } + if let Some(previous) = slot.replace(StreamFeederSlot { + track_id, + cancel_tx, + writer, + }) { + let _ = previous.cancel_tx.send(true); + let _ = previous.writer.complete(); + } + true + } + + fn cancel_gapless_stream_feeder(&self, track_id: u64) { + let slot = self + .gapless_stream_feeder + .lock() + .ok() + .and_then(|mut slot| { + if slot.as_ref().map(|s| s.track_id) == Some(track_id) { + slot.take() + } else { + None + } + }); + if let Some(slot) = slot { + let _ = slot.cancel_tx.send(true); + let _ = slot.writer.complete(); + } + } + + fn promote_gapless_stream_feeder(&self, track_id: u64) { + let pending = self + .gapless_stream_feeder + .lock() + .ok() + .and_then(|mut slot| { + if slot.as_ref().map(|s| s.track_id) == Some(track_id) { + slot.take() + } else { + None + } + }); + let Some(pending) = pending else { + return; + }; + if let Ok(mut current) = self.stream_feeder.lock() { + *current = Some(pending); + } + } + pub fn set_stream_quality(&self, sample_rate: u32, bit_depth: u32) { self.sample_rate.store(sample_rate, Ordering::SeqCst); self.bit_depth.store(bit_depth, Ordering::SeqCst); @@ -1233,8 +1835,14 @@ impl SharedState { let position_at_start = self.position_at_start.load(Ordering::SeqCst); let duration = self.duration.load(Ordering::SeqCst); - // Clamp to duration - (position_at_start + elapsed_secs).min(duration) + // Clamp to duration — unless it is unknown (0): a decoder that could + // not derive one must not pin the clock to 0 while audio plays. + let position = position_at_start + elapsed_secs; + if duration == 0 { + position + } else { + position.min(duration) + } } /// Millisecond-precision companion to [`Self::current_position`] — the @@ -1270,8 +1878,13 @@ impl SharedState { .saturating_mul(1000); let duration_ms = self.duration.load(Ordering::SeqCst).saturating_mul(1000); - // Clamp to duration (same rule as current_position) - position_at_start_ms.saturating_add(elapsed_ms).min(duration_ms) + // Clamp to duration (same rule as current_position: unknown = no clamp) + let position_ms = position_at_start_ms.saturating_add(elapsed_ms); + if duration_ms == 0 { + position_ms + } else { + position_ms.min(duration_ms) + } } /// Mark playback as started/resumed at current position @@ -1320,6 +1933,14 @@ impl SharedState { pub fn volume(&self) -> f32 { f32::from_bits(self.volume.load(Ordering::SeqCst)) } + + pub fn set_hardware_volume_active(&self, active: bool) { + self.hardware_volume_active.store(active, Ordering::SeqCst); + } + + pub fn hardware_volume_active(&self) -> bool { + self.hardware_volume_active.load(Ordering::SeqCst) + } } /// Audio player that handles streaming playback @@ -1391,17 +2012,34 @@ impl Player { // Pipeline order (normalization ON): // Diagnostic (raw) → AnalyzerTap → DynamicAmplify → Visualizer // Pipeline order (normalization OFF — bit-perfect): - // Diagnostic (raw) → Visualizer + // Diagnostic (raw) → dormant/seek-waveform AnalyzerTap → Visualizer let wrap_source = |source: Box<dyn Source<Item = f32> + Send>, normalization_gain: Option<f32>, gain_atomic: Option<Arc<AtomicU32>>, analyzer_tx: &SyncSender<AnalyzerMessage>, - analyzer_enabled: &Arc<AtomicBool>| + analyzer_enabled: &Arc<AtomicBool>, + waveform_track: Option<AnalyzerWaveformTrack>, + waveform_start_frame: u64| -> Box<dyn Source<Item = f32> + Send> { // Diagnostic tap (innermost — captures raw decoded samples) let source: Box<dyn Source<Item = f32> + Send> = Box::new(DiagnosticSource::new(source, thread_diagnostic.clone())); + if gain_atomic.is_some() { + analyzer_enabled.store(true, Ordering::SeqCst); + } + // The tap is always present but its disabled path is one + // relaxed flag check. This lets the waveform setting turn on + // during a track without rebuilding the audible source. + let source: Box<dyn Source<Item = f32> + Send> = + Box::new(AnalyzerTap::new_with_waveform( + source, + analyzer_tx.clone(), + analyzer_enabled.clone(), + waveform_track, + waveform_start_frame, + )); + // Normalization: dynamic (Phase 2) > static (Phase 1 fallback) > none (bit-perfect) let source: Box<dyn Source<Item = f32> + Send> = if let Some(gain_atomic) = gain_atomic { @@ -1410,10 +2048,6 @@ impl Player { "Audio thread: dynamic normalization enabled (initial gain {:.4})", initial_gain ); - analyzer_enabled.store(true, Ordering::SeqCst); - let source: Box<dyn Source<Item = f32> + Send> = Box::new( - AnalyzerTap::new(source, analyzer_tx.clone(), analyzer_enabled.clone()), - ); Box::new(DynamicAmplify::new(source, gain_atomic, initial_gain)) } else if let Some(gain) = normalization_gain { log::info!( @@ -1427,6 +2061,11 @@ impl Player { // Visualizer tap (outermost) if let Some(ref tap) = thread_viz_tap { + // FFT frequency bins must follow the decoded source rate. + // Gapless sources are admitted only when their format + // matches the current stream, so preparing the next one + // cannot move this clock ahead to a different rate. + tap.set_sample_rate(source.sample_rate().get()); Box::new(TappedSource::new( source, tap.ring_buffer.clone(), @@ -1596,6 +2235,9 @@ impl Player { let mut current_audio_data: Option<Vec<u8>> = None; // Store streaming source for resume (when download completes, we can get the data) let mut current_streaming_source: Option<Arc<BufferedMediaSource>> = None; + // Direct DSD bypasses rodio and therefore has no cached PCM/audio + // source to rebuild on seek. Keep the container identity instead. + let mut current_direct_dsd: Option<DirectDsdMedia> = None; // Track consecutive sink creation failures to detect broken streams let mut consecutive_sink_failures: u32 = 0; const MAX_SINK_FAILURES: u32 = 3; @@ -1603,6 +2245,11 @@ impl Player { const PAUSE_SUSPEND_DELAY_MS: u64 = 2000; let mut pause_suspend_deadline: Option<Instant> = None; let mut last_empty_check = Instant::now(); + // Latch so the low-memory oversized-track promotion skip logs + // once per track instead of on every 500 ms idle tick. Reset + // whenever the streaming source is absent or still downloading + // (i.e. the next track re-arms the log). + let mut low_mem_promotion_skip_logged = false; // Current track's normalization gain factor (stored for reuse on resume/seek) let mut current_normalization_gain: Option<f32> = None; // Current track's dynamic gain atomic (shared with DynamicAmplify + LoudnessAnalyzer) @@ -1620,6 +2267,7 @@ impl Player { current_engine: &mut Option<PlaybackEngine>, current_audio_data: &mut Option<Vec<u8>>, current_streaming_source: &mut Option<Arc<BufferedMediaSource>>, + current_direct_dsd: &mut Option<DirectDsdMedia>, stream_opt: &mut Option<StreamType>, current_device_name: &mut Option<String>, consecutive_sink_failures: &mut u32, @@ -1637,7 +2285,15 @@ impl Player { duration_secs, sample_rate, channels, + play_gen, } => { + if !thread_state.is_current_play(play_gen) { + log::info!( + "Audio thread: cached play of track {} was superseded", + track_id + ); + return; + } log::info!( "Audio thread: playing track {} ({}Hz, {} channels)", track_id, @@ -1646,7 +2302,15 @@ impl Player { ); *pause_suspend_deadline = None; thread_state.set_dsd_mode(0); + *current_direct_dsd = None; // Clear any pending gapless state (new Play supersedes queued gapless) + // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition + // survived the PlayStreaming fix, and guessing at the remaining + // path has failed twice. Every set/clear now says who did it. + log::info!( + "[gapless-trace] clear (Play) pending was {:?}", + gapless_pending.as_ref().map(|p| p.track_id) + ); *gapless_pending = None; *gapless_request_armed = false; thread_state.set_gapless_ready(false); @@ -1704,6 +2368,7 @@ impl Player { engine.stop(); std::thread::sleep(Duration::from_millis(50)); } + thread_state.set_hardware_volume_active(false); // Now this drop is the last Arc ref — PCM actually closes drop(stream_opt.take()); // Give kernel time to fully release the ALSA device @@ -1777,6 +2442,7 @@ impl Player { sample_rate, channels, dac_passthrough, + thread_state.clone(), ) .map(StreamType::rodio) }; @@ -1868,6 +2534,7 @@ impl Player { sample_rate, channels, dac_passthrough, + thread_state.clone(), ) .map(StreamType::rodio) }; @@ -1914,6 +2581,11 @@ impl Player { ); thread_state.set_stream_error(true); thread_state.set_current_device(None); + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); return; } } @@ -1937,6 +2609,11 @@ impl Player { let Some(ref stream) = *stream_opt else { log::error!("Audio thread: no audio device available"); + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); return; }; @@ -1951,6 +2628,7 @@ impl Player { #[cfg(target_os = "linux")] std::thread::sleep(Duration::from_millis(50)); } + thread_state.set_hardware_volume_active(false); *current_audio_data = Some(data.clone()); *current_streaming_source = None; // Clear streaming source for non-streaming playback @@ -2004,22 +2682,28 @@ impl Player { thread_state.set_current_device(None); } } + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); return; } } } - #[cfg(target_os = "linux")] - StreamType::AlsaDirect(alsa_stream) => { + StreamType::Direct(alsa_stream) => { *consecutive_sink_failures = 0; thread_state.set_stream_error(false); - let hardware_volume = thread_settings - .lock() - .ok() - .map(|s| s.alsa_hardware_volume) - .unwrap_or(false); - PlaybackEngine::new_alsa_direct( + let (hardware_volume, hardware_volume_events) = + direct_hardware_volume_binding( + &thread_settings, + &thread_state, + ); + PlaybackEngine::new_direct( alsa_stream.clone(), hardware_volume, + thread_viz_tap.clone(), + hardware_volume_events, ) } #[cfg(target_os = "linux")] @@ -2037,6 +2721,11 @@ impl Player { Ok(s) => s, Err(e) => { log::error!("Failed to decode audio: {}", e); + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); return; } }; @@ -2077,15 +2766,6 @@ impl Player { ); } - // Notify analyzer of new track - let _ = analyzer_tx.try_send(AnalyzerMessage::NewTrack { - track_id, - sample_rate, - channels, - target_lufs, - gain_atomic: atomic.clone(), - }); - (rg_gain, Some(atomic)) } else { (None, None) @@ -2099,15 +2779,36 @@ impl Player { let source = wrap_source( source, normalization, - gain_atomic, + gain_atomic.clone(), &analyzer_tx, &analyzer_enabled, + Some(AnalyzerWaveformTrack { + track_id, + sample_rate, + channels, + duration_secs: actual_duration, + start_frame: 0, + target_lufs: norm_settings, + gain_atomic, + }), + 0, ); if let Err(e) = engine.append(source) { log::error!("Failed to append source to engine: {}", e); + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); return; } + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Ready, + track_id, + play_gen, + ); + thread_state.is_playing.store(true, Ordering::SeqCst); thread_state.position.store(0, Ordering::SeqCst); thread_state @@ -2143,6 +2844,52 @@ impl Player { start_position_secs ); *pause_suspend_deadline = None; + *current_direct_dsd = None; + // Clear any pending gapless state — a new + // PlayStreaming supersedes queued gapless, exactly + // as `AudioCommand::Play` does above. + // + // THIS ARM WAS THE ONLY START PATH MISSING IT + // (Play, PlayDsdDop, PlayDsdNative, Stop and Seek + // all clear). The consequences were not local, + // because a pending that outlives its engine is + // both a liar and a lock: + // + // - It LOCKS. The re-arm test below requires + // `gapless_pending.is_none()`, so the stale + // entry silently blocks gapless for the new + // track — no "approaching end" line is ever + // logged for it. + // - It LIES. The transition test is positional + // (`pos >= dur`), not engine-confirmed, so at + // the new track's natural end the stale pending + // fires anyway and swaps `current_track_id` to a + // track whose samples were appended to an engine + // that no longer exists. The engine goes empty + // one tick later. + // - And the swap BLINDS the frontend: its + // end-of-track predicate needs + // `track_id == 0 || track_id == last_track_id`, + // and the id has already moved — so the queue + // never advances and playback stops dead with a + // full queue. + // + // Observed 2026-08-05: `Gapless transition: track + // 371039 -> 269423327` where 269423327 had been + // queued SEVEN MINUTES EARLIER against a different + // track, and `269423306 -> 269423306`, a pending + // that had come to point at itself. Streaming is + // the common path for anything not already cached, + // which is why this reads as "the playlist only + // plays one track". + // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition + // survived the PlayStreaming fix, and guessing at the remaining + // path has failed twice. Every set/clear now says who did it. + log::info!("[gapless-trace] clear (PlayStreaming) pending was {:?}", gapless_pending.as_ref().map(|p| p.track_id)); + *gapless_pending = None; + *gapless_request_armed = false; + thread_state.set_gapless_ready(false); + thread_state.set_gapless_next_track_id(0); // Store streaming source for resume capability // When download completes, we can extract the data for resume @@ -2208,6 +2955,7 @@ impl Player { engine.stop(); std::thread::sleep(Duration::from_millis(50)); } + thread_state.set_hardware_volume_active(false); // Now this drop is the last Arc ref — PCM actually closes drop(stream_opt.take()); // Give kernel time to fully release the ALSA device @@ -2249,6 +2997,7 @@ impl Player { sample_rate, channels, dac_passthrough, + thread_state.clone(), ) .map(StreamType::rodio) }; @@ -2323,6 +3072,7 @@ impl Player { sample_rate, channels, dac_passthrough, + thread_state.clone(), ) .map(StreamType::rodio) }; @@ -2380,6 +3130,7 @@ impl Player { #[cfg(target_os = "linux")] std::thread::sleep(Duration::from_millis(50)); } + thread_state.set_hardware_volume_active(false); // Create PlaybackEngine let mut engine = match stream { @@ -2408,16 +3159,17 @@ impl Player { } } } - #[cfg(target_os = "linux")] - StreamType::AlsaDirect(alsa_stream) => { - let hardware_volume = thread_settings - .lock() - .ok() - .map(|s| s.alsa_hardware_volume) - .unwrap_or(false); - PlaybackEngine::new_alsa_direct( + StreamType::Direct(alsa_stream) => { + let (hardware_volume, hardware_volume_events) = + direct_hardware_volume_binding( + &thread_settings, + &thread_state, + ); + PlaybackEngine::new_direct( alsa_stream.clone(), hardware_volume, + thread_viz_tap.clone(), + hardware_volume_events, ) } #[cfg(target_os = "linux")] @@ -2514,6 +3266,11 @@ impl Player { thread_state.set_loaded_audio(false); thread_state.is_playing.store(false, Ordering::SeqCst); thread_state.record_stream_error(err_msg); + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); return; } if resume_buffer_target > 0 @@ -2535,7 +3292,14 @@ impl Player { // Create incremental streaming source - this starts playback IMMEDIATELY // while continuing to decode/download in background let incremental_source = - match IncrementalStreamingSource::new(source.clone()) { + match IncrementalStreamingSource::new_for_play( + source.clone(), + PlaybackBufferReporter::new( + thread_state.clone(), + track_id, + play_gen, + ), + ) { Ok(s) => s, Err(e) => { log::error!( @@ -2549,6 +3313,11 @@ impl Player { thread_state.record_stream_error( "Failed to start the streaming decoder", ); + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); return; } }; @@ -2595,15 +3364,6 @@ impl Player { log::info!("Streaming normalization: cache hit for track {}, gain {:.4}", track_id, cached_gain); } - // Notify analyzer of new track - let _ = analyzer_tx.try_send(AnalyzerMessage::NewTrack { - track_id, - sample_rate, - channels, - target_lufs, - gain_atomic: atomic.clone(), - }); - (rg_gain, Some(atomic)) } else { (None, None) @@ -2652,12 +3412,28 @@ impl Player { let source_to_play = wrap_source( source_to_play, normalization, - gain_atomic, + gain_atomic.clone(), &analyzer_tx, &analyzer_enabled, + Some(AnalyzerWaveformTrack { + track_id, + sample_rate: actual_sr, + channels: actual_ch, + duration_secs, + start_frame: start_position_secs + .saturating_mul(actual_sr as u64), + target_lufs: norm_settings, + gain_atomic, + }), + start_position_secs.saturating_mul(actual_sr as u64), ); if let Err(e) = engine.append(source_to_play) { log::error!("Failed to append streaming source to engine: {}", e); + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); return; } @@ -2687,6 +3463,14 @@ impl Player { path.display() ); *pause_suspend_deadline = None; + *current_direct_dsd = None; + // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition + // survived the PlayStreaming fix, and guessing at the remaining + // path has failed twice. Every set/clear now says who did it. + log::info!( + "[gapless-trace] clear (PlayDsdDop) pending was {:?}", + gapless_pending.as_ref().map(|p| p.track_id) + ); *gapless_pending = None; *gapless_request_armed = false; thread_state.set_gapless_ready(false); @@ -2719,6 +3503,7 @@ impl Player { engine.stop(); std::thread::sleep(Duration::from_millis(50)); } + thread_state.set_hardware_volume_active(false); drop(stream_opt.take()); std::thread::sleep(Duration::from_millis(50)); @@ -2748,7 +3533,7 @@ impl Player { carrier, device ); - *stream_opt = Some(StreamType::AlsaDirect(stream.clone())); + *stream_opt = Some(StreamType::Direct(stream.clone())); thread_state.set_current_device(Some(device)); *current_track_sample_rate = Some(carrier); *current_track_channels = Some(2); @@ -2770,6 +3555,10 @@ impl Player { thread_state.set_stream_quality(dsd_rate, 1); thread_state.duration.store(duration, Ordering::SeqCst); thread_state.set_dsd_mode(1); + *current_direct_dsd = Some(DirectDsdMedia { + path: path.clone(), + mode: 1, + }); thread_state.is_playing.store(true, Ordering::SeqCst); thread_state.position.store(0, Ordering::SeqCst); thread_state @@ -2794,6 +3583,14 @@ impl Player { path.display() ); *pause_suspend_deadline = None; + *current_direct_dsd = None; + // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition + // survived the PlayStreaming fix, and guessing at the remaining + // path has failed twice. Every set/clear now says who did it. + log::info!( + "[gapless-trace] clear (PlayDsdNative) pending was {:?}", + gapless_pending.as_ref().map(|p| p.track_id) + ); *gapless_pending = None; *gapless_request_armed = false; thread_state.set_gapless_ready(false); @@ -2819,6 +3616,7 @@ impl Player { engine.stop(); std::thread::sleep(Duration::from_millis(50)); } + thread_state.set_hardware_volume_active(false); drop(stream_opt.take()); std::thread::sleep(Duration::from_millis(50)); @@ -2866,7 +3664,7 @@ impl Player { if little_endian { "LE" } else { "BE" }, device ); - *stream_opt = Some(StreamType::AlsaDirect(stream.clone())); + *stream_opt = Some(StreamType::Direct(stream.clone())); thread_state.set_current_device(Some(device)); *current_track_sample_rate = Some(rate); *current_track_channels = Some(2); @@ -2887,7 +3685,12 @@ impl Player { thread_state.set_stream_error(false); thread_state.set_stream_quality(info.dsd_rate, 1); thread_state.duration.store(duration, Ordering::SeqCst); - thread_state.set_dsd_mode(if little_endian { 3 } else { 2 }); + let direct_mode = if little_endian { 3 } else { 2 }; + thread_state.set_dsd_mode(direct_mode); + *current_direct_dsd = Some(DirectDsdMedia { + path: path.clone(), + mode: direct_mode, + }); thread_state.is_playing.store(true, Ordering::SeqCst); thread_state.position.store(0, Ordering::SeqCst); thread_state @@ -2984,11 +3787,20 @@ impl Player { // resumes from current_audio_data (the // pause-suspend teardown is gated off in // DoP mode). + log::info!("[gapless-trace] set (dop) track {track_id}"); *gapless_pending = Some(GaplessPending { track_id, + play_generation: thread_state.current_play_generation(), duration_secs: duration, - data: Vec::new(), + media: GaplessMedia::Direct(DirectDsdMedia { + path: path.clone(), + mode, + }), + sample_rate: rate, + channels: 2, + bit_depth: 1, normalization_gain: None, + gain_atomic: None, }); thread_state.set_gapless_next_track_id(track_id); thread_state.set_gapless_ready(false); @@ -3091,16 +3903,17 @@ impl Player { } } } - #[cfg(target_os = "linux")] - StreamType::AlsaDirect(alsa_stream) => { - let hardware_volume = thread_settings - .lock() - .ok() - .map(|s| s.alsa_hardware_volume) - .unwrap_or(false); - PlaybackEngine::new_alsa_direct( + StreamType::Direct(alsa_stream) => { + let (hardware_volume, hardware_volume_events) = + direct_hardware_volume_binding( + &thread_settings, + &thread_state, + ); + PlaybackEngine::new_direct( alsa_stream.clone(), hardware_volume, + thread_viz_tap.clone(), + hardware_volume_events, ) } #[cfg(target_os = "linux")] @@ -3154,6 +3967,10 @@ impl Player { current_gain_atomic.clone(), &analyzer_tx, &analyzer_enabled, + None, + resume_pos.saturating_mul( + (*current_track_sample_rate).unwrap_or(0) as u64, + ), ); if let Err(e) = engine.append(skipped_source) { log::error!("Failed to append source for resume: {}", e); @@ -3179,10 +3996,19 @@ impl Player { if let Some(engine) = current_engine.take() { engine.stop(); } + thread_state.set_hardware_volume_active(false); *current_audio_data = None; *current_streaming_source = None; + *current_direct_dsd = None; *current_normalization_gain = None; *current_gain_atomic = None; + // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition + // survived the PlayStreaming fix, and guessing at the remaining + // path has failed twice. Every set/clear now says who did it. + log::info!( + "[gapless-trace] clear (Stop) pending was {:?}", + gapless_pending.as_ref().map(|p| p.track_id) + ); *gapless_pending = None; *gapless_request_armed = false; thread_state.set_gapless_ready(false); @@ -3216,9 +4042,14 @@ impl Player { log::info!("Audio thread: stopped"); } AudioCommand::SetVolume(volume) => { + let reported_volume = reported_volume_after_command( + volume, + matches!(stream_opt.as_ref(), Some(StreamType::Direct(_))), + thread_state.hardware_volume_active(), + ); thread_state .volume - .store(volume.to_bits(), Ordering::SeqCst); + .store(reported_volume.to_bits(), Ordering::SeqCst); if let Some(ref engine) = *current_engine { apply_engine_volume(&stream_opt, &engine, volume); } @@ -3229,19 +4060,123 @@ impl Player { log::debug!("Audio thread: volume set to {}", volume); } AudioCommand::Seek(position_secs) => { - if current_engine.as_ref().map(|e| e.is_dop()).unwrap_or(false) { - // v1 limitation: no seek inside a DoP stream - // (demuxer-level seek + marker re-phase later). - log::info!("Seek ignored during DoP playback ({}s)", position_secs); - return; - } *pause_suspend_deadline = None; - // Cancel any pending gapless — seek creates a new engine + // Seeking invalidates a queued successor in every + // engine, including direct DSD where the live + // source is replaced in place. + // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition + // survived the PlayStreaming fix, and guessing at the remaining + // path has failed twice. Every set/clear now says who did it. + log::info!( + "[gapless-trace] clear (Seek) pending was {:?}", + gapless_pending.as_ref().map(|p| p.track_id) + ); *gapless_pending = None; *gapless_request_armed = false; thread_state.set_gapless_ready(false); thread_state.set_gapless_next_track_id(0); + #[cfg(target_os = "linux")] + if current_engine.as_ref().map(|e| e.is_dop()).unwrap_or(false) { + let Some(media) = current_direct_dsd.as_ref().cloned() else { + log::error!( + "Direct DSD seek aborted: current container is unknown" + ); + thread_state.set_stream_error(true); + return; + }; + let target_secs = position_secs.min(thread_state.duration()); + let mut demux = match qbz_dsd::open_dsd(&media.path) { + Ok(d) => d, + Err(e) => { + log::error!("Direct DSD seek: cannot reopen source: {e}"); + thread_state.set_stream_error(true); + return; + } + }; + let dsd_rate = demux.info().dsd_rate; + if let Err(e) = + demux.seek_to_bit(target_secs.saturating_mul(dsd_rate as u64)) + { + log::error!("Direct DSD seek: demux seek failed: {e}"); + thread_state.set_stream_error(true); + return; + } + let built: Result< + (Box<dyn Iterator<Item = i32> + Send>, u32), + String, + > = match media.mode { + 1 => qbz_dsd::DopStream::new(demux) + .map_err(|e| e.to_string()) + .map(|source| { + let rate = source.carrier_rate(); + ( + Box::new(DsdErrorReport::new( + source, + thread_state.clone(), + )) + as Box<dyn Iterator<Item = i32> + Send>, + rate, + ) + }), + 2 | 3 => qbz_dsd::NativeDsdStream::new(demux, media.mode == 3) + .map_err(|e| e.to_string()) + .map(|source| { + let rate = source.rate(); + ( + Box::new(DsdErrorReport::new( + source, + thread_state.clone(), + )) + as Box<dyn Iterator<Item = i32> + Send>, + rate, + ) + }), + _ => Err(format!("unknown direct DSD mode {}", media.mode)), + }; + let (source, output_rate) = match built { + Ok(v) => v, + Err(e) => { + log::error!("Direct DSD seek: source build failed: {e}"); + thread_state.set_stream_error(true); + return; + } + }; + if *current_track_sample_rate != Some(output_rate) { + log::error!( + "Direct DSD seek: active rate {:?} differs from source rate {}", + *current_track_sample_rate, + output_rate + ); + thread_state.set_stream_error(true); + return; + } + let Some(engine) = current_engine.as_mut() else { + return; + }; + if let Err(e) = engine.replace_dop( + source, + target_secs.saturating_mul(output_rate as u64), + ) { + log::error!("Direct DSD seek: replace failed: {e}"); + thread_state.set_stream_error(true); + return; + } + + let was_playing = thread_state.is_playing.load(Ordering::SeqCst); + thread_state.position.store(target_secs, Ordering::SeqCst); + if was_playing { + thread_state.start_playback_timer(target_secs); + } + thread_state.set_stream_error(false); + log::info!( + "Audio thread: direct DSD seeked to {}s (was_playing: {})", + target_secs, + was_playing + ); + return; + } + // Three cases reach this handler: // * full-file playback (current_audio_data set) // * CMAF streaming, download complete (buffered @@ -3310,6 +4245,7 @@ impl Player { if let Some(engine) = current_engine.take() { engine.stop(); } + thread_state.set_hardware_volume_active(false); let seek_abort = |thread_state: &SharedState, why: &str| { log::error!("Audio thread: seek aborted: {why}"); thread_state.is_playing.store(false, Ordering::SeqCst); @@ -3329,16 +4265,17 @@ impl Player { } } } - #[cfg(target_os = "linux")] - StreamType::AlsaDirect(alsa_stream) => { - let hardware_volume = thread_settings - .lock() - .ok() - .map(|s| s.alsa_hardware_volume) - .unwrap_or(false); - PlaybackEngine::new_alsa_direct( + StreamType::Direct(alsa_stream) => { + let (hardware_volume, hardware_volume_events) = + direct_hardware_volume_binding( + &thread_settings, + &thread_state, + ); + PlaybackEngine::new_direct( alsa_stream.clone(), hardware_volume, + thread_viz_tap.clone(), + hardware_volume_events, ) } #[cfg(target_os = "linux")] @@ -3444,6 +4381,10 @@ impl Player { current_gain_atomic.clone(), &analyzer_tx, &analyzer_enabled, + None, + position_secs.saturating_mul( + (*current_track_sample_rate).unwrap_or(0) as u64, + ), ); if let Err(e) = engine.append(skipped_source) { seek_abort( @@ -3483,10 +4424,40 @@ impl Player { if let Some(engine) = current_engine.take() { engine.stop(); } + thread_state.set_hardware_volume_active(false); drop(stream_opt.take()); log::info!("Audio thread: previous stream dropped, device released"); + // ReloadSettings updates `thread_settings` before + // this command is queued. When the NEW route is no + // longer ALSA Direct, finish the teardown the same + // way ReleaseDevice does: dropping the PCM alone + // releases the fd/reservation but leaves the + // PipeWire sink QBZ suspended. Keep it suspended + // for direct->direct reinitialization so PipeWire + // cannot race the immediate exclusive reopen. + #[cfg(target_os = "linux")] + { + let next_is_alsa_direct = thread_settings + .lock() + .ok() + .map(|settings| { + qbz_audio::alsa_direct::uses_alsa_direct_route(&settings) + }) + .unwrap_or(false); + if !next_is_alsa_direct { + if let Err(error) = + qbz_audio::alsa_backend::resume_suspended_sink() + { + log::warn!( + "Audio thread: release during reinit was incomplete: {error}" + ); + } + } + qbz_audio::pipewire_backend::PipeWireBackend::reset_pipewire_clock(); + } + std::thread::sleep(Duration::from_millis(100)); *current_device_name = new_device; @@ -3511,7 +4482,7 @@ impl Player { // Keep current_audio_data and current_streaming_source // intact so Resume can recreate the engine and seek. } - AudioCommand::ReleaseDevice => { + AudioCommand::ReleaseDevice { completed } => { log::info!("Audio thread: releasing output device (user-requested)"); // Cancel any deferred drop and tear the stream down NOW so // the device is freed immediately (no warm-stream lingering). @@ -3519,27 +4490,33 @@ impl Player { if let Some(engine) = current_engine.take() { engine.stop(); } + thread_state.set_hardware_volume_active(false); drop(stream_opt.take()); // Undo anything QBZ parked so PipeWire / WirePlumber can // reclaim the device (e.g. a DAC left invisible to other // apps after bit-perfect ALSA Direct held it exclusively). // Both calls are self-gating no-ops if QBZ didn't set them. #[cfg(target_os = "linux")] - { - qbz_audio::alsa_backend::resume_suspended_sink(); + let release_result = { + let result = qbz_audio::alsa_backend::resume_suspended_sink(); qbz_audio::pipewire_backend::PipeWireBackend::reset_pipewire_clock(); - } + result + }; + #[cfg(not(target_os = "linux"))] + let release_result = Ok(()); thread_state.pause_playback_timer(); thread_state.is_playing.store(false, Ordering::SeqCst); // Keep current_audio_data / current_streaming_source intact // so a later Play / Resume reopens and continues. log::info!("Audio thread: output device released"); + let _ = completed.send(release_result); } AudioCommand::PlayNext { data, track_id, sample_rate, channels, + bit_depth, } => { // Gapless: append next track to existing Rodio Sink let engine = match current_engine.as_mut() { @@ -3568,13 +4545,6 @@ impl Player { } } - // Don't queue if already streaming - if current_streaming_source.is_some() { - log::info!("Gapless: streaming source active, ignoring PlayNext for track {}", track_id); - thread_state.set_gapless_ready(false); - return; - } - // Decode the next track's audio let source = match decode_with_fallback(&data) { Ok(s) => s, @@ -3609,25 +4579,29 @@ impl Player { let cached_gain = db_to_linear(cached.gain_db.min(6.0)); atomic.store(cached_gain.to_bits(), Ordering::Relaxed); } - let _ = analyzer_tx.try_send(AnalyzerMessage::NewTrack { - track_id, - sample_rate, - channels, - target_lufs, - gain_atomic: atomic.clone(), - }); (rg_gain, Some(atomic)) } else { (None, None) }; // Wrap source with normalization/visualizer pipeline + let pending_gain_atomic = gain_atomic.clone(); let source = wrap_source( source, normalization, - gain_atomic, + gain_atomic.clone(), &analyzer_tx, &analyzer_enabled, + Some(AnalyzerWaveformTrack { + track_id, + sample_rate, + channels, + duration_secs: actual_duration, + start_frame: 0, + target_lufs: norm_settings, + gain_atomic, + }), + 0, ); // Append to existing Sink (gapless queue). @@ -3664,11 +4638,17 @@ impl Player { } // Store pending gapless data for transition detection + log::info!("[gapless-trace] set (pcm) track {track_id}"); *gapless_pending = Some(GaplessPending { track_id, + play_generation: thread_state.current_play_generation(), duration_secs: actual_duration, - data, + media: GaplessMedia::Buffered(data), + sample_rate, + channels, + bit_depth, normalization_gain: normalization, + gain_atomic: pending_gain_atomic, }); thread_state.set_gapless_next_track_id(track_id); thread_state.set_gapless_ready(false); // Request fulfilled @@ -3679,6 +4659,151 @@ impl Player { actual_duration ); } + AudioCommand::PlayNextStreaming { + source, + track_id, + sample_rate, + channels, + bit_depth, + duration_secs, + } => { + let engine = match current_engine.as_mut() { + Some(engine) => engine, + None => { + log::warn!( + "Gapless stream: no engine for successor {}", + track_id + ); + thread_state.cancel_gapless_stream_feeder(track_id); + thread_state.set_gapless_ready(false); + return; + } + }; + if let (Some(cur_sr), Some(cur_ch)) = + (*current_track_sample_rate, *current_track_channels) + { + if sample_rate != cur_sr || channels != cur_ch { + log::info!( + "Gapless stream: format mismatch (current {}Hz/{}ch vs next {}Hz/{}ch), ignoring track {}", + cur_sr, + cur_ch, + sample_rate, + channels, + track_id + ); + thread_state.cancel_gapless_stream_feeder(track_id); + thread_state.set_gapless_ready(false); + return; + } + } + if !source.has_min_buffer() { + log::warn!( + "Gapless stream: initial buffer not ready for track {}", + track_id + ); + thread_state.cancel_gapless_stream_feeder(track_id); + thread_state.set_gapless_ready(false); + return; + } + + let incremental = match IncrementalStreamingSource::new_for_play( + source.clone(), + PlaybackBufferReporter::new( + thread_state.clone(), + track_id, + thread_state.current_play_generation(), + ), + ) { + Ok(source) => source, + Err(error) => { + log::warn!( + "Gapless stream: decoder setup failed for track {}: {}", + track_id, + error + ); + thread_state.cancel_gapless_stream_feeder(track_id); + thread_state.set_gapless_ready(false); + return; + } + }; + let norm_settings = thread_settings + .lock() + .ok() + .filter(|settings| settings.normalization_enabled) + .map(|settings| settings.normalization_target_lufs); + let (normalization, gain_atomic) = if let Some(target_lufs) = norm_settings + { + let replay_gain = source.get_buffered_data().and_then(|data| { + extract_replaygain(&data) + .map(|gain| calculate_gain_factor(&gain, target_lufs)) + }); + let atomic = Arc::new(AtomicU32::new( + replay_gain.unwrap_or(1.0).to_bits(), + )); + if let Some(cached) = loudness_cache.get(track_id) { + atomic.store( + db_to_linear(cached.gain_db.min(6.0)).to_bits(), + Ordering::Relaxed, + ); + } + (replay_gain, Some(atomic)) + } else { + (None, None) + }; + let pending_gain_atomic = gain_atomic.clone(); + let source_to_play = wrap_source( + Box::new(incremental), + normalization, + gain_atomic.clone(), + &analyzer_tx, + &analyzer_enabled, + Some(AnalyzerWaveformTrack { + track_id, + sample_rate, + channels, + duration_secs, + start_frame: 0, + target_lufs: norm_settings, + gain_atomic, + }), + 0, + ); + let engine_was_empty = engine.empty(); + if let Err(error) = engine.append(source_to_play) { + log::warn!( + "Gapless stream: append failed for track {}: {}", + track_id, + error + ); + thread_state.cancel_gapless_stream_feeder(track_id); + thread_state.set_gapless_ready(false); + return; + } + if engine_was_empty + && !thread_state.is_playing.load(Ordering::SeqCst) + { + thread_state.is_playing.store(true, Ordering::SeqCst); + } + + log::info!("[gapless-trace] set (stream) track {track_id}"); + *gapless_pending = Some(GaplessPending { + track_id, + play_generation: thread_state.current_play_generation(), + duration_secs, + media: GaplessMedia::Streaming(source), + sample_rate, + channels, + bit_depth, + normalization_gain: normalization, + gain_atomic: pending_gain_atomic, + }); + thread_state.set_gapless_next_track_id(track_id); + thread_state.set_gapless_ready(false); + log::info!( + "Gapless stream: queued track {} after its initial buffer", + track_id + ); + } } }; @@ -3690,6 +4815,7 @@ impl Player { &mut current_engine, &mut current_audio_data, &mut current_streaming_source, + &mut current_direct_dsd, &mut stream_opt, &mut current_device_name, &mut consecutive_sink_failures, @@ -3721,18 +4847,59 @@ impl Player { if let Some(streaming_src) = current_streaming_source.as_ref() { if streaming_src.is_complete() { if current_audio_data.is_none() { - if let Some(full_data) = - streaming_src.take_complete_data() - { - log::info!( - "Streaming promotion: full track buffered ({} bytes), enabling cached transition path", - full_data.len() - ); - current_audio_data = Some(full_data); + // Low-memory profile + oversized + // track: skip the promotion clone + // (take_complete_data copies the + // whole buffer — a persistent 2x + // RSS for the rest of the track) + // and KEEP the streaming source: + // seek/resume then read the + // single buffered copy. The + // gapless pre-queue gate below + // requires the source cleared, so + // such a track transitions with a + // small gap instead of gapless — + // accepted trade vs. an OOM kill + // on 1 GB hosts (issue #660). The + // L2 disk copy is written by the + // CMAF feeder task, off this + // thread. + let skip_promotion = + memory_tuning::is_low_memory_class() + && memory_tuning::oversized_for_l1( + streaming_src.buffer_size(), + memory_tuning::audio_cache_l1_max_bytes(), + ); + if skip_promotion { + if !low_mem_promotion_skip_logged { + low_mem_promotion_skip_logged = true; + log::info!( + "Streaming promotion skipped (low-memory host): {} bytes exceeds the oversized threshold — keeping the single buffered copy", + streaming_src.buffer_size() + ); + } + } else { + if let Some(full_data) = + streaming_src.take_complete_data() + { + log::info!( + "Streaming promotion: full track buffered ({} bytes), enabling cached transition path", + full_data.len() + ); + current_audio_data = Some(full_data); + } + clear_streaming_source = true; } + } else { + clear_streaming_source = true; } - clear_streaming_source = true; + } else { + // Still downloading: re-arm the + // skip log for the next track. + low_mem_promotion_skip_logged = false; } + } else { + low_mem_promotion_skip_logged = false; } if clear_streaming_source { current_streaming_source = None; @@ -3776,7 +4943,21 @@ impl Player { // (clean gapless transition), but never the inconsistent // mid-swap mix. if let Some(ref pending) = gapless_pending { - if dur > 0 && pos >= dur { + // A streaming reader can only hand off + // after its feeder has sealed the source. + // The wall-clock timer may reach catalog + // duration while a stalled feeder is still + // alive; promoting then would drop its last + // cancel sender and truncate the outgoing + // tail. Cached sources have no such gate. + let outgoing_stream_sealed = current_streaming_source + .as_ref() + .map(|source| { + source.is_complete() + || source.download_error().is_some() + }) + .unwrap_or(true); + if dur > 0 && pos >= dur && outgoing_stream_sealed { log::info!( "Gapless transition: track {} -> {} (pos {}s >= dur {}s)", thread_state.current_track_id.load(Ordering::SeqCst), @@ -3795,10 +4976,42 @@ impl Player { .duration .store(pending.duration_secs, Ordering::SeqCst); thread_state.start_playback_timer(0); - current_audio_data = Some(pending.data.clone()); + match &pending.media { + GaplessMedia::Buffered(data) => { + current_audio_data = Some(data.clone()); + current_streaming_source = None; + } + GaplessMedia::Streaming(source) => { + current_audio_data = None; + current_streaming_source = Some(source.clone()); + thread_state.promote_gapless_stream_feeder( + pending.track_id, + ); + } + GaplessMedia::Direct(media) => { + current_audio_data = None; + current_streaming_source = None; + current_direct_dsd = Some(media.clone()); + } + } + thread_state.adopt_buffered_track( + pending.track_id, + PlaybackBufferState::Ready, + pending.play_generation, + ); + current_track_sample_rate = Some(pending.sample_rate); + current_track_channels = Some(pending.channels); + thread_state + .set_stream_quality(pending.sample_rate, pending.bit_depth); + thread_state.set_buffer_progress(0.0); current_normalization_gain = pending.normalization_gain; + current_gain_atomic = pending.gain_atomic.clone(); thread_state .set_normalization_gain(pending.normalization_gain); + // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition + // survived the PlayStreaming fix, and guessing at the remaining + // path has failed twice. Every set/clear now says who did it. + log::info!("[gapless-trace] clear (transition-consumed) pending was {:?}", gapless_pending.as_ref().map(|p| p.track_id)); gapless_pending = None; gapless_request_armed = false; transition_consumed_pending = true; @@ -3827,10 +5040,44 @@ impl Player { .duration .store(pending.duration_secs, Ordering::SeqCst); thread_state.start_playback_timer(0); - current_audio_data = Some(pending.data.clone()); + match &pending.media { + GaplessMedia::Buffered(data) => { + current_audio_data = Some(data.clone()); + current_streaming_source = None; + } + GaplessMedia::Streaming(source) => { + current_audio_data = None; + current_streaming_source = Some(source.clone()); + thread_state.promote_gapless_stream_feeder( + pending.track_id, + ); + } + GaplessMedia::Direct(media) => { + current_audio_data = None; + current_streaming_source = None; + current_direct_dsd = Some(media.clone()); + } + } + thread_state.adopt_buffered_track( + pending.track_id, + PlaybackBufferState::Ready, + pending.play_generation, + ); + current_track_sample_rate = Some(pending.sample_rate); + current_track_channels = Some(pending.channels); + thread_state.set_stream_quality( + pending.sample_rate, + pending.bit_depth, + ); + thread_state.set_buffer_progress(0.0); current_normalization_gain = pending.normalization_gain; + current_gain_atomic = pending.gain_atomic.clone(); thread_state .set_normalization_gain(pending.normalization_gain); + // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition + // survived the PlayStreaming fix, and guessing at the remaining + // path has failed twice. Every set/clear now says who did it. + log::info!("[gapless-trace] clear (alsa-transition-consumed) pending was {:?}", gapless_pending.as_ref().map(|p| p.track_id)); gapless_pending = None; gapless_request_armed = false; transition_consumed_pending = true; @@ -3871,7 +5118,6 @@ impl Player { && !gapless_request_armed && !thread_state.is_gapless_ready() && thread_state.get_gapless_next_track_id() == 0 - && current_streaming_source.is_none() { log::info!("Gapless: approaching end of track ({}s/{}s), requesting next", pos, dur); thread_state.set_gapless_ready(true); @@ -3884,6 +5130,9 @@ impl Player { && thread_state.is_playing.load(Ordering::SeqCst) { log::info!("Audio thread: track finished (engine empty)"); + let ended_track_id = thread_state + .current_track_id + .load(Ordering::SeqCst); thread_state.is_playing.store(false, Ordering::SeqCst); let duration = thread_state.duration.load(Ordering::SeqCst); thread_state.position.store(duration, Ordering::SeqCst); @@ -3893,8 +5142,22 @@ impl Player { // Clear gapless state on track end thread_state.set_gapless_ready(false); thread_state.set_gapless_next_track_id(0); + // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition + // survived the PlayStreaming fix, and guessing at the remaining + // path has failed twice. Every set/clear now says who did it. + log::info!("[gapless-trace] clear (engine-empty) pending was {:?}", gapless_pending.as_ref().map(|p| p.track_id)); gapless_pending = None; gapless_request_armed = false; + // Publish only after every stopped/end + // field above is coherent. The + // generation is the release edge the + // pollers key on. + thread_state.record_engine_empty(ended_track_id); + thread_state.set_buffer_state_for_play( + PlaybackBufferState::Idle, + ended_track_id, + thread_state.current_play_generation(), + ); } } } @@ -3919,6 +5182,7 @@ impl Player { if let Some(engine) = current_engine.take() { engine.stop(); } + thread_state.set_hardware_volume_active(false); drop(stream_opt.take()); pause_suspend_deadline = None; // Reset the PipeWire clock if WE forced it (self-gating; @@ -3931,7 +5195,7 @@ impl Player { // above), so resume any PipeWire sink we suspended for // exclusive access — self-gating no-op otherwise (#263). #[cfg(target_os = "linux")] - qbz_audio::alsa_backend::resume_suspended_sink(); + let _ = qbz_audio::alsa_backend::resume_suspended_sink(); log::info!("Audio thread: suspended stream after pause"); continue; } @@ -3944,6 +5208,7 @@ impl Player { &mut current_engine, &mut current_audio_data, &mut current_streaming_source, + &mut current_direct_dsd, &mut stream_opt, &mut current_device_name, &mut consecutive_sink_failures, @@ -3972,6 +5237,7 @@ impl Player { &mut current_engine, &mut current_audio_data, &mut current_streaming_source, + &mut current_direct_dsd, &mut stream_opt, &mut current_device_name, &mut consecutive_sink_failures, @@ -3992,17 +5258,21 @@ impl Player { } }); - // Two-level playback cache: L1 in memory (~400 MB), L2 on disk - // (~800 MB). A disk-cache failure degrades to L1-only rather than - // aborting player creation. + // Two-level playback cache: L1 in memory, L2 on disk (~800 MB). The + // L1 budget comes from the host memory profile pushed down via + // `memory_tuning` at process start (400 MB Normal / 50 MB LowMemory) + // — on a 1 GB host the historical 400 MB hardcode alone could eat + // 40 % of RAM (issue #660). A disk-cache failure degrades to + // L1-only rather than aborting player creation. + let l1_max_bytes = memory_tuning::audio_cache_l1_max_bytes(); let audio_cache = match qbz_cache::PlaybackCache::new(800 * 1024 * 1024) { Ok(pc) => Arc::new(qbz_cache::AudioCache::with_playback_cache( - 400 * 1024 * 1024, + l1_max_bytes, Arc::new(pc), )), Err(e) => { log::warn!("Playback disk cache unavailable: {e}; memory cache only"); - Arc::new(qbz_cache::AudioCache::new(400 * 1024 * 1024)) + Arc::new(qbz_cache::AudioCache::new(l1_max_bytes)) } }; @@ -4024,7 +5294,14 @@ impl Player { /// previous track (last-writer race on `AudioCommand::Play`), and so the /// audio thread can abandon superseded buffer waits (#591). fn begin_play(&self) -> u64 { - self.state.begin_play() + let gen = self.state.begin_play(); + // The bump comes first so a concurrent `register_stream_feeder` sees + // the new generation; then cancel the previous track's feeder — + // every caller of `begin_play` is a superseding intent (new play or + // stop), so the old download is by definition abandoned work and + // only competes with the new track's fetches for the link. + self.state.cancel_stream_feeder(); + gen } /// A passing check is a snapshot, not a lock: a newer intent can still @@ -4177,6 +5454,9 @@ impl Player { let flac_header = cmaf_info.flac_header; let n_segments = cmaf_info.n_segments; let cache = self.audio_cache.clone(); + let feeder_state = self.state.clone(); + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let slot_writer = buffer_writer.clone(); tokio::spawn(async move { match Self::cmaf_stream_segments( @@ -4188,20 +5468,32 @@ impl Player { track_id, cache, skip_cache, + total_flac_size, + cancel_rx, ) .await { - Ok(()) => log::info!( + // `false` = cancelled on supersede; already logged by + // the feeder itself, and it is not a completion. + Ok(true) => log::info!( "[CMAF-STREAM COMPLETE] Track {}", track_id ), - Err(e) => log::error!( - "[CMAF-STREAM ERROR] Track {}: {}", - track_id, - e - ), + Ok(false) => {} + Err(e) => { + feeder_state.record_source_failure(track_id, gen); + log::error!( + "[CMAF-STREAM ERROR] Track {}: {} — queue recovery armed", + track_id, + e + ); + } } }); + // Register for cancel-on-supersede: the next `begin_play` + // stops this download instead of letting it hold the link. + self.state + .register_stream_feeder(track_id, cancel_tx, slot_writer, gen); return Ok(()); } @@ -4226,9 +5518,9 @@ impl Player { let stream_url = client .get_stream_url_with_fallback(track_id, quality) .await - .map_err(|e| { - log::error!("Player: Failed to get stream URL: {}", e); - format!("Failed to get stream URL: {}", e) + .map_err(|_| { + log::error!("Player: Failed to get stream URL (details omitted)"); + "Failed to get stream URL".to_string() })?; if !self.is_current_play(gen) { @@ -4238,11 +5530,7 @@ impl Player { return Ok(()); } - log::info!( - "Player: Got stream URL: {} (format: {})", - stream_url.url, - stream_url.mime_type - ); + log::info!("Player: Got stream URL (format: {})", stream_url.mime_type); // Download the audio data log::info!("Player: Starting audio caching..."); @@ -4272,15 +5560,148 @@ impl Player { r } + /// Queue a cold Qobuz successor as an incremental source. This is the + /// Streaming-only counterpart to [`Self::fetch_for_gapless`]: only the + /// initial buffer must win the final 10-second race, so track length no + /// longer determines whether the transition is seamless. + pub async fn queue_next_streaming( + &self, + client: &QobuzClient, + track_id: u64, + quality: Quality, + ) -> Result<(), String> { + let gen = self.state.current_play_generation(); + let cmaf_info = qbz_qobuz::cmaf::setup_streaming(client, track_id, quality).await?; + if !self.is_current_play(gen) { + return Err("current track changed during gapless stream setup".to_string()); + } + + let sample_rate = cmaf_info.sampling_rate.unwrap_or(44_100); + let channels = 2u16; + let bit_depth = cmaf_info.bit_depth.unwrap_or(16); + let total_flac_size = cmaf_info.flac_header.len() as u64 + + cmaf_info + .segment_table + .iter() + .map(|segment| segment.byte_len as u64) + .sum::<u64>(); + let total_samples: u64 = cmaf_info + .segment_table + .iter() + .map(|segment| segment.sample_count as u64) + .sum(); + let duration_secs = total_samples / sample_rate.max(1) as u64; + let speed_mbps = if cmaf_info.init_fetch_ms > 0 { + let init_bytes = cmaf_info.flac_header.len() as f64 + 4096.0; + (init_bytes / (cmaf_info.init_fetch_ms as f64 / 1000.0)) / (1024.0 * 1024.0) + } else { + 10.0 + }; + + let mut config = StreamingConfig::from_speed_mbps(speed_mbps); + let user_secs = self + .audio_settings + .lock() + .map(|settings| settings.stream_buffer_seconds) + .unwrap_or(2); + let bytes_per_second = total_flac_size / duration_secs.max(1); + let user_floor = (user_secs as u64).saturating_mul(bytes_per_second) as usize; + config.initial_buffer_bytes = config + .initial_buffer_bytes + .max(user_floor) + .clamp(256 * 1024, 8 * 1024 * 1024); + + let (source, writer) = BufferedMediaSource::new(config, Some(total_flac_size)); + let source = Arc::new(source); + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + if !self.state.register_gapless_stream_feeder( + track_id, + cancel_tx, + writer.clone(), + gen, + ) { + return Err("gapless stream was superseded before download".to_string()); + } + + let url_template = cmaf_info.url_template; + let content_key = cmaf_info.content_key; + let flac_header = cmaf_info.flac_header; + let n_segments = cmaf_info.n_segments; + let cache = self.audio_cache.clone(); + tokio::spawn(async move { + match Self::cmaf_stream_segments( + &url_template, + n_segments, + content_key, + flac_header, + writer, + track_id, + cache, + true, + total_flac_size, + cancel_rx, + ) + .await + { + Ok(true) => log::info!("[GAPLESS-STREAM] Track {track_id} fully buffered"), + Ok(false) => {} + Err(error) => { + log::warn!("[GAPLESS-STREAM] Track {track_id} feeder failed: {error}") + } + } + }); + + let wait_started = Instant::now(); + const INITIAL_BUFFER_TIMEOUT: Duration = Duration::from_secs(8); + while !source.has_min_buffer() + && source.download_error().is_none() + && wait_started.elapsed() < INITIAL_BUFFER_TIMEOUT + && self.is_current_play(gen) + { + tokio::time::sleep(Duration::from_millis(20)).await; + } + if !self.is_current_play(gen) { + self.state.cancel_gapless_stream_feeder(track_id); + return Err("gapless stream superseded during initial buffering".to_string()); + } + if let Some(error) = source.download_error() { + self.state.cancel_gapless_stream_feeder(track_id); + return Err(error); + } + if !source.has_min_buffer() { + self.state.cancel_gapless_stream_feeder(track_id); + return Err("gapless stream initial buffer timed out".to_string()); + } + + self.tx + .send(AudioCommand::PlayNextStreaming { + source, + track_id, + sample_rate, + channels, + bit_depth, + duration_secs, + }) + .map_err(|error| { + self.state.cancel_gapless_stream_feeder(track_id); + format!("failed to queue gapless stream: {error}") + })?; + log::info!( + "[GAPLESS-STREAM] Track {} initial buffer ready in {}ms", + track_id, + wait_started.elapsed().as_millis() + ); + Ok(()) + } + /// Download a track fully into the L1/L2 cache **without** starting /// playback. /// - /// Gapless playback requires upcoming tracks to be cache hits so they - /// play via `play_data` (fully in-memory) rather than the streaming - /// path — the audio engine's `PlayNext` handler ignores gapless - /// requests while a streaming source is active. This method is the - /// prefetch primitive the controller drives for the next 1-2 queue - /// tracks. + /// Normal cached gapless playback benefits from upcoming tracks being + /// cache hits so they can be appended fully in-memory. Streaming only + /// uses [`Self::queue_next_streaming`] instead, which queues a cold + /// successor after its initial buffer. This method remains the prefetch + /// primitive the controller drives for the next 1-2 queue tracks. /// /// Mirrors the Tauri V2 prefetch download: CMAF `download_full` first /// (Akamai CDN), legacy `/track/getFileUrl` full download as fallback. @@ -4382,6 +5803,13 @@ impl Player { self.audio_cache.clear(); } + /// Drop only the L1 in-memory entries, keeping the L2 disk cache. The + /// memory-pressure watchdog's relief valve (issue #660): freeing RAM is + /// what relieves the pressure; the disk files cost nothing to keep. + pub fn evict_l1_audio_cache(&self) { + self.audio_cache.evict_all_memory(); + } + /// Fetch a track's audio bytes for a gapless handoff: L1 memory → /// L2 disk → CMAF `download_full` (legacy full download as fallback). /// Does not start playback — the caller passes the bytes to @@ -4462,6 +5890,99 @@ impl Player { /// not known here (no metadata stored with the bytes) — the caller derives /// the quality label from the track's catalog metadata; the network path /// returns the precise resolved tier. + /// Open a Qobuz track as a PROGRESSIVE byte source for an external + /// renderer (Chromecast / DLNA): the CMAF download starts immediately, + /// the returned buffer exposes the bytes as they land, and the exact + /// final FLAC size is known up front (header + segment table) so the + /// media server can advertise a truthful Content-Length. Returns once the + /// initial buffer (first segments) is in, i.e. within ~0.3-1 s instead of + /// after the whole file (5-6 s for a 100 MB Hi-Res track on a fast line, + /// measured 2026-08-30). The finished download is cached like a played + /// stream, so a replay / re-cast is served from memory. + /// + /// Cached tracks should NOT come through here — `fetch_for_external_stream` + /// answers them from L1/L2 with no network at all. + pub async fn open_external_stream( + &self, + client: &QobuzClient, + track_id: u64, + quality: Quality, + ) -> Result<ExternalStreamHandle, String> { + let cmaf_info = qbz_qobuz::cmaf::setup_streaming(client, track_id, quality).await?; + let total_bytes = cmaf_info.flac_header.len() as u64 + + cmaf_info + .segment_table + .iter() + .map(|segment| segment.byte_len as u64) + .sum::<u64>(); + let (source, writer) = + BufferedMediaSource::new(StreamingConfig::fast_start(), Some(total_bytes)); + let source = Arc::new(source); + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + + let url_template = cmaf_info.url_template; + let content_key = cmaf_info.content_key; + let flac_header = cmaf_info.flac_header; + let n_segments = cmaf_info.n_segments; + let cache = self.audio_cache.clone(); + tokio::spawn(async move { + match Self::cmaf_stream_segments( + &url_template, + n_segments, + content_key, + flac_header, + writer, + track_id, + cache, + false, + total_bytes, + cancel_rx, + ) + .await + { + Ok(true) => log::info!("[CAST-STREAM] Track {track_id} fully downloaded"), + Ok(false) => {} + Err(error) => log::warn!("[CAST-STREAM] Track {track_id} feeder failed: {error}"), + } + }); + + // Hand the source over only once the renderer's first read can be + // answered without stalling on the network (header + first segment). + const INITIAL_BUFFER_TIMEOUT: Duration = Duration::from_secs(8); + let waiter = Arc::clone(&source); + let ready = tokio::time::timeout( + INITIAL_BUFFER_TIMEOUT, + tokio::task::spawn_blocking(move || waiter.wait_for_initial_buffer()), + ) + .await; + match ready { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(error))) => { + let _ = cancel_tx.send(true); + return Err(format!("cast stream download failed: {error}")); + } + Ok(Err(join)) => { + let _ = cancel_tx.send(true); + return Err(format!("cast stream waiter failed: {join}")); + } + Err(_) => { + let _ = cancel_tx.send(true); + return Err("cast stream initial buffer timed out".to_string()); + } + } + log::info!( + "[CAST-STREAM] Track {track_id} progressive source ready ({} B buffered of {total_bytes})", + source.buffer_size() + ); + Ok(ExternalStreamHandle { + source, + total_bytes, + sample_rate: cmaf_info.sampling_rate, + bit_depth: cmaf_info.bit_depth, + cancel: cancel_tx, + }) + } + pub async fn fetch_for_external_stream( &self, client: &QobuzClient, @@ -4572,6 +6093,17 @@ impl Player { /// then fetches each audio segment, decrypts encrypted frames, and pushes /// the resulting FLAC frame data to the streaming buffer. The player /// starts playing as soon as enough data is buffered. + /// + /// `expected_total_bytes` is the assembled FLAC size known from the + /// segment table; on a LowMemory host it decides up front whether the + /// track is oversized for the L1 budget, in which case NO parallel RAM + /// accumulator is built and the finished track goes straight to the L2 + /// disk cache, streamed out of the playback buffer in chunks (issue + /// #660). + /// + /// `cancel_rx` flips to `true` when a newer play intent supersedes this + /// stream (see `SharedState::cancel_stream_feeder`). Returns `Ok(true)` + /// on real completion, `Ok(false)` when cancelled. async fn cmaf_stream_segments( url_template: &str, n_segments: u8, @@ -4581,7 +6113,9 @@ impl Player { track_id: u64, cache: Arc<qbz_cache::AudioCache>, skip_cache: bool, - ) -> Result<(), String> { + expected_total_bytes: u64, + mut cancel_rx: tokio::sync::watch::Receiver<bool>, + ) -> Result<bool, String> { struct FailGuard { writer: BufferWriter, armed: bool, @@ -4615,25 +6149,51 @@ impl Player { let mut total_written: u64 = flac_header.len() as u64; // Accumulate the assembled FLAC (header + decrypted frames) so the // finished track can be cached for instant replay. Empty when - // `skip_cache` (streaming_only) is set. - let mut cache_data: Vec<u8> = if skip_cache { - Vec::new() - } else { + // `skip_cache` (streaming_only) is set — and never built at all for + // an oversized track on a LowMemory host, where a second full copy + // of the track in RAM next to the playback buffer is the issue #660 + // OOM recipe; that track goes to the L2 disk cache at the end. + let low_mem_oversized = !skip_cache + && memory_tuning::is_low_memory_class() + && memory_tuning::oversized_for_l1( + expected_total_bytes as usize, + memory_tuning::audio_cache_l1_max_bytes(), + ); + let accumulate_cache = !skip_cache && !low_mem_oversized; + let mut cache_data: Vec<u8> = if accumulate_cache { flac_header.clone() + } else { + Vec::new() }; let start = Instant::now(); for seg_idx in 1..=n_segments { let seg_url = url_template.replace("$SEGMENT$", &seg_idx.to_string()); - let seg_data = client - .get(&seg_url) - .header("User-Agent", "Mozilla/5.0") - .send() - .await - .map_err(|e| format!("CMAF segment {} fetch: {}", seg_idx, e))? - .bytes() - .await - .map_err(|e| format!("CMAF segment {} read: {}", seg_idx, e))?; + let fetch_tag = format!("CMAF-STREAM track {track_id} seg {seg_idx}"); + let fetch = qbz_qobuz::cmaf::fetch_cdn_bytes_with_retry( + &client, + &seg_url, + &fetch_tag, + ); + let seg_data = tokio::select! { + biased; + // Superseded by a newer play (or stop): drop the in-flight + // request so the link frees immediately, and leave the + // abandoned buffer OPEN (no error, no complete) so the + // outgoing engine keeps playing what is already buffered + // until the audio thread installs the new source; the new + // play seals the buffer after the swap. The partial track is + // NOT cached — the user abandoned it. + _ = cancel_rx.changed() => { + log::info!( + "[CMAF-STREAM] Track {} feeder cancelled (superseded)", + track_id + ); + guard.armed = false; + return Ok(false); + } + data = fetch => data?, + }; let crypto = qbz_cmaf::parse_segment_crypto(&seg_data) .map_err(|e| format!("CMAF segment {} parse: {}", seg_idx, e))?; @@ -4655,7 +6215,7 @@ impl Player { let _ = writer.error(msg.clone()); return Err(msg); } - if !skip_cache { + if accumulate_cache { cache_data.extend_from_slice(&frame); } total_written += frame.len() as u64; @@ -4670,7 +6230,7 @@ impl Player { let _ = writer.error(msg.clone()); return Err(msg); } - if !skip_cache { + if accumulate_cache { cache_data.extend_from_slice(trailing); } total_written += trailing.len() as u64; @@ -4716,13 +6276,35 @@ impl Player { // Cache the assembled FLAC (header + decrypted frames) for instant // replay on the next play of this track. - if !skip_cache && !cache_data.is_empty() { + if accumulate_cache && !cache_data.is_empty() { let bytes = cache_data.len(); cache.insert(track_id, cache_data); log::info!("[CMAF-STREAM] Track {} cached ({} bytes)", track_id, bytes); + } else if low_mem_oversized { + // Oversized track on a low-memory host: no RAM accumulator was + // built. Persist straight to the L2 disk playback cache, + // streamed out of the playback buffer in chunks — no second + // full in-RAM copy, and the per-chunk lock keeps the audio + // reader starved for at most ~1 MB of writes at a time. + if let Some(playback_cache) = cache.get_playback_cache() { + playback_cache.insert_from(track_id, total_written, |file| { + writer.write_buffered_to(file) + }); + log::info!( + "[CMAF-STREAM] Track {} persisted to L2 disk cache only ({} bytes, low-memory oversized)", + track_id, + total_written + ); + } else { + log::info!( + "[CMAF-STREAM] Track {} ({} bytes) oversized for low-memory L1 and no disk cache available — not cached", + track_id, + total_written + ); + } } - Ok(()) + Ok(true) } /// Play from raw audio data (for cached tracks) @@ -4744,9 +6326,18 @@ impl Player { track_id ); + let play_gen = self.state.current_play_generation(); + self.state.begin_buffering(track_id, play_gen); + // Extract audio metadata (sample rate, channels, bit depth) - fast header-only read - let meta = extract_audio_metadata_full(&data) - .map_err(|e| format!("Failed to extract audio metadata: {}", e))?; + let meta = extract_audio_metadata_full(&data).map_err(|e| { + self.state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); + format!("Failed to extract audio metadata: {}", e) + })?; let sample_rate = meta.sample_rate; let channels = meta.channels; @@ -4769,8 +6360,14 @@ impl Player { duration_secs: 0, // Will be determined by decoder sample_rate, channels, + play_gen, }) .map_err(|e| { + self.state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); log::error!("Player: Failed to send to audio thread: {}", e); format!( "Failed to send play command (audio thread may have crashed): {}", @@ -4778,6 +6375,10 @@ impl Player { ) })?; + // A new source was just installed: seal the abandoned feeder's + // buffer so a parked reader wakes to EOF instead of hanging. + self.state.seal_stream_feeder(); + log::info!("Player: Playback initiated successfully"); Ok(()) } @@ -4801,6 +6402,7 @@ impl Player { track_id, sample_rate: meta.sample_rate, channels: meta.channels, + bit_depth: meta.bit_depth.unwrap_or(16), }) .map_err(|e| { log::error!("Player: Failed to send PlayNext to audio thread: {}", e); @@ -4859,6 +6461,31 @@ impl Player { }); } let carrier = qbz_dsd::dop_carrier_rate(info.dsd_rate); + // Ask the platform that owns the device. On Windows the + // ALSA probe cannot answer, so this guard was taking its + // `.unwrap_or(true)` and dispatching DoP on a DEFAULT + // rather than on a measurement -- on a DAC that refuses + // 176400, which is the DSD64 carrier. + // WINDOWS: never. Not "probe and decide" -- the DoP + // handler below is `cfg(target_os = "linux")` and answers + // "DoP playback is Linux-only" with a stream error, so + // dispatching here can only ever fail. Converting to PCM + // is the one outcome that plays the track. + // + // It is also FAIL-CLOSED, which this guard has to be. An + // undecoded DoP stream is read as ordinary PCM carrying + // packed DSD and marker bytes: it comes out as very loud + // broadband noise, which threatens tweeters and hearing. + // An earlier draft here fell back to `true` whenever the + // probe could not answer -- preserving exactly the guess + // this work existed to remove. + // + // `wasapi_backend::supported_rates` is what a future + // Windows DoP path would consult, and it already reports + // that the owner's DAC refuses 176400, the DSD64 carrier. + #[cfg(windows)] + let rate_ok = false; + #[cfg(not(windows))] let rate_ok = qbz_audio::alsa_backend::get_device_supported_rates(&device) .map(|r| r.contains(&carrier)) @@ -4917,6 +6544,19 @@ impl Player { 0, )?; + // Cancel-on-supersede, the same contract `register_stream_feeder` + // gives the CMAF feeder. This loop USED to rely on `push_chunk` + // returning `Err` to notice the track had changed — but `push_chunk` + // only extends a `Vec` and fails solely on a poisoned mutex + // (`streaming_source.rs:541`), and `BufferState` carries no + // reader-gone flag, so that check could never fire. Skipping a DSD + // track therefore left this thread converting to the END of the file, + // growing a buffer no reader would ever read: a 60-minute DSD64 album + // track is ~1.9 GB of 88.2 kHz/24-bit PCM held for nothing. + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let slot_writer = writer.clone(); + let play_gen = self.state.current_play_generation(); + std::thread::spawn(move || { if writer .push_chunk(&qbz_dsd::wav_header(total_frames, channels, rate)) @@ -4926,12 +6566,26 @@ impl Player { } let mut pcm = Vec::new(); loop { + // Cooperative, checked per block (~a fraction of a second of + // audio), so a skip stops the conversion promptly. Like the + // CMAF feeder's own cancel, this deliberately neither errors + // nor completes the buffer: the outgoing engine keeps playing + // what is already buffered until the new source is installed, + // and `seal_stream_feeder` closes it from there. + if *cancel_rx.borrow() { + log::info!( + "Player: DSD conversion for superseded track {} cancelled", + track_id + ); + return; + } match conv.next_block() { Ok(Some(frames)) => { pcm.clear(); qbz_dsd::frames_to_pcm24(&frames, &mut pcm); if writer.push_chunk(&pcm).is_err() { - // Reader gone (track changed/stopped) — just stop. + // Only a poisoned buffer lock reaches here; the + // track-changed case is the cancel check above. return; } } @@ -4947,6 +6601,11 @@ impl Player { } } }); + // After the spawn, and with the generation this play was born under: + // a supersede that landed in between makes `register_stream_feeder` + // cancel the thread instead of registering it. + self.state + .register_stream_feeder(track_id, cancel_tx, slot_writer, play_gen); Ok(()) } @@ -5044,6 +6703,8 @@ impl Player { let (source, writer) = BufferedMediaSource::new(config, Some(content_length)); let source = Arc::new(source); + let play_gen = self.state.current_play_generation(); + self.state.begin_buffering(track_id, play_gen); self.tx .send(AudioCommand::PlayStreaming { @@ -5054,13 +6715,22 @@ impl Player { duration_secs, start_position_secs, content_length, - play_gen: self.state.current_play_generation(), + play_gen, }) .map_err(|e| { + self.state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); log::error!("Player: Failed to send streaming command: {}", e); format!("Failed to send streaming play command: {}", e) })?; + // A new source was just installed: seal the abandoned feeder's + // buffer so a parked reader wakes to EOF instead of hanging. + self.state.seal_stream_feeder(); + log::info!("Player: Streaming playback initiated"); Ok(writer) } @@ -5130,8 +6800,9 @@ impl Player { // unlike `speed_mbps`, which is estimated from the tiny init fetch and // is latency-dominated — it lands on the slowest ladder rung for every // connection. Clamped to 256KB (format-detection minimum) .. 8MB (the - // process-wide ladder cap has no desktop caller, so this is the - // effective ceiling protecting low-memory hosts). + // process-wide ladder cap is set from the host memory profile at + // process start, so on a LowMemory host `from_speed_mbps` already + // clamped lower; the 8MB ceiling here is the last line of defense). let user_secs = self .audio_settings .lock() @@ -5155,6 +6826,8 @@ impl Player { let (source, writer) = BufferedMediaSource::new(config, Some(content_length)); let source = Arc::new(source); + let play_gen = self.state.current_play_generation(); + self.state.begin_buffering(track_id, play_gen); self.tx .send(AudioCommand::PlayStreaming { @@ -5165,13 +6838,22 @@ impl Player { duration_secs, start_position_secs, content_length, - play_gen: self.state.current_play_generation(), + play_gen, }) .map_err(|e| { + self.state.set_buffer_state_for_play( + PlaybackBufferState::Error, + track_id, + play_gen, + ); log::error!("Player: Failed to send streaming command: {}", e); format!("Failed to send streaming play command: {}", e) })?; + // A new source was just installed: seal the abandoned feeder's + // buffer so a parked reader wakes to EOF instead of hanging. + self.state.seal_stream_feeder(); + log::info!("Player: Dynamic streaming playback initiated"); Ok(writer) } @@ -5184,7 +6866,7 @@ impl Player { .timeout(Duration::from_secs(60)) .connect_timeout(Duration::from_secs(10)) .build() - .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + .map_err(|_| "Failed to create HTTP client".to_string())?; log::info!("Caching audio from URL..."); @@ -5193,7 +6875,10 @@ impl Player { .header("User-Agent", "Mozilla/5.0") .send() .await - .map_err(|e| format!("Failed to fetch audio: {}", e))?; + .map_err(|e| format!( + "Failed to fetch audio: {}", + crate::remote_stream::describe_reqwest_error(&e) + ))?; if !response.status().is_success() { return Err(format!("HTTP error: {}", response.status())); @@ -5204,7 +6889,10 @@ impl Player { let bytes = response .bytes() .await - .map_err(|e| format!("Failed to read audio bytes: {}", e))?; + .map_err(|e| format!( + "Failed to read audio bytes: {}", + crate::remote_stream::describe_reqwest_error(&e) + ))?; log::info!("Cached {} bytes", bytes.len()); Ok(bytes.to_vec()) @@ -5233,6 +6921,9 @@ impl Player { // Supersede any in-flight play_track so a slow CMAF/legacy fetch cannot // restart audio after the user stopped. let _ = self.begin_play(); + // Nothing will consume the abandoned stream's buffer after this: + // seal it so a reader parked on its condvar wakes to EOF. + self.state.seal_stream_feeder(); self.tx .send(AudioCommand::Stop) .map_err(|e| format!("Failed to send stop command: {}", e)) @@ -5253,6 +6944,19 @@ impl Player { .map_err(|e| format!("Failed to send volume command: {}", e)) } + /// Seed the volume state for an imminent engine rebuild without touching + /// the currently open output. Settings uses this when enabling ALSA + /// hardware volume: it samples the physical mixer first, stores that level + /// here, then reinitializes the direct engine. Sending `SetVolume` instead + /// would write the new device's sampled level through the *old* device's + /// still-live hardware mixer before the reinit command reached the audio + /// thread. + pub fn seed_volume_state(&self, volume: f32) { + self.state + .volume + .store(volume.clamp(0.0, 1.0).to_bits(), Ordering::SeqCst); + } + /// Seek to position in seconds pub fn seek(&self, position: u64) -> Result<(), String> { // Clamp to duration if known @@ -5284,9 +6988,15 @@ impl Player { /// with a device re-enumeration in the UI to surface a freed or /// hot-plugged DAC without restarting the app. pub fn release_device(&self) -> Result<(), String> { + let (completed_tx, completed_rx) = mpsc::sync_channel(1); self.tx - .send(AudioCommand::ReleaseDevice) - .map_err(|e| format!("Failed to send release command: {}", e)) + .send(AudioCommand::ReleaseDevice { + completed: completed_tx, + }) + .map_err(|e| format!("Failed to send release command: {}", e))?; + completed_rx + .recv_timeout(Duration::from_secs(5)) + .map_err(|error| format!("Timed out waiting for audio-device release: {error}"))? } /// Reload audio settings from fresh config (e.g., after database update) @@ -5315,12 +7025,14 @@ impl Player { pub fn get_playback_event(&self) -> PlaybackEvent { let sample_rate = self.state.get_sample_rate(); let bit_depth = self.state.get_bit_depth(); + let (buffer_state, buffer_track_id) = self.state.playback_buffer_snapshot(); PlaybackEvent { is_playing: self.state.is_playing(), position: self.state.current_position(), duration: self.state.duration(), track_id: self.state.current_track_id(), volume: self.state.volume(), + hardware_volume_active: self.state.hardware_volume_active(), sample_rate: if sample_rate > 0 { Some(sample_rate) } else { @@ -5334,6 +7046,18 @@ impl Player { gapless_next_track_id: self.state.get_gapless_next_track_id(), bit_perfect_mode: self.state.get_bit_perfect_mode(), buffer_progress: self.state.get_buffer_progress(), + buffer_state, + buffer_track_id, + engine_empty_generation: self + .state + .engine_empty_generation + .load(Ordering::SeqCst), + engine_empty_track_id: self.state.engine_empty_track_id.load(Ordering::SeqCst), + source_failure_generation: self + .state + .source_failure_generation + .load(Ordering::SeqCst), + source_failure_track_id: self.state.source_failure_track_id.load(Ordering::SeqCst), } } } @@ -5410,13 +7134,102 @@ pub fn external_content_type(mime: &str, format_id: u32) -> String { mod tests { use super::compute_needs_new_stream; use super::external_content_type; - use super::{DsdErrorReport, SharedState}; + #[cfg(target_os = "linux")] + use super::{hardware_volume_event_callback, reported_volume_after_command}; + use super::{ + BufferedMediaSource, DsdErrorReport, PlaybackBufferReporter, PlaybackBufferState, + SharedState, StreamingConfig, + }; + #[cfg(target_os = "linux")] + use qbz_audio::alsa_hardware_volume::{ + AlsaMixerControlId, HardwareVolumeEvent, HardwareVolumeSnapshot, + }; + use std::sync::atomic::Ordering; struct FakeDsdSource { words: std::vec::IntoIter<i32>, error: Option<String>, } + /// #734: a CBR MP3 without a Xing/Info/VBRI frame (2 s, 32 kbps mono, + /// `lame -t`). Symphonia can only estimate its duration from the byte + /// length, which `Decoder::new` never passes along. + #[test] + fn local_decoder_derives_duration_without_xing_header() { + let bytes = include_bytes!("../../testdata/cbr_no_xing.mp3"); + let source = super::decode_with_fallback(bytes).expect("fixture decodes"); + let duration = source + .total_duration() + .expect("duration estimated from the byte length"); + assert!( + (1..=3).contains(&duration.as_secs()), + "expected ~2 s, got {duration:?}" + ); + } + + /// #734: an unknown (0) duration must not pin the playback clock to 0. + #[test] + fn unknown_duration_does_not_pin_position_to_zero() { + let state = SharedState::new(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + state.is_playing.store(true, Ordering::SeqCst); + state.position_at_start.store(0, Ordering::SeqCst); + state + .playback_start_millis + .store(now_ms.saturating_sub(3_000), Ordering::SeqCst); + + state.duration.store(0, Ordering::SeqCst); + assert!(state.current_position() >= 2, "unclamped clock advances"); + assert!(state.current_position_ms() >= 2_000); + + state.duration.store(1, Ordering::SeqCst); + assert_eq!(state.current_position(), 1, "known duration still clamps"); + assert_eq!(state.current_position_ms(), 1_000); + } + + #[cfg(target_os = "linux")] + #[test] + fn inactive_direct_hardware_volume_reports_unity() { + assert_eq!(reported_volume_after_command(0.25, true, false), 1.0); + assert_eq!(reported_volume_after_command(0.25, true, true), 0.25); + assert_eq!(reported_volume_after_command(0.25, false, false), 0.25); + } + + #[cfg(target_os = "linux")] + #[test] + fn unavailable_hardware_volume_session_cannot_be_revived_by_stale_event() { + let state = SharedState::new(); + let callback = hardware_volume_event_callback(&state, true); + let changed = |volume| { + HardwareVolumeEvent::Changed(HardwareVolumeSnapshot { + control: AlsaMixerControlId { + name: "D50 III".to_string(), + index: 0, + }, + channels: Vec::new(), + volume, + muted: false, + }) + }; + + callback(changed(0.42)); + assert!(state.hardware_volume_active()); + assert_eq!(state.volume(), 0.42); + + callback(HardwareVolumeEvent::Unavailable( + "device disappeared".to_string(), + )); + assert!(!state.hardware_volume_active()); + assert_eq!(state.volume(), 1.0); + + callback(changed(0.75)); + assert!(!state.hardware_volume_active()); + assert_eq!(state.volume(), 1.0); + } + impl Iterator for FakeDsdSource { type Item = i32; fn next(&mut self) -> Option<i32> { @@ -5464,6 +7277,127 @@ mod tests { assert_eq!(state.take_stream_error_message(), None); } + #[test] + fn engine_empty_edges_are_monotonic_and_keep_the_track_identity() { + let state = SharedState::new(); + state.record_engine_empty(41); + assert_eq!(state.engine_empty_generation.load(Ordering::SeqCst), 1); + assert_eq!(state.engine_empty_track_id.load(Ordering::SeqCst), 41); + + state.record_engine_empty(42); + assert_eq!(state.engine_empty_generation.load(Ordering::SeqCst), 2); + assert_eq!(state.engine_empty_track_id.load(Ordering::SeqCst), 42); + } + + #[test] + fn superseded_feeder_cannot_publish_a_source_failure() { + let state = SharedState::new(); + let old_generation = state.current_play_generation(); + state.begin_play(); + state.record_source_failure(41, old_generation); + + assert_eq!(state.source_failure_generation.load(Ordering::SeqCst), 0); + assert_eq!(state.source_failure_track_id.load(Ordering::SeqCst), 0); + + let current_generation = state.current_play_generation(); + state.record_source_failure(42, current_generation); + assert_eq!(state.source_failure_generation.load(Ordering::SeqCst), 1); + assert_eq!(state.source_failure_track_id.load(Ordering::SeqCst), 42); + } + + #[test] + fn buffer_state_is_scoped_to_play_generation_and_track() { + let state = SharedState::new(); + let first_generation = state.begin_play(); + state.begin_buffering(41, first_generation); + let first_reporter = PlaybackBufferReporter::new(state.clone(), 41, first_generation); + + assert_eq!( + state.playback_buffer_snapshot(), + (PlaybackBufferState::InitialBuffering, 41) + ); + first_reporter.report(PlaybackBufferState::Ready); + assert_eq!( + state.playback_buffer_snapshot(), + (PlaybackBufferState::Ready, 41) + ); + + let second_generation = state.begin_play(); + state.begin_buffering(42, second_generation); + first_reporter.report(PlaybackBufferState::Underrun); + + assert_eq!( + state.playback_buffer_snapshot(), + (PlaybackBufferState::InitialBuffering, 42), + "a late transition from the old decoder must not overwrite the new play" + ); + } + + #[test] + fn current_source_failure_marks_buffer_error() { + let state = SharedState::new(); + let generation = state.begin_play(); + state.begin_buffering(42, generation); + + state.record_source_failure(42, generation); + + assert_eq!( + state.playback_buffer_snapshot(), + (PlaybackBufferState::Error, 42) + ); + } + + #[test] + fn superseding_play_cancels_current_and_gapless_stream_feeders() { + let state = SharedState::new(); + let (_, current_writer) = + BufferedMediaSource::new(StreamingConfig::fast_start(), Some(1024)); + let (_, next_writer) = + BufferedMediaSource::new(StreamingConfig::fast_start(), Some(1024)); + let (current_tx, current_rx) = tokio::sync::watch::channel(false); + let (next_tx, next_rx) = tokio::sync::watch::channel(false); + let generation = state.current_play_generation(); + state.register_stream_feeder(1, current_tx, current_writer, generation); + assert!(state.register_gapless_stream_feeder( + 2, + next_tx, + next_writer, + generation + )); + + state.cancel_stream_feeder(); + + assert!(*current_rx.borrow()); + assert!(*next_rx.borrow()); + } + + #[test] + fn gapless_handoff_promotes_the_successor_feeder() { + let state = SharedState::new(); + let (_, writer) = BufferedMediaSource::new(StreamingConfig::fast_start(), Some(1024)); + let (cancel_tx, _cancel_rx) = tokio::sync::watch::channel(false); + let generation = state.current_play_generation(); + assert!(state.register_gapless_stream_feeder( + 42, + cancel_tx, + writer, + generation + )); + + state.promote_gapless_stream_feeder(42); + + assert_eq!( + state + .stream_feeder + .lock() + .unwrap() + .as_ref() + .map(|slot| slot.track_id), + Some(42) + ); + assert!(state.gapless_stream_feeder.lock().unwrap().is_none()); + } + #[test] fn no_stream_always_needs_new() { // Without an existing stream there is nothing to reuse — every diff --git a/crates/qbz-player/src/player/playback_engine.rs b/crates/qbz-player/src/player/playback_engine.rs index 55337a1f3..0a229be82 100644 --- a/crates/qbz-player/src/player/playback_engine.rs +++ b/crates/qbz-player/src/player/playback_engine.rs @@ -8,7 +8,13 @@ //! to enable gapless playback. When one source ends, the next is picked up //! seamlessly without interrupting the PCM stream. +use qbz_audio::alsa_hardware_volume::{ + read_hardware_volume, subscribe_hardware_volume_events, AlsaMixerControlId, + HardwareVolumeEvent, HardwareVolumeEventCallback, HardwareVolumeEventSubscription, +}; +#[cfg(target_os = "linux")] use qbz_audio::AlsaDirectStream; +use qbz_audio::VisualizerTap; #[cfg(target_os = "linux")] use qbz_audio::JackStream; use rodio::{mixer::Mixer, Player as RodioPlayer, Source}; @@ -25,6 +31,9 @@ type BoxedSampleIter = Box<dyn Iterator<Item = f32> + Send>; #[cfg(target_os = "linux")] type BoxedDopIter = Box<dyn Iterator<Item = i32> + Send>; +#[cfg(target_os = "linux")] +type DopReplacement = (BoxedDopIter, u64); + /// Thread-safe source queue for gapless playback. /// The writer thread consumes sources; append() pushes new ones. pub(crate) struct SourceQueue<S> { @@ -68,15 +77,28 @@ impl<S> SourceQueue<S> { fn is_empty(&self) -> bool { self.queue.lock().unwrap().is_empty() } + + fn clear(&self) { + self.queue.lock().unwrap().clear(); + } + + fn wake(&self) { + self.notify.notify_one(); + } } /// Unified playback engine pub enum PlaybackEngine { /// Rodio-based (PipeWire, Pulse, ALSA via CPAL) Rodio { sink: RodioPlayer }, - /// Direct ALSA (hw: devices, bit-perfect) with gapless source queue - AlsaDirect { - stream: Arc<AlsaDirectStream>, + /// A bit-perfect DIRECT sink with a gapless source queue: ALSA hw: on + /// Linux, WASAPI exclusive on Windows. The two are interchangeable here - + /// the writer thread only ever calls `DirectSink`. + Direct { + stream: Arc<dyn qbz_audio::backend::DirectSink>, + /// Log prefix for THIS sink. Without it `play`/`pause`/`stop`/ + /// `set_volume` printed "ALSA Direct Engine" for a WASAPI stream. + label: &'static str, is_playing: Arc<AtomicBool>, should_stop: Arc<AtomicBool>, position_frames: Arc<AtomicU64>, @@ -85,7 +107,10 @@ pub enum PlaybackEngine { playback_thread: Option<thread::JoinHandle<()>>, /// Signals that the writer thread has consumed a source and moved to next source_transition: Arc<AtomicBool>, - hardware_volume: bool, + hardware_volume_control: Option<AlsaMixerControlId>, + hardware_volume_active: Arc<AtomicBool>, + hardware_volume_events: HardwareVolumeEventCallback, + _hardware_volume_subscription: Option<HardwareVolumeEventSubscription>, }, /// Native JACK output (#263 Tier 3). Mirrors AlsaDirect (gapless source queue /// + a single long-lived feeder thread), but the feeder resamples each source @@ -115,6 +140,9 @@ pub enum PlaybackEngine { should_stop: Arc<AtomicBool>, position_frames: Arc<AtomicU64>, source_queue: Arc<SourceQueue<BoxedDopIter>>, + /// A seek replaces the live source without closing ALSA. The writer + /// inserts DSD silence before consuming it so the DAC stays locked. + replacement: Arc<Mutex<Option<DopReplacement>>>, writer_thread: Option<thread::JoinHandle<()>>, source_transition: Arc<AtomicBool>, }, @@ -127,15 +155,62 @@ impl PlaybackEngine { Ok(Self::Rodio { sink }) } - /// Create ALSA Direct engine with gapless source queue. + /// Create a bit-perfect direct engine with a gapless source queue. /// Spawns a single writer thread that lives for the engine's lifetime. - pub fn new_alsa_direct(stream: Arc<AlsaDirectStream>, hardware_volume: bool) -> Self { + /// Takes ANY direct sink. The log prefix comes from the SINK, so no call + /// site can mislabel it. + pub fn new_direct( + stream: Arc<dyn qbz_audio::backend::DirectSink>, + hardware_volume_control: Option<AlsaMixerControlId>, + visualizer_tap: Option<VisualizerTap>, + hardware_volume_events: HardwareVolumeEventCallback, + ) -> Self { + let label = stream.log_label(); let is_playing = Arc::new(AtomicBool::new(false)); let should_stop = Arc::new(AtomicBool::new(false)); let position_frames = Arc::new(AtomicU64::new(0)); let duration_frames = Arc::new(AtomicU64::new(0)); let source_queue = Arc::new(SourceQueue::new()); let source_transition = Arc::new(AtomicBool::new(false)); + let hardware_volume_active = Arc::new(AtomicBool::new(hardware_volume_control.is_some())); + + let hardware_volume_subscription = hardware_volume_control.as_ref().and_then(|control| { + let Some(device_id) = stream.alsa_device_id() else { + hardware_volume_active.store(false, Ordering::SeqCst); + hardware_volume_events(HardwareVolumeEvent::Unavailable( + "selected hardware volume has no ALSA device route".to_string(), + )); + return None; + }; + match read_hardware_volume(device_id, control) { + Ok(snapshot) => hardware_volume_events(HardwareVolumeEvent::Changed(snapshot)), + Err(error) => { + hardware_volume_active.store(false, Ordering::SeqCst); + hardware_volume_events(HardwareVolumeEvent::Unavailable(error)); + return None; + } + } + let active = hardware_volume_active.clone(); + let forward = hardware_volume_events.clone(); + let callback: HardwareVolumeEventCallback = Arc::new(move |event| match event { + HardwareVolumeEvent::Unavailable(error) => { + active.store(false, Ordering::SeqCst); + forward(HardwareVolumeEvent::Unavailable(error)); + } + HardwareVolumeEvent::Changed(snapshot) if active.load(Ordering::SeqCst) => { + forward(HardwareVolumeEvent::Changed(snapshot)); + } + HardwareVolumeEvent::Changed(_) => {} + }); + match subscribe_hardware_volume_events(device_id, control, callback) { + Ok(subscription) => Some(subscription), + Err(error) => { + hardware_volume_active.store(false, Ordering::SeqCst); + hardware_volume_events(HardwareVolumeEvent::Unavailable(error)); + None + } + } + }); // Spawn the single long-lived writer thread let handle = { @@ -147,9 +222,10 @@ impl PlaybackEngine { let queue_c = source_queue.clone(); let transition_c = source_transition.clone(); let channels = stream.channels(); + let visualizer_c = visualizer_tap.clone(); thread::spawn(move || { - alsa_writer_thread( + direct_writer_thread( stream_c, playing_c, stop_c, @@ -158,12 +234,15 @@ impl PlaybackEngine { queue_c, transition_c, channels, + label, + visualizer_c, ); }) }; - Self::AlsaDirect { + Self::Direct { stream, + label, is_playing, should_stop, position_frames, @@ -171,7 +250,10 @@ impl PlaybackEngine { source_queue, playback_thread: Some(handle), source_transition, - hardware_volume, + hardware_volume_control, + hardware_volume_active, + hardware_volume_events, + _hardware_volume_subscription: hardware_volume_subscription, } } @@ -223,6 +305,7 @@ impl PlaybackEngine { let should_stop = Arc::new(AtomicBool::new(false)); let position_frames = Arc::new(AtomicU64::new(0)); let source_queue: Arc<SourceQueue<BoxedDopIter>> = Arc::new(SourceQueue::new()); + let replacement: Arc<Mutex<Option<DopReplacement>>> = Arc::new(Mutex::new(None)); let source_transition = Arc::new(AtomicBool::new(false)); let handle = { let stream_c = stream.clone(); @@ -230,11 +313,20 @@ impl PlaybackEngine { let stop_c = should_stop.clone(); let pos_c = position_frames.clone(); let queue_c = source_queue.clone(); + let replacement_c = replacement.clone(); let transition_c = source_transition.clone(); let channels = stream.channels(); thread::spawn(move || { dop_writer_thread( - stream_c, playing_c, stop_c, pos_c, queue_c, transition_c, channels, native, + stream_c, + playing_c, + stop_c, + pos_c, + queue_c, + replacement_c, + transition_c, + channels, + native, ); }) }; @@ -244,6 +336,7 @@ impl PlaybackEngine { should_stop, position_frames, source_queue, + replacement, writer_thread: Some(handle), source_transition, } @@ -278,6 +371,36 @@ impl PlaybackEngine { } } + /// Replace the currently playing direct-DSD source for seek while keeping + /// the exclusive ALSA stream open. Queued gapless successors are dropped; + /// the audio thread will request them again near the new end position. + #[cfg(target_os = "linux")] + pub fn replace_dop( + &mut self, + source: BoxedDopIter, + position_frames_base: u64, + ) -> Result<(), String> { + match self { + Self::AlsaDop { + should_stop, + source_queue, + replacement, + source_transition, + .. + } => { + if should_stop.load(Ordering::SeqCst) { + return Err("direct DSD writer is stopped".to_string()); + } + source_queue.clear(); + source_transition.store(false, Ordering::SeqCst); + *replacement.lock().unwrap() = Some((source, position_frames_base)); + source_queue.wake(); + Ok(()) + } + _ => Err("replace_dop on a non-DoP engine".to_string()), + } + } + /// True when this engine is the DoP (DSD over PCM) writer. pub fn is_dop(&self) -> bool { #[cfg(target_os = "linux")] @@ -302,7 +425,8 @@ impl PlaybackEngine { sink.append(source); Ok(()) } - Self::AlsaDirect { + Self::Direct { + label, is_playing, should_stop, position_frames, @@ -322,9 +446,9 @@ impl PlaybackEngine { should_stop.store(false, Ordering::SeqCst); source_transition.store(false, Ordering::SeqCst); is_playing.store(true, Ordering::SeqCst); - log::info!("[ALSA Direct Engine] First source queued, playback starting"); + log::info!("[{label}] First source queued, playback starting"); } else { - log::info!("[ALSA Direct Engine] Source queued for gapless transition"); + log::info!("[{label}] Source queued for gapless transition"); } Ok(()) @@ -371,8 +495,10 @@ impl PlaybackEngine { pub fn play(&self) { match self { Self::Rodio { sink } => sink.play(), - Self::AlsaDirect { is_playing, .. } => { - log::info!("[ALSA Direct Engine] Resume requested"); + Self::Direct { + label, is_playing, .. + } => { + log::info!("[{label}] Resume requested"); is_playing.store(true, Ordering::SeqCst); } #[cfg(target_os = "linux")] @@ -392,8 +518,10 @@ impl PlaybackEngine { pub fn pause(&self) { match self { Self::Rodio { sink } => sink.pause(), - Self::AlsaDirect { is_playing, .. } => { - log::info!("[ALSA Direct Engine] Pause requested"); + Self::Direct { + label, is_playing, .. + } => { + log::info!("[{label}] Pause requested"); is_playing.store(false, Ordering::SeqCst); } #[cfg(target_os = "linux")] @@ -424,8 +552,9 @@ impl PlaybackEngine { Self::Rodio { sink } => { sink.stop(); } - Self::AlsaDirect { + Self::Direct { stream, + label, is_playing, should_stop, playback_thread, @@ -434,7 +563,7 @@ impl PlaybackEngine { if should_stop.load(Ordering::SeqCst) { return; // Already stopped } - log::info!("[ALSA Direct Engine] Stop requested"); + log::info!("[{label}] Stop requested"); should_stop.store(true, Ordering::SeqCst); is_playing.store(false, Ordering::SeqCst); @@ -443,7 +572,7 @@ impl PlaybackEngine { } if let Err(e) = stream.stop() { - log::warn!("[ALSA Direct Engine] Stop failed: {}", e); + log::warn!("[{label}] Stop failed: {}", e); } } #[cfg(target_os = "linux")] @@ -492,21 +621,25 @@ impl PlaybackEngine { pub fn set_volume(&self, volume: f32) { match self { Self::Rodio { sink } => sink.set_volume(volume), - Self::AlsaDirect { + Self::Direct { stream, - hardware_volume, + label, + hardware_volume_control, + hardware_volume_active, + hardware_volume_events, .. } => { - if *hardware_volume { - #[cfg(target_os = "linux")] - { - if let Err(e) = stream.set_hardware_volume(volume) { - log::warn!("[ALSA Direct Engine] Hardware volume failed: {}", e); + if hardware_volume_active.load(Ordering::SeqCst) { + if let Some(control) = hardware_volume_control { + if let Err(error) = stream.set_hardware_volume(control, volume) { + log::warn!("[{label}] Hardware volume failed: {error}"); + hardware_volume_active.store(false, Ordering::SeqCst); + hardware_volume_events(HardwareVolumeEvent::Unavailable(error)); } } } else { log::debug!( - "[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)" + "[{label}] Hardware volume control disabled (use DAC/amplifier)" ); } } @@ -529,7 +662,7 @@ impl PlaybackEngine { pub fn empty(&self) -> bool { match self { Self::Rodio { sink } => sink.empty(), - Self::AlsaDirect { + Self::Direct { is_playing, source_queue, .. @@ -554,7 +687,7 @@ impl PlaybackEngine { pub fn take_source_transition(&self) -> bool { match self { Self::Rodio { .. } => false, - Self::AlsaDirect { + Self::Direct { source_transition, .. } => source_transition .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst) @@ -579,7 +712,7 @@ impl PlaybackEngine { pub fn position_secs(&self) -> Option<u64> { match self { Self::Rodio { .. } => None, - Self::AlsaDirect { + Self::Direct { position_frames, stream, .. @@ -614,7 +747,7 @@ impl PlaybackEngine { pub fn duration_secs(&self) -> Option<u64> { match self { Self::Rodio { .. } => None, - Self::AlsaDirect { + Self::Direct { duration_frames, stream, .. @@ -639,19 +772,41 @@ impl PlaybackEngine { /// Check if using ALSA Direct engine #[allow(dead_code)] - pub fn is_alsa_direct(&self) -> bool { - matches!(self, Self::AlsaDirect { .. }) + pub fn is_direct(&self) -> bool { + matches!(self, Self::Direct { .. }) } } -/// Single long-lived writer thread for ALSA Direct. +/// Single long-lived writer thread for any bit-perfect direct sink. /// -/// Continuously reads samples from the current source and writes to ALSA. +/// Continuously reads samples from the current source and writes to the sink. /// When a source ends, seamlessly picks up the next one from the queue /// (gapless transition). If no next source is available, drains the ALSA /// buffer and waits for the next source or a stop signal. -fn alsa_writer_thread( - stream: Arc<AlsaDirectStream>, +/// +/// The source is consumed in ~10 ms pieces instead of the historical fixed +/// 8192-frame blocks. This does not change PCM data or device parameters; it +/// only keeps the passive visualizer tap from receiving ~186 ms bursts at +/// 44.1 kHz. Sinks that can measure their queued frames then point snapshots +/// behind the submitted PCM at the part currently reaching the DAC. +/// +/// Generic rather than duplicated: ALSA and WASAPI need byte-identical +/// behaviour here — same pacing, gapless hand-off and drain — and two copies +/// of this state machine would drift. +const DIRECT_PCM_FEED_TARGET_MS: u64 = 10; +const DIRECT_PCM_FEED_MIN_FRAMES: u64 = 64; +const DIRECT_PCM_FEED_MAX_FRAMES: u64 = 8192; + +fn direct_pcm_feed_frames(sample_rate: u32) -> usize { + let frames = u64::from(sample_rate) + .saturating_mul(DIRECT_PCM_FEED_TARGET_MS) + .div_ceil(1000) + .clamp(DIRECT_PCM_FEED_MIN_FRAMES, DIRECT_PCM_FEED_MAX_FRAMES); + frames as usize +} + +fn direct_writer_thread<S: qbz_audio::backend::DirectSink + ?Sized>( + stream: Arc<S>, is_playing: Arc<AtomicBool>, should_stop: Arc<AtomicBool>, position_frames: Arc<AtomicU64>, @@ -659,19 +814,31 @@ fn alsa_writer_thread( source_queue: Arc<SourceQueue<BoxedSampleIter>>, source_transition: Arc<AtomicBool>, channels: u16, + label: &'static str, + visualizer_tap: Option<VisualizerTap>, ) { - const CHUNK_FRAMES: usize = 8192; - let chunk_samples = CHUNK_FRAMES * channels as usize; + let chunk_frames = direct_pcm_feed_frames(stream.sample_rate()); + let chunk_samples = chunk_frames * channels as usize; let mut buffer_f32 = Vec::with_capacity(chunk_samples); let mut current_source: Option<BoxedSampleIter> = None; let mut total_frames: u64 = 0; + let mut queued_frames = 0u64; + let mut visualizer_was_enabled = false; + let mut delay_error_reported = false; - log::info!("[ALSA Direct Engine] Writer thread started (gapless-capable)"); + if let Some(tap) = visualizer_tap.as_ref() { + tap.clear_output_delay(); + } + log::info!( + "[{label}] Writer thread started (gapless-capable, viz feed {} frames / ~{} ms)", + chunk_frames, + DIRECT_PCM_FEED_TARGET_MS + ); 'thread: loop { // Check global stop if should_stop.load(Ordering::SeqCst) { - log::info!("[ALSA Direct Engine] Stop signal, writer thread exiting"); + log::info!("[{label}] Stop signal, writer thread exiting"); break 'thread; } @@ -683,7 +850,7 @@ fn alsa_writer_thread( current_source = Some(src); total_frames = 0; position_frames.store(0, Ordering::SeqCst); - log::info!("[ALSA Direct Engine] Acquired new source from queue"); + log::info!("[{label}] Acquired new source from queue"); } None => { // No source available, loop back to check stop @@ -700,7 +867,44 @@ fn alsa_writer_thread( std::thread::sleep(Duration::from_millis(50)); } - // Fill buffer from current source + // Keep the passive tap at the previous audible position while this + // next piece is pulled rapidly from the source. Once the sink accepts it, + // the delay query below advances the tap by exactly what the device + // consumed. Shared/callback backends leave this offset at zero. + let visualizer_enabled = visualizer_tap + .as_ref() + .map(VisualizerTap::is_enabled) + .unwrap_or(false); + if visualizer_enabled { + if !visualizer_was_enabled { + match stream.playback_delay_frames() { + Ok(delay) => queued_frames = delay, + Err(error) => { + if !delay_error_reported { + log::warn!( + "[{label}] Visualizer delay query failed; using paced feed without playhead compensation: {}", + error + ); + delay_error_reported = true; + } + queued_frames = 0; + } + } + } + if let Some(tap) = visualizer_tap.as_ref() { + tap.set_output_delay_frames( + queued_frames.saturating_add(chunk_frames as u64), + channels, + ); + } + } else if visualizer_was_enabled { + if let Some(tap) = visualizer_tap.as_ref() { + tap.clear_output_delay(); + } + } + visualizer_was_enabled = visualizer_enabled; + + // Fill buffer from current source. buffer_f32.clear(); let source = current_source.as_mut().unwrap(); let mut source_ended = false; @@ -715,29 +919,62 @@ fn alsa_writer_thread( } } - // Write whatever we have to ALSA (even partial chunks on source end) + let frames_in_chunk = buffer_f32.len() / channels as usize; + if visualizer_enabled { + if let Some(tap) = visualizer_tap.as_ref() { + tap.set_output_delay_frames( + queued_frames.saturating_add(frames_in_chunk as u64), + channels, + ); + } + } + + // Write whatever we have to the direct sink (even partial chunks on source end) if !buffer_f32.is_empty() { if let Err(e) = stream.write_f32(&buffer_f32) { - log::error!("[ALSA Direct Engine] Write failed: {}", e); + log::error!("[{label}] Write failed: {}", e); break 'thread; } - let frames_written = buffer_f32.len() / channels as usize; - total_frames += frames_written as u64; + total_frames += frames_in_chunk as u64; position_frames.store(total_frames, Ordering::SeqCst); duration_frames.store(total_frames, Ordering::SeqCst); + + if visualizer_enabled { + match stream.playback_delay_frames() { + Ok(delay) => { + queued_frames = delay; + if let Some(tap) = visualizer_tap.as_ref() { + tap.set_output_delay_frames(queued_frames, channels); + } + } + Err(error) => { + if !delay_error_reported { + log::warn!( + "[{label}] Visualizer delay query failed; using paced feed without playhead compensation: {}", + error + ); + delay_error_reported = true; + } + queued_frames = 0; + if let Some(tap) = visualizer_tap.as_ref() { + tap.clear_output_delay(); + } + } + } + } } if source_ended { log::info!( - "[ALSA Direct Engine] Source ended (total frames: {})", + "[{label}] Source ended (total frames: {})", total_frames ); // Try to get next source immediately (gapless transition) match source_queue.try_pop() { Some(next_src) => { - log::info!("[ALSA Direct Engine] Gapless transition to next source"); + log::info!("[{label}] Gapless transition to next source"); current_source = Some(next_src); total_frames = 0; position_frames.store(0, Ordering::SeqCst); @@ -747,9 +984,9 @@ fn alsa_writer_thread( } None => { // No next source — this is a natural end of playback - log::info!("[ALSA Direct Engine] No next source, draining ALSA buffer"); + log::info!("[{label}] No next source, draining the device buffer"); if let Err(e) = stream.drain() { - log::warn!("[ALSA Direct Engine] Drain failed: {}", e); + log::warn!("[{label}] Drain failed: {}", e); } current_source = None; is_playing.store(false, Ordering::SeqCst); @@ -760,12 +997,15 @@ fn alsa_writer_thread( } is_playing.store(false, Ordering::SeqCst); - log::info!("[ALSA Direct Engine] Writer thread finished"); + if let Some(tap) = visualizer_tap.as_ref() { + tap.clear_output_delay(); + } + log::info!("[{label}] Writer thread finished"); } /// Single long-lived feeder thread for JACK (#263 Tier 3). /// -/// Mirrors `alsa_writer_thread`, but writes graph-rate interleaved STEREO f32 +/// Mirrors `direct_writer_thread`, but writes graph-rate interleaved STEREO f32 /// into the JACK client's lock-free ring buffer via `JackStream::write_f32` /// (the RT process callback drains it), pacing itself when the ring is full. /// Sources are resampled to the graph rate + stereo at `append` time. @@ -868,7 +1108,7 @@ impl Drop for PlaybackEngine { /// Single long-lived writer thread for DoP (DSD over PCM). /// -/// Mirrors `alsa_writer_thread`'s shape: pulls pre-packed S32 DoP words from +/// Mirrors `direct_writer_thread`'s shape: pulls pre-packed S32 DoP words from /// the current source, writes them VERBATIM, and picks up the next queued /// source seamlessly (gapless DSD). Differences forced by the format: /// - pause writes 0x69 DSD silence (with valid alternating markers) instead @@ -883,6 +1123,7 @@ fn dop_writer_thread( should_stop: Arc<AtomicBool>, position_frames: Arc<AtomicU64>, source_queue: Arc<SourceQueue<BoxedDopIter>>, + replacement: Arc<Mutex<Option<DopReplacement>>>, source_transition: Arc<AtomicBool>, channels: u16, native: bool, @@ -919,6 +1160,19 @@ fn dop_writer_thread( break 'thread; } + if let Some((source, position_base)) = replacement.lock().unwrap().take() { + // Do not drop/reopen ALSA here: that makes the DAC leave DSD mode + // and is the source of the loud click users hear on a naive seek. + // A short valid-silence bridge gives both DoP and native receivers + // a clean boundary while preserving exclusive ownership. + write_silence(&mut silence_packer, &mut silence_buf, carrier * 150 / 1000); + current = Some(source); + had_source = true; + position_frames.store(position_base, Ordering::SeqCst); + source_transition.store(false, Ordering::SeqCst); + log::info!("[DoP Engine] Source replaced in-place for seek"); + } + if current.is_none() { match source_queue.wait_for_source(Duration::from_millis(100)) { Some(src) => { @@ -979,3 +1233,23 @@ fn dop_writer_thread( } } } + +#[cfg(test)] +mod tests { + use super::direct_pcm_feed_frames; + + #[test] + fn direct_pcm_feed_stays_near_ten_ms_across_pcm_rates() { + assert_eq!(direct_pcm_feed_frames(44_100), 441); + assert_eq!(direct_pcm_feed_frames(48_000), 480); + assert_eq!(direct_pcm_feed_frames(96_000), 960); + assert_eq!(direct_pcm_feed_frames(192_000), 1_920); + assert_eq!(direct_pcm_feed_frames(768_000), 7_680); + } + + #[test] + fn direct_pcm_feed_has_defensive_bounds() { + assert_eq!(direct_pcm_feed_frames(0), 64); + assert_eq!(direct_pcm_feed_frames(u32::MAX), 8_192); + } +} diff --git a/crates/qbz-player/src/player/streaming_source.rs b/crates/qbz-player/src/player/streaming_source.rs index 74d20a14d..87b50d59e 100644 --- a/crates/qbz-player/src/player/streaming_source.rs +++ b/crates/qbz-player/src/player/streaming_source.rs @@ -38,6 +38,8 @@ use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use symphonia::default::{get_codecs, get_probe}; +use super::{PlaybackBufferReporter, PlaybackBufferState}; + /// Configuration for the streaming buffer #[derive(Debug, Clone)] pub struct StreamingConfig { @@ -169,6 +171,23 @@ pub fn max_initial_buffer_bytes() -> usize { MAX_INITIAL_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed) } +/// Absolute ceiling for the up-front buffer reservation: 1 GiB. Guards +/// against absurd Content-Length values reserving more RAM than any real +/// track needs. +const MAX_PREALLOC_BYTES: u64 = 1024 * 1024 * 1024; + +/// Pure decision for the up-front reservation in [`BufferedMediaSource::new`]: +/// `Some(capacity)` when `total_size` is known and non-zero (capped at +/// [`MAX_PREALLOC_BYTES`]), `None` when the caller should fall back to the +/// config's initial-buffer capacity. +fn prealloc_capacity(total_size: Option<u64>) -> Option<usize> { + let total = total_size?; + if total == 0 { + return None; + } + Some(total.min(MAX_PREALLOC_BYTES) as usize) +} + /// Internal state shared between reader and writer struct BufferState { /// Accumulated data from HTTP response @@ -199,9 +218,30 @@ impl BufferedMediaSource { /// Returns the source and a writer for pushing downloaded chunks. /// The writer should be used from the async download task. pub fn new(config: StreamingConfig, total_size: Option<u64>) -> (Self, BufferWriter) { + // When the total track size is known, reserve it up front: the + // buffer ends up holding the whole track either way, so this skips + // the ~8 doubling reallocs (and their memcpy) a 60-400 MB download + // would otherwise pay. Falls back to the initial-buffer capacity + // when the reservation fails (e.g. a 424 MB track on 32-bit, where + // the contiguous range may not exist) or the size is unknown. + let data = match prealloc_capacity(total_size) { + Some(cap) => { + let mut v = Vec::new(); + if v.try_reserve_exact(cap).is_err() { + log::warn!( + "Stream buffer: could not reserve {} bytes up front; growing dynamically", + cap + ); + v = Vec::with_capacity(config.initial_buffer_bytes); + } + v + } + None => Vec::with_capacity(config.initial_buffer_bytes), + }; + let state = Arc::new(( Mutex::new(BufferState { - data: Vec::with_capacity(config.initial_buffer_bytes), + data, download_complete: false, download_error: None, total_size, @@ -550,6 +590,30 @@ impl BufferWriter { 0 } } + + /// Copy the buffered bytes into `out` in bounded chunks, re-taking the + /// state lock per chunk instead of holding it for the whole transfer, + /// so live readers are starved for at most one chunk's write instead of + /// a full-track copy. Used by the low-memory oversized-track path to + /// persist the finished track to the L2 disk cache straight from the + /// playback buffer — no second full in-RAM copy. Returns bytes written. + pub fn write_buffered_to<W: std::io::Write>(&self, out: &mut W) -> IoResult<usize> { + const CHUNK: usize = 1024 * 1024; + let (lock, _) = &*self.state; + let mut offset = 0usize; + loop { + let state = lock + .lock() + .map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?; + if offset >= state.data.len() { + break; + } + let end = (offset + CHUNK).min(state.data.len()); + out.write_all(&state.data[offset..end])?; + offset = end; + } + Ok(offset) + } } // ============================================================================= @@ -587,6 +651,8 @@ pub struct IncrementalStreamingSource { stalled: bool, /// Reference to the buffered source (for cache retrieval after playback) buffered_source: Arc<BufferedMediaSource>, + /// Optional generation-safe side channel into the player's buffer state. + buffer_reporter: Option<PlaybackBufferReporter>, } impl IncrementalStreamingSource { @@ -597,6 +663,20 @@ impl IncrementalStreamingSource { /// /// Returns the source along with detected sample_rate and channels. pub fn new(buffered_source: Arc<BufferedMediaSource>) -> Result<Self, String> { + Self::new_inner(buffered_source, None) + } + + pub(super) fn new_for_play( + buffered_source: Arc<BufferedMediaSource>, + buffer_reporter: PlaybackBufferReporter, + ) -> Result<Self, String> { + Self::new_inner(buffered_source, Some(buffer_reporter)) + } + + fn new_inner( + buffered_source: Arc<BufferedMediaSource>, + buffer_reporter: Option<PlaybackBufferReporter>, + ) -> Result<Self, String> { // Create a reader from the buffered source let reader = buffered_source.create_reader(); let media_source = Box::new(reader) as Box<dyn MediaSource>; @@ -650,6 +730,7 @@ impl IncrementalStreamingSource { packets_decoded: 0, stalled: false, buffered_source, + buffer_reporter, }) } @@ -720,6 +801,9 @@ impl IncrementalStreamingSource { // mode so the live stream gets the pipe to itself (#591). self.stalled = true; qbz_audio::network_throttle::state().record_underrun(); + if let Some(reporter) = &self.buffer_reporter { + reporter.report(PlaybackBufferState::Underrun); + } } std::thread::sleep(Duration::from_millis(5)); continue; @@ -731,11 +815,19 @@ impl IncrementalStreamingSource { self.packets_decoded ); self.finished = true; + if self.buffered_source.download_error().is_some() { + if let Some(reporter) = &self.buffer_reporter { + reporter.report(PlaybackBufferState::Error); + } + } return; } Err(err) => { log::error!("Symphonia read error in stream: {}", err); self.finished = true; + if let Some(reporter) = &self.buffer_reporter { + reporter.report(PlaybackBufferState::Error); + } return; } }; @@ -746,6 +838,12 @@ impl IncrementalStreamingSource { match self.decoder.decode(&packet) { Ok(audio_buf) => { + // Vorbis/Opus setup, comment, and padding packets can decode to zero frames. + // Passing one to copy_interleaved_ref panics on the CPAL output thread, + // tearing down audio output and closing its command channel for the session. + if audio_buf.frames() == 0 { + continue; + } let spec = *audio_buf.spec(); let mut sample_buf = SampleBuffer::<f32>::new(audio_buf.frames() as u64, spec); sample_buf.copy_interleaved_ref(audio_buf); @@ -753,10 +851,16 @@ impl IncrementalStreamingSource { // Add samples to queue self.sample_queue .extend(sample_buf.samples().iter().copied()); + let ready_edge = should_report_ready(self.packets_decoded, self.stalled); self.packets_decoded += 1; // Successful decode ends any stall episode; the next // WouldBlock streak records a fresh underrun. self.stalled = false; + if ready_edge { + if let Some(reporter) = &self.buffer_reporter { + reporter.report(PlaybackBufferState::Ready); + } + } } Err(SymphoniaError::DecodeError(e)) => { log::warn!("Decode error (skipping packet): {}", e); @@ -769,6 +873,9 @@ impl IncrementalStreamingSource { Err(err) => { log::error!("Symphonia decode error: {}", err); self.finished = true; + if let Some(reporter) = &self.buffer_reporter { + reporter.report(PlaybackBufferState::Error); + } return; } } @@ -776,6 +883,10 @@ impl IncrementalStreamingSource { } } +const fn should_report_ready(packets_decoded: u64, stalled: bool) -> bool { + packets_decoded == 0 || stalled +} + impl Source for IncrementalStreamingSource { fn current_span_len(&self) -> Option<usize> { // We don't know frame boundaries in the queue @@ -812,17 +923,28 @@ impl Iterator for IncrementalStreamingSource { } } +/// Shared, immutable audio bytes: lets several readers (the player, the cast +/// media server, the cast visualizer's shadow decoder) sit on ONE copy of a +/// 100 MB track instead of cloning it per consumer. +pub struct SharedBytes(pub Arc<Vec<u8>>); + +impl AsRef<[u8]> for SharedBytes { + fn as_ref(&self) -> &[u8] { + self.0.as_slice() + } +} + /// Cursor-backed MediaSource for in-memory audio data. struct InMemoryMediaSource { - inner: Cursor<Vec<u8>>, + inner: Cursor<SharedBytes>, len: u64, } impl InMemoryMediaSource { - fn new(data: Vec<u8>) -> Self { + fn shared(data: Arc<Vec<u8>>) -> Self { let len = data.len() as u64; Self { - inner: Cursor::new(data), + inner: Cursor::new(SharedBytes(data)), len, } } @@ -876,7 +998,12 @@ pub struct InMemorySource { impl InMemorySource { pub fn new(data: Vec<u8>) -> Result<Self, String> { - let source = Box::new(InMemoryMediaSource::new(data)) as Box<dyn MediaSource>; + Self::from_shared(Arc::new(data)) + } + + /// Decode bytes that another consumer keeps alive too (no copy). + pub fn from_shared(data: Arc<Vec<u8>>) -> Result<Self, String> { + let source = Box::new(InMemoryMediaSource::shared(data)) as Box<dyn MediaSource>; let mss = MediaSourceStream::new(source, Default::default()); let hint = Hint::new(); @@ -919,6 +1046,14 @@ impl InMemorySource { }) } + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn channels(&self) -> u16 { + self.channels + } + pub fn seek_to(&mut self, time: Duration) -> Result<(), String> { self.format .seek( @@ -960,6 +1095,12 @@ impl InMemorySource { match self.decoder.decode(&packet) { Ok(audio_buf) => { + // Vorbis/Opus setup, comment, and padding packets can decode to zero frames. + // Passing one to copy_interleaved_ref panics on the CPAL output thread, + // tearing down audio output and closing its command channel for the session. + if audio_buf.frames() == 0 { + continue; + } let spec = *audio_buf.spec(); let mut sample_buf = SampleBuffer::<f32>::new(audio_buf.frames() as u64, spec); sample_buf.copy_interleaved_ref(audio_buf); @@ -1020,6 +1161,15 @@ mod tests { use std::thread; use std::time::Duration; + #[test] + fn ready_reports_only_on_first_decode_and_stall_recovery() { + assert!(should_report_ready(0, false)); + assert!(!should_report_ready(1, false)); + assert!(!should_report_ready(400, false)); + assert!(should_report_ready(1, true)); + assert!(should_report_ready(400, true)); + } + #[test] fn first_error_wins_over_later_generic_abort() { let (source, writer) = BufferedMediaSource::new(StreamingConfig::from_seconds(1), None); @@ -1100,6 +1250,67 @@ mod tests { assert_eq!(cfg.max_buffer_bytes, 100 * 1024 * 1024); } + #[test] + fn prealloc_capacity_decision() { + // Unknown or zero size: no reservation, caller uses the initial + // buffer capacity instead. + assert_eq!(prealloc_capacity(None), None); + assert_eq!(prealloc_capacity(Some(0)), None); + // Known size reserves exactly that (no doubling slack). + assert_eq!(prealloc_capacity(Some(60 * 1024 * 1024)), Some(60 * 1024 * 1024)); + assert_eq!(prealloc_capacity(Some(1)), Some(1)); + // Absurd sizes are capped at 1 GiB. + assert_eq!( + prealloc_capacity(Some(8 * 1024 * 1024 * 1024)), + Some(MAX_PREALLOC_BYTES as usize) + ); + } + + #[test] + fn new_source_preallocates_known_total_size() { + let config = StreamingConfig { + initial_buffer_bytes: 16, + max_buffer_bytes: 100, + }; + let (_source, writer) = BufferedMediaSource::new(config, Some(4096)); + // The writer's buffer should have reserved the full track up front. + // (No public capacity accessor — push the full size and ensure the + // data lands intact, which is the observable contract.) + let payload = vec![0xABu8; 4096]; + writer.push_chunk(&payload).unwrap(); + writer.complete().unwrap(); + assert_eq!(writer.buffer_size(), 4096); + } + + #[test] + fn write_buffered_to_copies_full_contents() { + let config = StreamingConfig { + initial_buffer_bytes: 4, + max_buffer_bytes: 100, + }; + let (_source, writer) = BufferedMediaSource::new(config, None); + writer.push_chunk(b"Hello, ").unwrap(); + writer.push_chunk(b"world!").unwrap(); + writer.complete().unwrap(); + + let mut out: Vec<u8> = Vec::new(); + let written = writer.write_buffered_to(&mut out).unwrap(); + assert_eq!(written, 13); + assert_eq!(&out, b"Hello, world!"); + } + + #[test] + fn write_buffered_to_handles_empty_buffer() { + let config = StreamingConfig { + initial_buffer_bytes: 4, + max_buffer_bytes: 100, + }; + let (_source, writer) = BufferedMediaSource::new(config, None); + let mut out: Vec<u8> = Vec::new(); + assert_eq!(writer.write_buffered_to(&mut out).unwrap(), 0); + assert!(out.is_empty()); + } + #[test] fn test_basic_read_write() { let config = StreamingConfig { diff --git a/crates/qbz-player/src/queue.rs b/crates/qbz-player/src/queue.rs index e6d6c5e4f..09f2c162a 100644 --- a/crates/qbz-player/src/queue.rs +++ b/crates/qbz-player/src/queue.rs @@ -7,7 +7,7 @@ //! - Repeat modes (off, all, one) //! - Play history for going back -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::sync::Mutex; use qbz_models::{QueueState, QueueTrack, RepeatMode}; @@ -18,7 +18,10 @@ enum QueueMoveDirection { Down, } +const MAX_HISTORY_LEN: usize = 50; + /// Internal queue state - all in one struct to avoid deadlocks +#[derive(Clone)] struct InternalState { /// All tracks in the queue (original order) tracks: Vec<QueueTrack>, @@ -36,8 +39,23 @@ struct InternalState { history: VecDeque<usize>, /// Track ID to stop after (optional) stop_after_track_id: Option<u64>, + /// Manual-block size (#442): how many entries right after `current_index` + /// were added by hand ("Play next" / "Play later"). The block always + /// plays before the source (album/playlist) resumes; "Add to queue" does + /// NOT extend it (it appends to the absolute end, untouched). + manual_next_count: usize, } +/// An exact, in-process snapshot of queue playback authority. +/// +/// The payload is deliberately opaque: callers can preserve and later restore +/// the queue, but cannot manufacture a partial or internally inconsistent +/// state. It is not a persistence format and is intended for transactional +/// authority handoffs such as QConnect delegation. +#[must_use = "an authority snapshot must be retained until it is restored or deliberately discarded"] +#[derive(Clone)] +pub struct QueueAuthoritySnapshot(InternalState); + /// Queue manager for handling playback queue pub struct QueueManager { state: Mutex<InternalState>, @@ -61,6 +79,7 @@ impl QueueManager { repeat: RepeatMode::Off, history: VecDeque::with_capacity(50), stop_after_track_id: None, + manual_next_count: 0, }), } } @@ -113,6 +132,48 @@ impl QueueManager { current_patched } + /// Attach an artwork url to every queued track whose id is in `ids`. + /// + /// The sibling of [`Self::patch_plex_quality`], for the other thing a row + /// can learn AFTER it was enqueued: a cover. A disc carries none — it is + /// fetched, and the fetch outlives the click that started playback — so a + /// CD queued at second two is still holding `artwork_url: None` when the + /// cover lands at second ten. Every consumer downstream reads the QUEUE + /// row (the now-playing bar, the miniplayer, MPRIS through + /// `art_url_for`), so patching the session store alone leaves all of them + /// blank. + /// + /// Rows that already have art are left alone: this fills a gap, it does + /// not overwrite a cover somebody else resolved. + /// + /// Returns `(any patched, the CURRENT track was patched)`. The two are + /// separate answers because they drive different work: any patch makes the + /// queue panel's thumbnails stale, while only the current one needs the + /// now-playing stamp re-pushed. + pub fn patch_artwork(&self, ids: &[u64], url: &str) -> (bool, bool) { + if ids.is_empty() || url.is_empty() { + return (false, false); + } + let mut state = self.state.lock().unwrap(); + let current_idx = state.current_index; + let mut any = false; + let mut current_patched = false; + for (idx, track) in state.tracks.iter_mut().enumerate() { + if track.artwork_url.as_deref().is_some_and(|u| !u.is_empty()) { + continue; + } + if !ids.contains(&track.id) { + continue; + } + track.artwork_url = Some(url.to_string()); + any = true; + if current_idx == Some(idx) { + current_patched = true; + } + } + (any, current_patched) + } + /// Add a track to the end of the queue pub fn add_track(&self, track: QueueTrack) { let mut state = self.state.lock().unwrap(); @@ -147,6 +208,8 @@ impl QueueManager { } else { state.tracks.insert(insert_index, track); } + // The track joins the manual block (#442). + state.manual_next_count += 1; if state.shuffle { for idx in state.shuffle_order.iter_mut() { @@ -170,16 +233,60 @@ impl QueueManager { } } + /// Add a track at the END of the manual block (#442 "Play later"): after + /// every play-next / play-later already queued, before the source + /// (album/playlist) resumes. "Add to queue" stays untouched at the + /// absolute end. With shuffle on, degrades to play-next — the block + /// concept is meaningless once the order is reshuffled. + pub fn add_track_later(&self, track: QueueTrack) { + let mut state = self.state.lock().unwrap(); + let insert_index = if state.shuffle { + state.current_index.map(|idx| idx + 1).unwrap_or(0) + } else { + state + .current_index + .map(|idx| idx + 1 + state.manual_next_count) + .unwrap_or(state.manual_next_count) + }; + let insert_index = insert_index.min(state.tracks.len()); + state.tracks.insert(insert_index, track); + state.manual_next_count += 1; + + if state.shuffle { + for idx in state.shuffle_order.iter_mut() { + if *idx >= insert_index { + *idx += 1; + } + } + let new_idx = insert_index; + let next_pos = if state.current_index.is_some() { + state.shuffle_position + 1 + } else { + state.shuffle_order.len() + }; + if next_pos >= state.shuffle_order.len() { + state.shuffle_order.push(new_idx); + } else { + state.shuffle_order.insert(next_pos, new_idx); + } + } + } + /// Set the entire queue (replaces existing) pub fn set_queue(&self, new_tracks: Vec<QueueTrack>, start_index: Option<usize>) { let mut state = self.state.lock().unwrap(); state.stop_after_track_id = None; + // A full replacement is a new source: the manual block dissolves (#442). + state.manual_next_count = 0; // Remap history by track id BEFORE replacing tracks so that legitimate // plays survive queue version bumps / reorders. Entries whose track is // no longer present are dropped. See bug #316. Self::remap_history_by_track_id_internal(&mut state, &new_tracks); state.tracks = new_tracks; state.current_index = start_index; + if let Some(current_index) = state.current_index { + state.history.retain(|&index| index != current_index); + } // Regenerate shuffle order Self::regenerate_shuffle_order_internal(&mut state); @@ -222,6 +329,9 @@ impl QueueManager { Self::remap_history_by_track_id_internal(&mut state, &new_tracks); state.tracks = new_tracks; state.current_index = start_index; + if let Some(current_index) = state.current_index { + state.history.retain(|&index| index != current_index); + } state.shuffle = shuffle_enabled; if !shuffle_enabled { @@ -259,6 +369,7 @@ impl QueueManager { pub fn clear(&self, keep_current: bool) { let mut state = self.state.lock().unwrap(); state.stop_after_track_id = None; + state.manual_next_count = 0; if keep_current { // Keep the track at `current_index`, not always `tracks[0]`. @@ -270,7 +381,10 @@ impl QueueManager { // History stores indices into `tracks`. Remap by track id // so entries for removed rows drop and any entry that still // refers to the kept track points at index 0. - Self::remap_history_by_track_id_internal(&mut state, std::slice::from_ref(&kept)); + Self::remap_history_by_track_id_internal( + &mut state, + std::slice::from_ref(&kept), + ); state.tracks = vec![kept]; state.current_index = Some(0); } else { @@ -307,6 +421,13 @@ impl QueueManager { let removed = state.tracks.remove(index); + // Removing an entry inside the manual block shrinks it (#442). + if let Some(curr_idx) = state.current_index { + if index > curr_idx && index <= curr_idx + state.manual_next_count { + state.manual_next_count = state.manual_next_count.saturating_sub(1); + } + } + // Invalidate marker if the removed track matches if state.stop_after_track_id == Some(removed.id) { state.stop_after_track_id = None; @@ -589,6 +710,198 @@ impl QueueManager { true } + /// Reorder one playback-history occurrence. `history_index` uses the + /// public most-recent-first coordinate while `to_slot` is an insertion + /// gap in the chronological (oldest-first) Queue View projection. + /// + /// History stores canonical queue indices, not track ids. Consequently two + /// independently enqueued copies of the same song remain independently + /// movable here. + pub fn move_history_entry( + &self, + history_index: usize, + expected_id: u64, + to_slot: usize, + ) -> bool { + let mut state = self.state.lock().unwrap(); + let Some(source_position) = state + .history + .len() + .checked_sub(history_index.saturating_add(1)) + else { + return false; + }; + let Some(&canonical_index) = state.history.get(source_position) else { + return false; + }; + if state.tracks.get(canonical_index).map(|track| track.id) != Some(expected_id) { + return false; + } + + let slot = to_slot.min(state.history.len()); + if slot == source_position || slot == source_position + 1 { + return false; + } + let Some(entry) = state.history.remove(source_position) else { + return false; + }; + let insertion = if slot > source_position { + slot - 1 + } else { + slot + }; + let insertion = insertion.min(state.history.len()); + state.history.insert(insertion, entry); + true + } + + /// Remove one Queue View history occurrence after an authoritative remote + /// renderer accepted it as a new upcoming item. The queue row itself stays + /// in place until the remote QueueUpdated echo materializes its canonical + /// order locally. + pub fn remove_history_entry(&self, history_index: usize, expected_id: u64) -> bool { + let mut state = self.state.lock().unwrap(); + let Some(source_position) = state + .history + .len() + .checked_sub(history_index.saturating_add(1)) + else { + return false; + }; + let Some(&canonical_index) = state.history.get(source_position) else { + return false; + }; + if state.tracks.get(canonical_index).map(|track| track.id) != Some(expected_id) { + return false; + } + + let before = state.history.len(); + // Historical sessions could contain the same canonical occurrence + // more than once. Removing all of those stale references establishes + // the same-entry/latest-play invariant immediately. + state.history.retain(|&index| index != canonical_index); + state.history.len() != before + } + + /// Move a played queue occurrence back into an Upcoming insertion slot. + /// This is deliberately a MOVE of the canonical occurrence, never an + /// `add_track` clone. Distinct queue occurrences may share a track id and + /// remain distinct; replaying one occurrence merely relocates that one. + pub fn requeue_history_entry( + &self, + history_index: usize, + expected_id: u64, + to_slot: usize, + ) -> bool { + let mut state = self.state.lock().unwrap(); + let Some(source_history_position) = state + .history + .len() + .checked_sub(history_index.saturating_add(1)) + else { + return false; + }; + let Some(&source_index) = state.history.get(source_history_position) else { + return false; + }; + if state.tracks.get(source_index).map(|track| track.id) != Some(expected_id) + || state.current_index == Some(source_index) + { + return false; + } + + if state.shuffle { + let Some(source_order_position) = state + .shuffle_order + .iter() + .position(|&index| index == source_index) + else { + return false; + }; + let current_order_position = match state.current_index { + Some(current_index) => { + let Some(position) = state + .shuffle_order + .iter() + .position(|&index| index == current_index) + else { + return false; + }; + Some(position) + } + None => None, + }; + let source_upcoming_position = match current_order_position { + Some(current_position) if source_order_position > current_position => { + Some(source_order_position - current_position - 1) + } + Some(_) => None, + None => Some(source_order_position), + }; + let current_after_removal = current_order_position.map(|current_position| { + if source_order_position < current_position { + current_position - 1 + } else { + current_position + } + }); + + state.history.retain(|&index| index != source_index); + state.shuffle_order.remove(source_order_position); + let base_position = current_after_removal.map(|position| position + 1).unwrap_or(0); + let insertion_slot = match source_upcoming_position { + Some(position) if to_slot > position => to_slot - 1, + _ => to_slot, + } + .min(state.shuffle_order.len().saturating_sub(base_position)); + state + .shuffle_order + .insert(base_position + insertion_slot, source_index); + state.shuffle_position = current_after_removal.unwrap_or(0); + return true; + } + + let current_before = state.current_index; + let source_upcoming_position = match current_before { + Some(current_index) if source_index > current_index => { + Some(source_index - current_index - 1) + } + Some(_) => None, + None => Some(source_index), + }; + let current_after_removal = current_before.map(|current_index| { + if source_index < current_index { + current_index - 1 + } else { + current_index + } + }); + let base_index = current_after_removal.map(|index| index + 1).unwrap_or(0); + let insertion_slot = match source_upcoming_position { + Some(position) if to_slot > position => to_slot - 1, + _ => to_slot, + } + .min( + state + .tracks + .len() + .saturating_sub(1) + .saturating_sub(base_index), + ); + let destination_index = base_index + insertion_slot; + + state.history.retain(|&index| index != source_index); + let track = state.tracks.remove(source_index); + state.tracks.insert(destination_index, track); + state.current_index = current_before + .map(|index| Self::remap_index_after_move(index, source_index, destination_index)); + for history_index in state.history.iter_mut() { + *history_index = + Self::remap_index_after_move(*history_index, source_index, destination_index); + } + true + } + /// Get current track pub fn current_track(&self) -> Option<QueueTrack> { let state = self.state.lock().unwrap(); @@ -678,6 +991,39 @@ impl QueueManager { result } + /// Inspect the track [`Self::previous`] would select without consuming + /// history or changing either queue cursor. Frontends use this before a + /// fallible physical-file handoff: publishing the previous row first can + /// leave metadata claiming a moved file while the old stream is audible. + pub fn peek_previous(&self) -> Option<QueueTrack> { + let state = self.state.lock().unwrap(); + if state.tracks.is_empty() { + return None; + } + if let Some(&prev_idx) = state.history.back() { + return state.tracks.get(prev_idx).cloned(); + } + let prev_idx = if state.shuffle { + if state.shuffle_position > 0 { + state.shuffle_order.get(state.shuffle_position - 1).copied() + } else if state.repeat == RepeatMode::All { + state.shuffle_order.last().copied() + } else { + state.shuffle_order.first().copied() + } + } else { + let curr_idx = state.current_index.unwrap_or(0); + if curr_idx > 0 { + Some(curr_idx - 1) + } else if state.repeat == RepeatMode::All { + Some(state.tracks.len().saturating_sub(1)) + } else { + Some(0) + } + }; + prev_idx.and_then(|idx| state.tracks.get(idx).cloned()) + } + /// Advance to next track and return it pub fn next(&self) -> Option<QueueTrack> { let mut state = self.state.lock().unwrap(); @@ -685,20 +1031,17 @@ impl QueueManager { return None; } - // Save current to history before moving - if let Some(curr_idx) = state.current_index { - state.history.push_back(curr_idx); - while state.history.len() > 50 { - state.history.pop_front(); - } - } - if state.repeat == RepeatMode::One { return state .current_index .and_then(|idx| state.tracks.get(idx).cloned()); } + // Save current to history before moving + if let Some(curr_idx) = state.current_index { + Self::record_history_internal(&mut state, curr_idx); + } + let next_idx = if state.shuffle { state.shuffle_position += 1; if state.shuffle_position < state.shuffle_order.len() { @@ -721,7 +1064,16 @@ impl QueueManager { } }; + if let Some(next_index) = next_idx { + state.history.retain(|&index| index != next_index); + } state.current_index = next_idx; + // Manual-block bookkeeping (#442): advancing past a manual entry + // shrinks the block; exhausting or wrapping the queue dissolves it. + match next_idx { + Some(0) | None => state.manual_next_count = 0, + Some(_) => state.manual_next_count = state.manual_next_count.saturating_sub(1), + } next_idx.and_then(|idx| state.tracks.get(idx).cloned()) } @@ -734,6 +1086,7 @@ impl QueueManager { // Try to get from history first if let Some(prev_idx) = state.history.pop_back() { + state.history.retain(|&index| index != prev_idx); state.current_index = Some(prev_idx); if state.shuffle { @@ -787,11 +1140,9 @@ impl QueueManager { if moved { // Record the outgoing track so `previous` still walks back. if let Some(curr_idx) = state.current_index { - state.history.push_back(curr_idx); - while state.history.len() > 50 { - state.history.pop_front(); - } + Self::record_history_internal(&mut state, curr_idx); } + state.history.retain(|&index| index != target); state.current_index = Some(target); // Keep the shuffle cursor aligned with the new position. if state.shuffle { @@ -827,6 +1178,124 @@ impl QueueManager { canonical_index.and_then(|idx| self.play_index(idx)) } + /// Move within the chronological Listen List without changing its leading + /// edge. Unlike [`Self::play_upcoming_at`], every row crossed on the way + /// to the target is appended to playback history in play order. The flat + /// projection therefore keeps the same sequence and only moves its NOW + /// cursor; ordinary album/playlist/queue-sidebar activation retains the + /// existing jump semantics through the sibling method above. + /// + /// `expected_id` is checked under the same lock as the cursor mutation. + /// Queue documents are snapshots, so an index that went stale between the + /// click and this call must not activate a different row. + pub fn play_upcoming_at_preserving_timeline( + &self, + upcoming_index: usize, + expected_id: u64, + ) -> Option<QueueTrack> { + let mut state = self.state.lock().unwrap(); + if state.tracks.is_empty() { + return None; + } + + let (target_index, crossed): (usize, Vec<usize>) = match state.current_index { + Some(_) if state.shuffle => { + let target_position = state + .shuffle_position + .checked_add(1)? + .checked_add(upcoming_index)?; + let target_index = *state.shuffle_order.get(target_position)?; + let crossed = state.shuffle_order[state.shuffle_position..target_position].to_vec(); + (target_index, crossed) + } + Some(current_index) => { + let target_index = current_index.checked_add(1)?.checked_add(upcoming_index)?; + if target_index >= state.tracks.len() { + return None; + } + (target_index, (current_index..target_index).collect()) + } + None => { + let target_index = upcoming_index; + if target_index >= state.tracks.len() { + return None; + } + // Starting midway through an idle Listen List still preserves + // its leading rows: they become the cursor's back path. + (target_index, (0..target_index).collect()) + } + }; + + if state.tracks.get(target_index).map(|track| track.id) != Some(expected_id) { + return None; + } + let target_shuffle_position = if state.shuffle { + Some( + state + .shuffle_order + .iter() + .position(|&index| index == target_index)?, + ) + } else { + None + }; + + for crossed_index in crossed { + Self::record_history_internal(&mut state, crossed_index); + } + state.history.retain(|&index| index != target_index); + state.current_index = Some(target_index); + state.manual_next_count = state + .manual_next_count + .saturating_sub(upcoming_index.saturating_add(1)); + if let Some(position) = target_shuffle_position { + state.shuffle_position = position; + } + + state.tracks.get(target_index).cloned() + } + + /// Move the Listen List cursor back to a most-recent-first history row. + /// This is the direct equivalent of invoking [`Self::previous`] until that + /// row becomes current: newer history entries return to the upcoming side + /// through the canonical/shuffle order and older history remains behind + /// the cursor. No row is cloned or inserted, so the flat list neither + /// duplicates nor changes its beginning. + pub fn play_history_at_preserving_timeline( + &self, + history_index: usize, + expected_id: u64, + ) -> Option<QueueTrack> { + let mut state = self.state.lock().unwrap(); + let target_position = state + .history + .len() + .checked_sub(history_index.checked_add(1)?)?; + let target_index = *state.history.get(target_position)?; + if state.tracks.get(target_index).map(|track| track.id) != Some(expected_id) { + return None; + } + let target_shuffle_position = if state.shuffle { + Some( + state + .shuffle_order + .iter() + .position(|&index| index == target_index)?, + ) + } else { + None + }; + + // `target_position` itself becomes NOW, so retain only rows older + // than it. VecDeque history is oldest-first internally. + state.history.truncate(target_position); + state.current_index = Some(target_index); + if let Some(position) = target_shuffle_position { + state.shuffle_position = position; + } + state.tracks.get(target_index).cloned() + } + /// Jump to a specific track by index pub fn play_index(&self, index: usize) -> Option<QueueTrack> { let mut state = self.state.lock().unwrap(); @@ -843,13 +1312,11 @@ impl QueueManager { // Matches `sync_current_to_id`'s `moved` guard. if let Some(curr_idx) = state.current_index { if curr_idx != index { - state.history.push_back(curr_idx); - while state.history.len() > 50 { - state.history.pop_front(); - } + Self::record_history_internal(&mut state, curr_idx); } } + state.history.retain(|&history_index| history_index != index); state.current_index = Some(index); if state.shuffle { @@ -867,22 +1334,40 @@ impl QueueManager { if state.shuffle == enabled { return; } - state.shuffle = enabled; if enabled { + state.shuffle = true; + // The manual block is meaningless once the order is reshuffled (#442). + state.manual_next_count = 0; Self::regenerate_shuffle_order_internal(&mut state); - // Enabling shuffle during active playback must keep current track - // as the first item in the shuffled timeline. Otherwise, indices - // before current are interpreted as already played. + // Shuffle only the part of the linear queue that has not been + // traversed yet. The canonical prefix stays behind the cursor, so + // enabling shuffle midway through an album cannot replay tracks + // the user already passed. if let Some(curr_idx) = state.current_index { - if let Some(pos) = state.shuffle_order.iter().position(|&idx| idx == curr_idx) { - if pos != 0 { - state.shuffle_order.swap(0, pos); - } - state.shuffle_position = 0; - } + let mut order = (0..=curr_idx).collect::<Vec<_>>(); + order.extend( + state + .shuffle_order + .iter() + .copied() + .filter(|&idx| idx > curr_idx), + ); + state.shuffle_order = order; + state.shuffle_position = curr_idx; } + } else { + // Turning shuffle off is a playback-order transition, not merely + // a flag change. If the shuffled cursor is on canonical track 8, + // deriving the ordinary tail from index 8 would silently discard + // every unheard track whose canonical index is lower. Flatten the + // traversed shuffle prefix + NOW + every still-unheard canonical + // occurrence into the physical queue before returning to linear + // traversal. History is remapped by occurrence index below and + // therefore keeps its chronology and duplicate rows intact. + Self::linearize_after_shuffle_internal(&mut state); + state.shuffle = false; } } @@ -891,14 +1376,20 @@ impl QueueManager { /// instead of generating a second independent shuffle. pub fn set_shuffle_with_order(&self, enabled: bool, shuffle_order: Option<Vec<usize>>) { let mut state = self.state.lock().unwrap(); - state.shuffle = enabled; if !enabled { - state.shuffle_order.clear(); - state.shuffle_position = 0; + if state.shuffle { + Self::linearize_after_shuffle_internal(&mut state); + } else { + state.shuffle_order.clear(); + state.shuffle_position = 0; + } + state.shuffle = false; return; } + state.shuffle = true; + if let Some(order) = shuffle_order.filter(|order| Self::is_valid_shuffle_order(order, state.tracks.len())) { @@ -1019,6 +1510,7 @@ impl QueueManager { repeat: state.repeat, total_tracks: state.tracks.len(), stop_after_track_id: state.stop_after_track_id, + manual_next_count: state.manual_next_count, } } @@ -1029,6 +1521,55 @@ impl QueueManager { (state.tracks.clone(), state.current_index) } + /// Capture the complete queue authority state under one lock. + /// + /// Unlike the frontend and persistence projections, this preserves every + /// playback-order detail without caps or normalization: canonical tracks, + /// current cursor, shuffle order and cursor, repeat mode, chronological + /// history, stop-after marker, and the manual-next block size. + pub fn capture_authority_snapshot(&self) -> QueueAuthoritySnapshot { + let state = self.state.lock().unwrap(); + QueueAuthoritySnapshot(state.clone()) + } + + /// Atomically replace the complete queue authority state with `snapshot`. + /// + /// Consuming the opaque snapshot prevents callers from restoring only a + /// subset of the coupled queue fields or mutating them between capture and + /// restore. + pub fn restore_authority_snapshot(&self, snapshot: QueueAuthoritySnapshot) { + let mut state = self.state.lock().unwrap(); + *state = snapshot.0; + } + + /// Capture the queue rows, cursor, and chronological play-history indices + /// under one lock for durable session persistence. + /// + /// History is stored oldest-first internally. Its indices refer to the + /// returned `tracks` vector, so callers must persist the three values as a + /// single snapshot. + pub fn get_persistable_state(&self) -> (Vec<QueueTrack>, Option<usize>, Vec<usize>) { + let state = self.state.lock().unwrap(); + ( + state.tracks.clone(), + state.current_index, + state.history.iter().copied().collect(), + ) + } + + /// Restore a persisted oldest-first play history for the current queue. + /// Invalid indices are discarded and only the manager's newest 50 entries + /// survive, matching the live playback-history bound. + pub fn restore_history_indices(&self, history: Vec<usize>) { + let mut state = self.state.lock().unwrap(); + state.history.clear(); + for index in history { + if state.current_index != Some(index) { + Self::record_history_internal(&mut state, index); + } + } + } + /// Get the full queue state without the upcoming/history caps applied by /// `get_state()`. Used by clients that paginate the upcoming list (e.g. /// the Queue sidebar's "UP NEXT" paginator) and need the complete history. @@ -1078,6 +1619,7 @@ impl QueueManager { repeat: state.repeat, total_tracks: state.tracks.len(), stop_after_track_id: state.stop_after_track_id, + manual_next_count: state.manual_next_count, } } @@ -1086,7 +1628,7 @@ impl QueueManager { let mut order: Vec<usize> = (0..state.tracks.len()).collect(); // Fisher-Yates shuffle with proper PRNG - use rand::{Rng, SeedableRng}; + use rand::{RngExt, SeedableRng}; use std::time::{SystemTime, UNIX_EPOCH}; let seed = SystemTime::now() @@ -1125,10 +1667,94 @@ impl QueueManager { } } + /// Convert the active shuffled timeline into a linear queue without + /// losing unheard rows whose canonical indices precede the current one. + /// + /// The new physical order is: + /// `traversed shuffle prefix · current · unheard rows in canonical order`. + /// Every coordinate stored by the manager is then remapped from the old + /// occurrence index to the new one. Track ids are deliberately irrelevant: + /// two separately queued copies of the same recording must remain two + /// separately positioned occurrences. + fn linearize_after_shuffle_internal(state: &mut InternalState) { + let track_count = state.tracks.len(); + let Some(current_index) = state.current_index.filter(|&idx| idx < track_count) else { + state.shuffle_order.clear(); + state.shuffle_position = 0; + return; + }; + let Some(current_position) = state + .shuffle_order + .iter() + .position(|&idx| idx == current_index) + else { + // Defensive fallback for a damaged/incomplete order: preserve the + // physical queue rather than guessing which rows were traversed. + state.shuffle_order.clear(); + state.shuffle_position = 0; + return; + }; + + let mut seen = vec![false; track_count]; + let mut linear_order = Vec::with_capacity(track_count); + for &index in state.shuffle_order.iter().take(current_position) { + if index < track_count && !seen[index] { + seen[index] = true; + linear_order.push(index); + } + } + if !seen[current_index] { + seen[current_index] = true; + linear_order.push(current_index); + } + for (index, was_seen) in seen.iter().enumerate() { + if !was_seen { + linear_order.push(index); + } + } + + let mut old_to_new = vec![None; track_count]; + for (new_index, &old_index) in linear_order.iter().enumerate() { + old_to_new[old_index] = Some(new_index); + } + let mut slots = std::mem::take(&mut state.tracks) + .into_iter() + .map(Some) + .collect::<Vec<_>>(); + state.tracks = linear_order + .iter() + .filter_map(|&old_index| slots[old_index].take()) + .collect(); + state.current_index = old_to_new[current_index]; + state.history = state + .history + .iter() + .filter_map(|&old_index| old_to_new.get(old_index).copied().flatten()) + .collect(); + state.shuffle_order.clear(); + state.shuffle_position = 0; + } + + /// Record one canonical queue occurrence as the newest played entry. + /// Replaying that SAME occurrence moves its existing record to the end; + /// independently enqueued copies remain distinct because their canonical + /// indices differ even when their track ids are equal. + fn record_history_internal(state: &mut InternalState, index: usize) { + if index >= state.tracks.len() { + return; + } + state.history.retain(|&existing| existing != index); + state.history.push_back(index); + while state.history.len() > MAX_HISTORY_LEN { + state.history.pop_front(); + } + } + /// Remap history entries from `state.tracks` indices to indices into - /// `new_tracks`, looking up by track id. Entries whose track id is no - /// longer present in `new_tracks` are dropped. Must be called with the - /// lock held and BEFORE `state.tracks` is replaced. + /// `new_tracks`. Repeated track ids are matched occurrence-by-occurrence + /// in canonical order rather than collapsed onto the last matching id. + /// Entries whose occurrence no longer exists are dropped. Must be called + /// with the lock held and BEFORE `state.tracks` is replaced. /// /// This preserves history across queue version bumps that don't change /// track identity (e.g. pure reorder, shuffle toggle, or an authoritative @@ -1139,23 +1765,32 @@ impl QueueManager { return; } - // Build lookup: track_id -> new index. If duplicate ids exist (rare), - // last occurrence wins; history will still resolve to a valid track. - let mut new_id_to_idx: std::collections::HashMap<u64, usize> = - std::collections::HashMap::with_capacity(new_tracks.len()); + // QueueTrack has no separate persisted occurrence UUID. Pairing the + // nth old occurrence with the nth new occurrence is the strongest + // stable identity available across an authoritative queue replacement + // and, crucially, does not merge duplicate queue entries. + let mut new_indices_by_id: HashMap<u64, VecDeque<usize>> = + HashMap::with_capacity(new_tracks.len()); for (idx, track) in new_tracks.iter().enumerate() { - new_id_to_idx.insert(track.id, idx); + new_indices_by_id.entry(track.id).or_default().push_back(idx); + } + let mut old_to_new = vec![None; state.tracks.len()]; + for (old_index, track) in state.tracks.iter().enumerate() { + old_to_new[old_index] = new_indices_by_id + .get_mut(&track.id) + .and_then(VecDeque::pop_front); } let mut remapped: VecDeque<usize> = VecDeque::with_capacity(state.history.len()); for &old_idx in state.history.iter() { - let Some(old_track) = state.tracks.get(old_idx) else { - continue; - }; - if let Some(&new_idx) = new_id_to_idx.get(&old_track.id) { + if let Some(new_idx) = old_to_new.get(old_idx).copied().flatten() { + remapped.retain(|&existing| existing != new_idx); remapped.push_back(new_idx); } } + while remapped.len() > MAX_HISTORY_LEN { + remapped.pop_front(); + } state.history = remapped; } @@ -1260,9 +1895,126 @@ mod tests { source_item_id_hint: None, context_kind: None, context_id: None, + isrc: None, + recording_mbid: None, } } + #[test] + fn authority_snapshot_round_trips_complete_shuffled_state() { + let queue = QueueManager::new(); + let mut tracks: Vec<_> = (101..=105).map(create_test_track).collect(); + tracks[2].version = Some("Authority snapshot edition".to_string()); + tracks[2].bit_depth = Some(24); + tracks[2].sample_rate = Some(192.0); + tracks[2].isrc = Some("TEST00000103".to_string()); + + let shuffle_order = vec![4, 2, 0, 3, 1]; + queue.set_queue_with_order(tracks, Some(2), true, Some(shuffle_order.clone())); + queue.play_index(3); + queue.set_repeat(RepeatMode::All); + queue.set_stop_after(105); + + let expected_tracks = { + let state = queue.state.lock().unwrap(); + format!("{:?}", state.tracks) + }; + let snapshot = queue.capture_authority_snapshot(); + + queue.clear(false); + queue.add_track(create_test_track(999)); + queue.play_index(0); + queue.set_repeat(RepeatMode::One); + + queue.restore_authority_snapshot(snapshot); + + let state = queue.state.lock().unwrap(); + assert_eq!(format!("{:?}", state.tracks), expected_tracks); + assert_eq!(state.current_index, Some(3)); + assert!(state.shuffle); + assert_eq!(state.shuffle_order, shuffle_order); + assert_eq!(state.shuffle_position, 3); + assert_eq!(state.repeat, RepeatMode::All); + assert_eq!(state.history, VecDeque::from([2])); + assert_eq!(state.stop_after_track_id, Some(105)); + assert_eq!(state.manual_next_count, 0); + } + + #[test] + fn authority_snapshot_round_trips_manual_next_block() { + let queue = QueueManager::new(); + queue.set_queue( + vec![ + create_test_track(1), + create_test_track(2), + create_test_track(3), + ], + Some(0), + ); + queue.add_track_next(create_test_track(10)); + queue.add_track_later(create_test_track(11)); + queue.set_repeat(RepeatMode::One); + queue.set_stop_after(3); + + let (expected_shuffle_order, expected_shuffle_position) = { + let state = queue.state.lock().unwrap(); + (state.shuffle_order.clone(), state.shuffle_position) + }; + let snapshot = queue.capture_authority_snapshot(); + queue.clear(false); + queue.restore_authority_snapshot(snapshot); + + let state = queue.state.lock().unwrap(); + assert_eq!( + state + .tracks + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![1, 10, 11, 2, 3] + ); + assert_eq!(state.current_index, Some(0)); + assert!(!state.shuffle); + assert_eq!(state.shuffle_order, expected_shuffle_order); + assert_eq!(state.shuffle_position, expected_shuffle_position); + assert_eq!(state.repeat, RepeatMode::One); + assert!(state.history.is_empty()); + assert_eq!(state.stop_after_track_id, Some(3)); + assert_eq!(state.manual_next_count, 2); + } + + #[test] + fn repeat_all_wraps_a_local_album_for_renderer_advance() { + let queue = QueueManager::new(); + for id in 1..=3 { + let mut track = create_test_track(id); + track.is_local = true; + track.source = Some("local".to_string()); + queue.add_track(track); + } + queue.play_index(2); + queue.set_repeat(RepeatMode::All); + + let wrapped = queue.next().expect("repeat-all must wrap the album"); + assert_eq!(wrapped.id, 1); + assert_eq!(queue.current_track().map(|track| track.id), Some(1)); + } + + #[test] + fn repeat_one_returns_the_same_local_track_for_renderer_advance() { + let queue = QueueManager::new(); + let mut track = create_test_track(7); + track.is_local = true; + track.source = Some("local".to_string()); + queue.add_track(track); + queue.play_index(0); + queue.set_repeat(RepeatMode::One); + + let repeated = queue.next().expect("repeat-one must replay the current row"); + assert_eq!(repeated.id, 7); + assert_eq!(queue.current_track().map(|track| track.id), Some(7)); + } + #[test] fn test_clear_without_current_track() { let queue = QueueManager::new(); @@ -1664,6 +2416,123 @@ mod tests { assert_eq!(state.upcoming.len(), 10); } + #[test] + fn disabling_shuffle_keeps_every_unheard_track_upcoming() { + let queue = QueueManager::new(); + queue.set_queue_with_order( + (1..=9).map(create_test_track).collect(), + Some(0), + true, + Some(vec![0, 7, 3, 1, 5, 8, 2, 6, 4]), + ); + + assert_eq!(queue.next().expect("shuffled track 8").id, 8); + queue.set_shuffle(false); + + let state = queue.get_state_full(); + assert!(!state.shuffle); + assert_eq!(state.total_tracks, 9); + assert_eq!(state.current_track.expect("current track").id, 8); + assert_eq!( + state + .history + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![1] + ); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![2, 3, 4, 5, 6, 7, 9] + ); + + let mut played_after_unshuffle = Vec::new(); + while let Some(track) = queue.next() { + played_after_unshuffle.push(track.id); + } + assert_eq!(played_after_unshuffle, vec![2, 3, 4, 5, 6, 7, 9]); + } + + #[test] + fn authoritative_shuffle_disable_keeps_every_unheard_track_upcoming() { + let queue = QueueManager::new(); + queue.set_queue_with_order( + (1..=6).map(create_test_track).collect(), + Some(0), + true, + Some(vec![0, 4, 2, 5, 1, 3]), + ); + + assert_eq!(queue.next().expect("shuffled track 5").id, 5); + queue.set_shuffle_with_order(false, None); + + let state = queue.get_state_full(); + assert!(!state.shuffle); + assert_eq!(state.current_track.expect("current track").id, 5); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![2, 3, 4, 6] + ); + } + + #[test] + fn disabling_shuffle_remaps_duplicate_occurrences_without_merging_them() { + let queue = QueueManager::new(); + let mut first_copy = create_test_track(7); + first_copy.title = "First copy".into(); + let mut second_copy = create_test_track(7); + second_copy.title = "Second copy".into(); + queue.set_queue_with_order( + vec![ + first_copy, + create_test_track(8), + second_copy, + create_test_track(9), + ], + Some(0), + true, + Some(vec![0, 2, 1, 3]), + ); + + assert_eq!(queue.next().expect("second copy").title, "Second copy"); + queue.set_shuffle(false); + + let state = queue.get_state_full(); + assert_eq!(state.total_tracks, 4); + assert_eq!(state.history[0].title, "First copy"); + assert_eq!(state.current_track.expect("current copy").title, "Second copy"); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![8, 9] + ); + } + + #[test] + fn enabling_shuffle_mid_queue_does_not_requeue_the_traversed_prefix() { + let queue = QueueManager::new(); + queue.set_queue((1..=7).map(create_test_track).collect(), Some(3)); + + queue.set_shuffle(true); + + let state = queue.get_state_full(); + assert!(state.shuffle); + assert_eq!(state.current_track.expect("current track").id, 4); + assert_eq!(state.upcoming.len(), 3); + assert!(state.upcoming.iter().all(|track| track.id > 4)); + } + #[test] fn test_play_upcoming_at_without_shuffle_uses_linear_offset() { let queue = QueueManager::new(); @@ -1677,6 +2546,59 @@ mod tests { assert_eq!(track.id, 4); } + #[test] + fn idle_queue_promotes_first_row_for_transport_play() { + let queue = QueueManager::new(); + queue.set_queue(vec![create_test_track(99)], Some(0)); + queue.clear(false); + + // All three enqueue actions keep an idle list cursor-less. "Later" + // and "Next" form the manual block ahead of a plain queued row. + queue.add_track(create_test_track(1)); + queue.add_track_later(create_test_track(2)); + queue.add_track_next(create_test_track(3)); + + let idle = queue.get_state_full(); + assert!(idle.current_track.is_none()); + assert_eq!( + idle.upcoming.iter().map(|track| track.id).collect::<Vec<_>>(), + vec![3, 2, 1] + ); + + let promoted = queue + .play_upcoming_at_preserving_timeline(0, 3) + .expect("first idle row"); + assert_eq!(promoted.id, 3); + + let playing = queue.get_state_full(); + assert_eq!(playing.current_track.expect("current").id, 3); + assert_eq!( + playing + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![2, 1] + ); + assert!(playing.history.is_empty()); + assert_eq!(playing.manual_next_count, 1); + } + + #[test] + fn peek_previous_matches_previous_without_consuming_history() { + let queue = QueueManager::new(); + for i in 1..=4 { + queue.add_track(create_test_track(i)); + } + queue.play_index(0); + assert_eq!(queue.next().expect("next").id, 2); + assert_eq!(queue.next().expect("next").id, 3); + + assert_eq!(queue.peek_previous().expect("peek").id, 2); + assert_eq!(queue.peek_previous().expect("second peek").id, 2); + assert_eq!(queue.previous().expect("previous").id, 2); + } + #[test] fn test_play_upcoming_at_with_shuffle_follows_shuffle_order() { let queue = QueueManager::new(); @@ -1709,6 +2631,314 @@ mod tests { assert_eq!(track.id, 4); } + #[test] + fn listen_list_upcoming_activation_preserves_linear_projection() { + let queue = QueueManager::new(); + queue.set_queue((1..=6).map(create_test_track).collect(), Some(1)); + + // Before: [2 NOW, 3, 4, 5, 6]. Selecting 5 must only move NOW. + let track = queue + .play_upcoming_at_preserving_timeline(2, 5) + .expect("track"); + assert_eq!(track.id, 5); + + let state = queue.get_state_full(); + assert_eq!(state.current_track.expect("current").id, 5); + assert_eq!( + state + .history + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![4, 3, 2] + ); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![6] + ); + } + + #[test] + fn listen_list_upcoming_activation_preserves_shuffle_projection() { + let queue = QueueManager::new(); + queue.set_queue_with_order( + (1..=5).map(create_test_track).collect(), + Some(2), + true, + Some(vec![2, 4, 1, 3, 0]), + ); + + // Before: [3 NOW, 5, 2, 4, 1]. Selecting 4 must retain 5 and 2 + // before the cursor instead of dropping them from the flat list. + let track = queue + .play_upcoming_at_preserving_timeline(2, 4) + .expect("track"); + assert_eq!(track.id, 4); + + let state = queue.get_state_full(); + assert_eq!(state.current_track.expect("current").id, 4); + assert_eq!( + state + .history + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![2, 5, 3] + ); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![1] + ); + } + + #[test] + fn listen_list_history_activation_moves_cursor_without_inserting() { + let queue = QueueManager::new(); + queue.set_queue((1..=5).map(create_test_track).collect(), Some(3)); + queue.restore_history_indices(vec![0, 1, 2]); + + // Core history is exposed newest-first as [3, 2, 1]. Selecting its + // middle row restores [1, 2 NOW, 3, 4, 5] without cloning track 2. + let track = queue + .play_history_at_preserving_timeline(1, 2) + .expect("track"); + assert_eq!(track.id, 2); + + let state = queue.get_state_full(); + assert_eq!(state.current_track.expect("current").id, 2); + assert_eq!( + state + .history + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![1] + ); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![3, 4, 5] + ); + assert_eq!(state.total_tracks, 5); + } + + #[test] + fn replaying_the_same_queue_occurrence_keeps_only_its_latest_history_entry() { + let queue = QueueManager::new(); + queue.set_queue((1..=3).map(create_test_track).collect(), Some(0)); + + assert_eq!(queue.next().expect("track 2").id, 2); + assert_eq!(queue.play_index(0).expect("replay track 1").id, 1); + assert_eq!( + queue + .get_state_full() + .history + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![2], + "the occurrence being replayed must leave History while current" + ); + + assert_eq!(queue.next().expect("track 2 again").id, 2); + assert_eq!(queue.next().expect("track 3").id, 3); + let history = queue.get_state_full().history; + assert_eq!( + history.iter().map(|track| track.id).collect::<Vec<_>>(), + vec![2, 1] + ); + assert_eq!(history.iter().filter(|track| track.id == 1).count(), 1); + } + + #[test] + fn separately_enqueued_copies_keep_distinct_history_occurrences() { + let queue = QueueManager::new(); + queue.set_queue( + vec![ + create_test_track(7), + create_test_track(8), + create_test_track(7), + create_test_track(9), + ], + Some(0), + ); + + queue.next(); + queue.next(); + queue.next(); + + let history = queue.get_state_full().history; + assert_eq!( + history.iter().map(|track| track.id).collect::<Vec<_>>(), + vec![7, 8, 7] + ); + assert_eq!(history.iter().filter(|track| track.id == 7).count(), 2); + assert_eq!(queue.get_persistable_state().2, vec![0, 1, 2]); + } + + #[test] + fn queue_view_reorders_history_in_chronological_slot_space() { + let queue = QueueManager::new(); + queue.set_queue((1..=4).map(create_test_track).collect(), Some(3)); + queue.restore_history_indices(vec![0, 1, 2]); + + // Visible History starts [1, 2, 3] (oldest first). Drag 1 to the + // trailing history gap, immediately before NOW. + assert!(queue.move_history_entry(2, 1, 3)); + assert_eq!( + queue + .get_state_full() + .history + .iter() + .rev() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![2, 3, 1] + ); + assert_eq!(queue.get_persistable_state().2, vec![1, 2, 0]); + } + + #[test] + fn queue_view_requeues_the_same_history_occurrence_without_cloning() { + let queue = QueueManager::new(); + queue.set_queue((1..=5).map(create_test_track).collect(), Some(3)); + queue.restore_history_indices(vec![0, 1, 2]); + + // History is publicly [3, 2, 1]. Move occurrence 1 to Upcoming slot + // 1: [2, 3, 4 NOW, 5, 1]. No sixth QueueTrack is created. + assert!(queue.requeue_history_entry(2, 1, 1)); + let moved = queue.get_state_full(); + assert_eq!(moved.total_tracks, 5); + assert_eq!(moved.current_track.expect("current").id, 4); + assert_eq!( + moved + .history + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![3, 2] + ); + assert_eq!( + moved + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![5, 1] + ); + + queue + .play_upcoming_at_preserving_timeline(1, 1) + .expect("requeued occurrence"); + assert_eq!( + queue + .get_state_full() + .history + .iter() + .filter(|track| track.id == 1) + .count(), + 0, + "the replayed occurrence is current, not also historical" + ); + assert!(queue.next().is_none()); + let finished = queue.get_state_full(); + assert_eq!(finished.total_tracks, 5); + assert_eq!(finished.history.iter().filter(|track| track.id == 1).count(), 1); + } + + #[test] + fn queue_view_requeues_only_the_selected_duplicate_occurrence() { + let queue = QueueManager::new(); + let mut first_lunch = create_test_track(7); + first_lunch.title = "Lunch A".into(); + let mut second_lunch = create_test_track(7); + second_lunch.title = "Lunch B".into(); + queue.set_queue( + vec![ + first_lunch, + create_test_track(8), + second_lunch, + create_test_track(9), + create_test_track(10), + ], + Some(3), + ); + queue.restore_history_indices(vec![0, 1, 2]); + + // Most-recent History row is Lunch B. Its id equals Lunch A, so only + // the phase coordinate/canonical occurrence can distinguish them. + assert!(queue.requeue_history_entry(0, 7, 0)); + let state = queue.get_state_full(); + assert_eq!(state.total_tracks, 5); + assert_eq!(state.history.iter().filter(|track| track.id == 7).count(), 1); + assert_eq!(state.history[1].title, "Lunch A"); + assert_eq!(state.upcoming[0].title, "Lunch B"); + } + + #[test] + fn queue_view_requeues_history_inside_the_shuffle_timeline() { + let queue = QueueManager::new(); + queue.set_queue_with_order( + (1..=5).map(create_test_track).collect(), + Some(2), + true, + Some(vec![0, 1, 2, 3, 4]), + ); + queue.restore_history_indices(vec![0, 1]); + + assert!(queue.requeue_history_entry(0, 2, 1)); + let state = queue.get_state_full(); + assert_eq!(state.total_tracks, 5); + assert_eq!(state.current_track.expect("current").id, 3); + assert_eq!( + state + .history + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![1] + ); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![4, 2, 5] + ); + } + + #[test] + fn listen_list_activation_rejects_a_stale_row_atomically() { + let queue = QueueManager::new(); + queue.set_queue((1..=4).map(create_test_track).collect(), Some(0)); + + assert!(queue.play_upcoming_at_preserving_timeline(1, 999).is_none()); + + let state = queue.get_state_full(); + assert_eq!(state.current_track.expect("current").id, 1); + assert!(state.history.is_empty()); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![2, 3, 4] + ); + } + #[test] fn test_set_shuffle_with_order_uses_authoritative_order() { let queue = QueueManager::new(); @@ -1953,6 +3183,44 @@ mod tests { ); } + #[test] + fn queue_replacement_does_not_merge_duplicate_track_occurrences() { + let queue = QueueManager::new(); + queue.set_queue( + vec![ + create_test_track(7), + create_test_track(8), + create_test_track(7), + create_test_track(9), + ], + Some(3), + ); + queue.restore_history_indices(vec![0, 2]); + + queue.set_queue_with_order( + vec![ + create_test_track(7), + create_test_track(7), + create_test_track(8), + create_test_track(9), + ], + Some(3), + false, + None, + ); + + assert_eq!(queue.get_persistable_state().2, vec![0, 1]); + assert_eq!( + queue + .get_state_full() + .history + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![7, 7] + ); + } + // ============ Stop-After Marker — Basic API ============ #[test] @@ -2195,10 +3463,7 @@ mod tests { fn test_remove_upcoming_after_linear() { let queue = QueueManager::new(); // 101 playing, upcoming = [102, 103, 104, 105]. - queue.set_queue( - (101..=105).map(create_test_track).collect(), - Some(0), - ); + queue.set_queue((101..=105).map(create_test_track).collect(), Some(0)); // Keep upcoming positions 0..=1 (102, 103); drop 2, 3 (104, 105). let removed = queue.remove_upcoming_after(1); @@ -2322,6 +3587,45 @@ mod tests { assert_eq!(full.history.first().unwrap().id, 25); } + #[test] + fn test_persistable_state_restores_bounded_history() { + let queue = QueueManager::new(); + queue.set_queue((1..=60).map(create_test_track).collect(), Some(55)); + + // Oldest-first, as captured on disk. More than the live 50-entry cap. + queue.restore_history_indices((0..55).collect()); + + let (tracks, current, history) = queue.get_persistable_state(); + assert_eq!(tracks.len(), 60); + assert_eq!(current, Some(55)); + assert_eq!(history.len(), 50); + assert_eq!(history.first(), Some(&5)); + assert_eq!(history.last(), Some(&54)); + + let full = queue.get_state_full(); + assert_eq!(full.history.first().map(|track| track.id), Some(55)); + assert_eq!(full.history.last().map(|track| track.id), Some(6)); + } + + #[test] + fn test_restore_history_discards_invalid_indices() { + let queue = QueueManager::new(); + queue.set_queue((1..=3).map(create_test_track).collect(), Some(2)); + + queue.restore_history_indices(vec![0, usize::MAX, 1, 99]); + + assert_eq!(queue.get_persistable_state().2, vec![0, 1]); + assert_eq!( + queue + .get_state_full() + .history + .iter() + .map(|track| track.id) + .collect::<Vec<_>>(), + vec![2, 1] + ); + } + #[test] fn test_get_state_full_no_current_track_returns_all_as_upcoming() { let queue = QueueManager::new(); diff --git a/crates/qbz-player/src/remote_stream.rs b/crates/qbz-player/src/remote_stream.rs new file mode 100644 index 000000000..ab3be2edc --- /dev/null +++ b/crates/qbz-player/src/remote_stream.rs @@ -0,0 +1,574 @@ +//! Progressive Range-streaming feeder — SHARED. +//! +//! Lifted verbatim out of `crates/qbz/src/remote_stream.rs` (the Slint binary), +//! where it had lived for no structural reason: its only imports are `std` and +//! this crate's own `BufferWriter` / `Player`, i.e. it never depended on Slint +//! at all. The Qt port had no streaming feeder because of that placement, so +//! its Plex path downloaded the WHOLE file before the first note — 58 MB, and +//! measurably slower than a Qobuz track streamed off the internet. +//! +//! ADR-006 (frontend-agnostic core) is the rule this restores. +//! +//! This shared extraction is now the sole implementation used by the +//! application frontends and renderer paths. + +//! Shared HTTP streaming feeder. +//! +//! Ports the Tauri `track_loading.rs` progressive feeder verbatim: probe a +//! remote audio URL for size + FLAC format, open the player's progressive +//! streaming sink (`Player::play_streaming_dynamic`), then push the body to the +//! returned `BufferWriter` chunk-by-chunk as it arrives. Playback starts as soon +//! as the initial buffer fills — not after the whole file lands. +//! +//! `reqwest + BufferWriter` bound only, so it stays frontend-side and never +//! crosses the qconnect-app boundary. Used by BOTH the QConnect renderer +//! (`qconnect_engine.rs`) and the Plex playback path (`playback.rs`), so there +//! is exactly one feeder. +//! +//! BIT-PERFECT: `play_streaming_dynamic` decodes the same original bytes and +//! drives the PROTECTED device init from the decoded stream. The +//! `sample_rate`/`bit_depth` parsed here are only the streaming-config hints; +//! the audio backend (`pipewire_backend.rs`, `init_device`, `audio_settings.rs`) +//! is untouched. + +use std::time::Duration; + +use crate::{BufferWriter, Player}; + +/// Format/size facts sniffed from a remote audio URL before streaming. +pub struct RemoteStreamInfo { + pub content_length: u64, + pub sample_rate: u32, + pub channels: u16, + pub bit_depth: u32, + /// Container/codec family detected from the bytes actually served. + pub format: &'static str, + pub speed_mbps: f64, +} + +/// Probe + open the progressive sink + spawn the background feeder. +/// +/// On success the player has begun buffering and `play_streaming_dynamic` will +/// start audio once the initial buffer fills; the body download runs in a +/// spawned task. Errors here mean the caller should fall back to a full +/// download (the probe or the sink open failed). +pub async fn stream_remote_track_into_player( + player: &Player, + track_id: u64, + duration_secs: u64, + start_position_secs: u64, + url: &str, + log_tag: &str, +) -> Result<(), String> { + stream_remote_track_into_player_with_headers( + player, + track_id, + duration_secs, + start_position_secs, + url, + log_tag, + &[], + ) + .await +} + +/// Variant of [`stream_remote_track_into_player`] that preserves a source's +/// HTTP request contract while keeping this transport source-agnostic. +/// +/// Plex media parts are the motivating case: their token is in the URL, but +/// some servers still reject the Range/body request unless the same +/// `X-Plex-*` identity used for metadata is present. Header values are never +/// included in errors or logs. +pub async fn stream_remote_track_into_player_with_headers( + player: &Player, + track_id: u64, + duration_secs: u64, + start_position_secs: u64, + url: &str, + log_tag: &str, + request_headers: &[(String, String)], +) -> Result<(), String> { + let stream_info = probe_remote_stream_info_with_headers(url, request_headers).await?; + log::info!( + "[{}/STREAMING] Track {} - {:.2} MB, {}Hz, {} ch, {}-bit {}, {:.1} MB/s", + log_tag, + track_id, + stream_info.content_length as f64 / (1024.0 * 1024.0), + stream_info.sample_rate, + stream_info.channels, + stream_info.bit_depth, + stream_info.format, + stream_info.speed_mbps + ); + + let writer = player + .play_streaming_dynamic( + track_id, + stream_info.sample_rate, + stream_info.channels, + stream_info.bit_depth, + stream_info.content_length, + stream_info.speed_mbps, + duration_secs, + start_position_secs, + ) + .map_err(|err| format!("start streaming remote track {track_id}: {err}"))?; + + let url = url.to_string(); + let content_length = stream_info.content_length; + let log_tag = log_tag.to_string(); + let request_headers = request_headers.to_vec(); + tokio::spawn(async move { + if let Err(err) = download_and_stream_remote_track_with_headers( + &url, + writer, + track_id, + content_length, + &log_tag, + &request_headers, + ) + .await + { + log::error!( + "[{}/STREAMING] Track {} failed while streaming: {}", + log_tag, + track_id, + err + ); + } + }); + + Ok(()) +} + +/// One small `Range: bytes=0-65535` GET supplies content length, throughput and +/// the audio header. The old HEAD + Range sequence paid two serial round trips +/// before the real body request; on Plex that was visible on every MP3 start, +/// and a surprising number of otherwise-valid media servers reject HEAD. +/// +/// A server that ignores Range may answer 200 with the whole file. We consume +/// only the first 64 KiB from its byte stream and drop the response, rather +/// than downloading the track once as a "probe" and then a second time into +/// the player. +pub async fn probe_remote_stream_info(url: &str) -> Result<RemoteStreamInfo, String> { + probe_remote_stream_info_with_headers(url, &[]).await +} + +pub async fn probe_remote_stream_info_with_headers( + url: &str, + request_headers: &[(String, String)], +) -> Result<RemoteStreamInfo, String> { + use futures_util::StreamExt; + use reqwest::header::{ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE}; + use reqwest::StatusCode; + use std::time::Instant; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|err| format!("create stream probe client: {err}"))?; + + let start_time = Instant::now(); + let request = client + .get(url) + .header("User-Agent", "Mozilla/5.0") + .header(ACCEPT_ENCODING, "identity") + .header("Range", "bytes=0-65535"); + let range_response = apply_request_headers(request, request_headers)? + .send() + .await + .map_err(|err| { + format!( + "probe range request failed: {}", + describe_reqwest_error(&err) + ) + })?; + + if !range_response.status().is_success() { + return Err(format!( + "probe range request failed with status {}", + range_response.status() + )); + } + + let partial = range_response.status() == StatusCode::PARTIAL_CONTENT; + let content_range = range_response + .headers() + .get(CONTENT_RANGE) + .and_then(|value| value.to_str().ok()); + let response_length = range_response + .headers() + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()); + let content_length = probe_content_length(partial, content_range, response_length) + .ok_or_else(|| "probe missing content length/range header".to_string())?; + + const PROBE_BYTES: usize = 65_536; + let mut initial_bytes = Vec::with_capacity(PROBE_BYTES); + let mut stream = range_response.bytes_stream(); + while initial_bytes.len() < PROBE_BYTES { + let Some(chunk) = stream.next().await else { + break; + }; + let chunk = chunk + .map_err(|err| format!("read probe bytes failed: {}", describe_reqwest_error(&err)))?; + let take = (PROBE_BYTES - initial_bytes.len()).min(chunk.len()); + initial_bytes.extend_from_slice(&chunk[..take]); + } + if initial_bytes.is_empty() { + return Err("probe returned no audio bytes".to_string()); + } + + let elapsed = start_time.elapsed(); + let speed_mbps = if elapsed.as_secs_f64() > 0.0 { + (initial_bytes.len() as f64 / elapsed.as_secs_f64()) / (1024.0 * 1024.0) + } else { + 10.0 + }; + + // FLAC's fixed STREAMINFO parser remains the strongest answer. For MP3, + // AAC, ALAC and the other direct-play containers, ask the same Symphonia + // probe the player itself uses. This removes the old unconditional + // 44.1/16 guess (wrong for 48 kHz MP3) before the protected backend opens. + let (sample_rate, channels, bit_depth, format) = + match qbz_models::probe_streaminfo(&initial_bytes) { + Some(p) => (p.sample_rate, p.channels, p.bits_per_sample, "FLAC"), + None => match crate::player::extract_audio_metadata_full(&initial_bytes) { + Ok(meta) => { + let format = if meta.codec == symphonia::core::codecs::CODEC_TYPE_MP3 { + "MP3" + } else { + "AUDIO" + }; + ( + meta.sample_rate, + meta.channels, + meta.bit_depth.unwrap_or(16), + format, + ) + } + Err(err) => { + log::warn!( + "[remote-stream] Header probe failed ({err}); using conservative 44.1/16" + ); + (44_100, 2, 16, "AUDIO") + } + }, + }; + + Ok(RemoteStreamInfo { + content_length, + sample_rate, + channels, + bit_depth, + format, + speed_mbps, + }) +} + +/// Parse the total from RFC 7233's `Content-Range`, e.g. +/// `bytes 0-65535/44790678`. `*` means the server does not know the total. +fn content_range_total(value: &str) -> Option<u64> { + value.rsplit_once('/')?.1.trim().parse().ok() +} + +/// A 206 `Content-Length` describes only the returned slice, never the media +/// object. Accept it as the total only for a 200 response where the server +/// ignored Range and sent the complete representation. +fn probe_content_length( + partial: bool, + content_range: Option<&str>, + content_length: Option<&str>, +) -> Option<u64> { + content_range.and_then(content_range_total).or_else(|| { + if partial { + None + } else { + content_length?.parse().ok() + } + }) +} + +/// Plain full-body GET → `bytes_stream()` loop → `writer.push_chunk` → +/// `writer.complete()`. No HTTP Range on the main GET (the `BufferedMediaSource` +/// buffers every pushed byte and serves seeks from the growing buffer). +pub async fn download_and_stream_remote_track( + url: &str, + writer: BufferWriter, + track_id: u64, + content_length: u64, + log_tag: &str, +) -> Result<(), String> { + download_and_stream_remote_track_with_headers( + url, + writer, + track_id, + content_length, + log_tag, + &[], + ) + .await +} + +pub async fn download_and_stream_remote_track_with_headers( + url: &str, + writer: BufferWriter, + track_id: u64, + content_length: u64, + log_tag: &str, + request_headers: &[(String, String)], +) -> Result<(), String> { + use futures_util::StreamExt; + use reqwest::header::ACCEPT_ENCODING; + use std::time::Instant; + + struct FailGuard { + writer: BufferWriter, + armed: bool, + } + impl Drop for FailGuard { + fn drop(&mut self) { + if self.armed { + let _ = self + .writer + .error("remote stream aborted before completion".into()); + } + } + } + let mut guard = FailGuard { + writer, + armed: true, + }; + let writer = &guard.writer; + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(300)) + .build() + .map_err(|err| format!("create remote streaming client: {err}"))?; + + let request = client + .get(url) + .header("User-Agent", "Mozilla/5.0") + .header(ACCEPT_ENCODING, "identity"); + let response = apply_request_headers(request, request_headers)? + .send() + .await + .map_err(|err| { + format!( + "start remote streaming request failed: {}", + describe_reqwest_error(&err) + ) + })?; + + if !response.status().is_success() { + return Err(format!( + "remote streaming request failed with status {}", + response.status() + )); + } + + let mut bytes_received = 0u64; + let mut stream = response.bytes_stream(); + let start_time = Instant::now(); + let mut last_log_time = Instant::now(); + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|err| { + format!( + "remote streaming chunk failed: {}", + describe_reqwest_error(&err) + ) + })?; + bytes_received += chunk.len() as u64; + + if let Err(err) = writer.push_chunk(&chunk) { + log::error!( + "[{}/STREAMING] Failed to push chunk for track {}: {}", + log_tag, + track_id, + err + ); + guard.armed = false; + let _ = writer.error(format!("push_chunk failed: {err}")); + return Err(format!("push_chunk failed: {err}")); + } + + let now = Instant::now(); + if now.duration_since(last_log_time) >= Duration::from_secs(2) && content_length > 0 { + let progress = (bytes_received as f64 / content_length as f64) * 100.0; + let avg_speed = + (bytes_received as f64 / start_time.elapsed().as_secs_f64()) / (1024.0 * 1024.0); + log::info!( + "[{}/STREAMING] Track {} {:.1}% ({:.2}/{:.2} MB) @ {:.2} MB/s", + log_tag, + track_id, + progress, + bytes_received as f64 / (1024.0 * 1024.0), + content_length as f64 / (1024.0 * 1024.0), + avg_speed + ); + last_log_time = now; + } + } + + guard.armed = false; + if let Err(err) = writer.complete() { + log::error!( + "[{}/STREAMING] Failed to mark stream complete for track {}: {}", + log_tag, + track_id, + err + ); + let _ = writer.error(format!("complete failed: {err}")); + return Err(format!("complete failed: {err}")); + } + + log::info!( + "[{}/STREAMING] Track {} complete: {:.2} MB in {:.1}s", + log_tag, + track_id, + bytes_received as f64 / (1024.0 * 1024.0), + start_time.elapsed().as_secs_f64() + ); + + Ok(()) +} + +fn apply_request_headers( + request: reqwest::RequestBuilder, + headers: &[(String, String)], +) -> Result<reqwest::RequestBuilder, String> { + Ok(request.headers(parsed_request_headers(headers)?)) +} + +fn parsed_request_headers( + headers: &[(String, String)], +) -> Result<reqwest::header::HeaderMap, String> { + use reqwest::header::{HeaderName, HeaderValue}; + + let mut parsed = reqwest::header::HeaderMap::new(); + for (name, value) in headers { + let header_name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| format!("invalid remote request header name {name:?}"))?; + let header_value = HeaderValue::from_str(value) + .map_err(|_| format!("invalid value for remote request header {name:?}"))?; + parsed.insert(header_name, header_value); + } + Ok(parsed) +} + +/// Return a bounded, URL-free diagnostic for a reqwest failure. +/// +/// The raw error and its source chain can contain a signed CDN URL. Inspect the +/// chain only to preserve header-limit classification; never copy an arbitrary +/// cause into logs or a returned error. +pub fn describe_reqwest_error(err: &reqwest::Error) -> String { + if error_chain_has_header_limit(err) { + return safe_transport_diagnostic("message head is too large").to_string(); + } + + if err.is_timeout() { + "HTTP transport timed out".to_string() + } else if err.is_connect() { + "HTTP transport connection failed".to_string() + } else if err.is_body() { + "HTTP response body failed".to_string() + } else if err.is_decode() { + "HTTP response decode failed".to_string() + } else if err.is_status() { + "HTTP status rejected".to_string() + } else { + "HTTP transport request failed".to_string() + } +} + +fn error_chain_has_header_limit(err: &reqwest::Error) -> bool { + use std::error::Error as _; + + if is_header_flood_error(&err.to_string()) { + return true; + } + let mut source = err.source(); + while let Some(cause) = source { + if is_header_flood_error(&cause.to_string()) { + return true; + } + source = cause.source(); + } + false +} + +fn safe_transport_diagnostic(message: &str) -> &'static str { + if is_header_flood_error(message) { + "HTTP response header limit exceeded (message head is too large)" + } else { + "HTTP transport request failed" + } +} + +/// True when an error message (already chain-expanded by +/// [`describe_reqwest_error`]) shows hyper's hard-coded h1 100-header cap. +/// Akamai answers SMALL raw-url objects with ~106 headers (the `X-AK-GRN` / +/// `X-AK-FWD-ERROR: ERR_POC_FWD_OBJ_TOO_SMALL` flood), so EVERY reqwest fetch +/// of such an URL fails this way — streaming probe and full download alike. +pub fn is_header_flood_error(message: &str) -> bool { + let haystack = message.to_ascii_lowercase(); + haystack.contains("message head is too large") || haystack.contains("too many headers") +} + +#[cfg(test)] +mod tests { + use super::{ + content_range_total, parsed_request_headers, probe_content_length, + safe_transport_diagnostic, + }; + + #[test] + fn transport_diagnostic_never_echoes_signed_url() { + let marker = "https://cdn.example/audio.flac?jwt=secret&request_sig=signed"; + let diagnostic = safe_transport_diagnostic(marker); + assert_eq!(diagnostic, "HTTP transport request failed"); + assert!(!diagnostic.contains(marker)); + assert!(!diagnostic.contains("secret")); + } + + #[test] + fn source_request_headers_are_parsed_for_the_http_request() { + let headers = + parsed_request_headers(&[("X-Plex-Product".to_string(), "QBZ".to_string())]).unwrap(); + assert_eq!( + headers + .get("X-Plex-Product") + .and_then(|value| value.to_str().ok()), + Some("QBZ") + ); + } + + #[test] + fn content_range_uses_the_whole_object_length() { + assert_eq!( + content_range_total("bytes 0-65535/44790678"), + Some(44_790_678) + ); + assert_eq!(content_range_total("bytes 0-65535/*"), None); + } + + #[test] + fn partial_content_length_is_never_mistaken_for_the_track_size() { + assert_eq!( + probe_content_length(true, Some("bytes 0-65535/44790678"), Some("65536")), + Some(44_790_678) + ); + assert_eq!(probe_content_length(true, None, Some("65536")), None); + } + + #[test] + fn full_response_length_is_valid_when_range_is_ignored() { + assert_eq!( + probe_content_length(false, None, Some("44790678")), + Some(44_790_678) + ); + } +} diff --git a/crates/qbz-player/testdata/cbr_no_xing.mp3 b/crates/qbz-player/testdata/cbr_no_xing.mp3 new file mode 100644 index 000000000..10f63d1c7 Binary files /dev/null and b/crates/qbz-player/testdata/cbr_no_xing.mp3 differ diff --git a/crates/qbz-playlist-import/Cargo.toml b/crates/qbz-playlist-import/Cargo.toml index 1cb5ec331..18c63e02b 100644 --- a/crates/qbz-playlist-import/Cargo.toml +++ b/crates/qbz-playlist-import/Cargo.toml @@ -23,6 +23,17 @@ thiserror = { workspace = true } # Logging log = { workspace = true } +# Playlist-FILE sources (2.0.3 expansion). Both were already resolved in the +# workspace lock, so neither adds a download or a new compilation unit to the +# tree — only an edge from this crate. +# roxmltree : XSPF. A read-only DOM walked by local element name is ~30 lines; +# quick-xml's event state machine only pays off for streaming, +# which the 16 MiB byte wall forecloses. +# encoding_rs: the utf8-then-Windows-1252 fallback for legacy .m3u / .pls, +# which is what keeps "Björk" and "Sigur Rós" matchable. +roxmltree = "0.21" +encoding_rs = "0.8" + # Async runtime + concurrency tokio = { workspace = true } futures-util = { workspace = true } diff --git a/crates/qbz-playlist-import/src/errors.rs b/crates/qbz-playlist-import/src/errors.rs index 97c936ec8..d8aa1ae52 100644 --- a/crates/qbz-playlist-import/src/errors.rs +++ b/crates/qbz-playlist-import/src/errors.rs @@ -17,4 +17,29 @@ pub enum PlaylistImportError { Parse(String), #[error("Qobuz error: {0}")] Qobuz(String), + + // --- The 2.0.3 expansion's semantic variants (design §11.9) ------------ + // + // These exist so the CONTROLLER can `match` and localize. The alternative + // the design weighed and rejected was reusing `Parse(String)` with a + // prefix and matching on the prefix — fragile in exactly the way a user + // never sees until their language is not English. + /// The bytes are not any playlist format this build reads. + #[error("Unrecognized playlist format")] + UnrecognizedFormat, + /// An `.m3u8` that is an HLS STREAM manifest, not a playlist. Its own + /// variant because the user's mistake is specific and so is the fix. + #[error("HLS stream manifest, not a playlist")] + HlsManifest, + /// Parsed fine and yielded nothing usable. + #[error("Playlist has no tracks")] + EmptyPlaylist, + /// Over `MAX_IMPORT_BYTES`. Refused BEFORE the parse, and on the app side + /// before the READ (design §7.3) — a 2 GB file must never reach RAM. + #[error("File too large to import")] + FileTooLarge, + /// Valid JSON with no track-shaped array in it. Fails loud rather than + /// half-importing whatever array it found first. + #[error("No track list recognized in this JSON")] + JsonShapeUnrecognized, } diff --git a/crates/qbz-playlist-import/src/importer.rs b/crates/qbz-playlist-import/src/importer.rs index 7de9d02d8..3b4886221 100644 --- a/crates/qbz-playlist-import/src/importer.rs +++ b/crates/qbz-playlist-import/src/importer.rs @@ -18,6 +18,9 @@ pub async fn preview_public_playlist(url: &str) -> Result<ImportPlaylist, Playli fetch_playlist(provider).await } +/// COMPAT SHIM — the pre-expansion signature, byte-for-byte. Every external +/// and test caller keeps working; it is now just "resolve the URL, then import +/// what came back". pub async fn import_public_playlist( url: &str, client: &QobuzClient, @@ -26,7 +29,30 @@ pub async fn import_public_playlist( progress: Arc<dyn ImportProgressSink>, ) -> Result<ImportSummary, PlaylistImportError> { let playlist = preview_public_playlist(url).await?; + import_prepared_playlist(playlist, client, name_override, is_public, progress).await +} +/// The IMPORT half, SOURCE-AGNOSTIC: match against the Qobuz catalog, create +/// the playlist (splitting past 2000 tracks) and add the matches. +/// +/// It takes an already-resolved [`ImportPlaylist`] rather than a URL, which is +/// what lets a file, a JSON blob or a service feed the same pipeline. The body +/// below is the pre-expansion `import_public_playlist` unchanged from the +/// matching phase down. +/// +/// TAKING THE PLAYLIST BY VALUE IS THE POINT. The Tauri original re-fetched +/// from the URL at execute time, and this port inherited that: it scraped the +/// same page twice per import. Worse, the pattern cannot express the new +/// sources at all — the `rfd` bytes of a picked file are gone by then and the +/// dialog cannot silently reopen. The caller snapshots the previewed playlist +/// and hands it over; the double-scrape dies with it. +pub async fn import_prepared_playlist( + playlist: ImportPlaylist, + client: &QobuzClient, + name_override: Option<&str>, + is_public: bool, + progress: Arc<dyn ImportProgressSink>, +) -> Result<ImportSummary, PlaylistImportError> { // Phase: matching progress.emit(ImportEvent::Phase(ImportPhase::Matching)); let matches = match_tracks(client, &playlist.tracks, Arc::clone(&progress)).await?; @@ -43,7 +69,21 @@ pub async fn import_public_playlist( let matched_count = matched_track_ids.len() as u32; let total_tracks = playlist.tracks.len() as u32; - let skipped_tracks = total_tracks.saturating_sub(matched_count); + // THREE DISJOINT NUMBERS, and they sum to `total_tracks`. + // + // `skipped` is counted from the matches themselves — rows that found + // nothing — instead of being inferred as `total - matched`. The inferred + // form folded duplicates into failures, and on a source that repeats + // (a Last.fm radio draw, a JSON export with the same track twice) that made + // the summary actively wrong: 469 rows, 453 matches, 198 distinct, and the + // user was told 271 tracks had been skipped. + let skipped_tracks = matches + .iter() + .filter(|m| m.qobuz_track_id.is_none()) + .count() as u32; + let duplicate_tracks = total_tracks + .saturating_sub(matched_count) + .saturating_sub(skipped_tracks); let mut qobuz_playlist_ids = Vec::new(); @@ -129,8 +169,71 @@ pub async fn import_public_playlist( total_tracks, matched_tracks: matched_count, skipped_tracks, + duplicate_tracks, qobuz_playlist_ids, parts_created, matches, }) } + +#[cfg(test)] +mod tests { + use crate::models::{ImportTrack, TrackMatch}; + + fn m(id: Option<u64>) -> TrackMatch { + TrackMatch { + source: ImportTrack { + title: "t".into(), + artist: "a".into(), + album: None, + duration_ms: None, + isrc: None, + provider_id: None, + provider_url: None, + }, + qobuz_track_id: id, + qobuz_title: None, + qobuz_artist: None, + score: 1.0, + } + } + + /// The arithmetic `import_prepared_playlist` performs on its match list, + /// isolated from the network. THE INVARIANT: matched + skipped + duplicates + /// == total, and each means exactly one thing. + /// + /// The shape is the real Last.fm radio import that exposed the bug — many + /// rows, most matching, heavy repetition — scaled down. + #[test] + fn the_three_counts_are_disjoint_and_sum_to_the_total() { + // 6 rows: ids 1,1,2,2,2 and one miss. + let matches = vec![ + m(Some(1)), + m(Some(1)), + m(Some(2)), + m(Some(2)), + m(Some(2)), + m(None), + ]; + let total = matches.len() as u32; + + let mut seen = std::collections::HashSet::new(); + let unique = matches + .iter() + .filter_map(|e| e.qobuz_track_id) + .filter(|id| seen.insert(*id)) + .count() as u32; + let skipped = matches + .iter() + .filter(|e| e.qobuz_track_id.is_none()) + .count() as u32; + let duplicates = total.saturating_sub(unique).saturating_sub(skipped); + + assert_eq!(unique, 2, "two distinct tracks reach the playlist"); + assert_eq!(skipped, 1, "one row matched nothing"); + assert_eq!(duplicates, 3, "three rows repeated an already-added track"); + assert_eq!(unique + skipped + duplicates, total); + // The OLD formula would have called all four non-unique rows skipped. + assert_ne!(total - unique, skipped); + } +} diff --git a/crates/qbz-playlist-import/src/lib.rs b/crates/qbz-playlist-import/src/lib.rs index a8313a7a9..6f9073c98 100644 --- a/crates/qbz-playlist-import/src/lib.rs +++ b/crates/qbz-playlist-import/src/lib.rs @@ -20,7 +20,22 @@ //! - Tidal: fetches a fresh proxy token per playlist fetch (no caching/expiry //! handling). TODO: cache the token until expiry. //! - Scrapers send no browser User-Agent (reqwest default) — TODO if any -//! provider starts gating on UA. +//! provider starts gating on UA. (The Last.fm source in `sources::service` +//! sets one PER REQUEST, because its CDN 403s an empty UA; the shared client +//! is deliberately left as it was.) +//! +//! # Sources beyond streaming URLs (2.0.3) +//! +//! [`PlaylistSource`] widens the front door to playlist FILES (XSPF / PLS / +//! M3U / M3U8), best-effort JSON, and two public-read services (ListenBrainz, +//! Last.fm). Every one of them resolves to the same [`models::ImportPlaylist`], +//! so nothing downstream — the Qobuz matcher, the 2000-track split, the +//! progress sink, the summary — learns that a second kind of source exists. +//! Design: `qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md`. +//! +//! FILES ARE READ FOR THEIR TRACK LIST ONLY. The paths inside a playlist file +//! are never opened, copied, or added to the Local Library; each entry is +//! matched against the Qobuz catalog like any other import. pub mod errors; pub mod importer; @@ -28,11 +43,20 @@ pub mod match_qobuz; pub mod models; pub mod providers; pub mod sink; +pub mod sources; mod http; pub use errors::PlaylistImportError; -pub use importer::{import_public_playlist, preview_public_playlist}; +pub use importer::{import_prepared_playlist, import_public_playlist, preview_public_playlist}; +// The 2.0.3 expansion: three new source classes, all resolving to the same +// `ImportPlaylist` the URL scrapers already produce, so the match -> create -> +// add pipeline behind `import_prepared_playlist` is untouched. +pub use sources::{FileFormat, LastFmStation, PlaylistSource, MAX_IMPORT_BYTES}; +// The Qobuz matcher's public surface. `match_tracks` stays the importer's own +// entry point; `rank_candidates` is the ranked-list form the "find available +// version" replacement flow needs (match_qobuz.rs). +pub use match_qobuz::{rank_candidates, MIN_MATCH_SCORE}; pub use models::{ ImportPlaylist, ImportProgress, ImportProvider, ImportSummary, ImportTrack, TrackMatch, }; diff --git a/crates/qbz-playlist-import/src/match_qobuz.rs b/crates/qbz-playlist-import/src/match_qobuz.rs index f77d28a17..bc1cc47db 100644 --- a/crates/qbz-playlist-import/src/match_qobuz.rs +++ b/crates/qbz-playlist-import/src/match_qobuz.rs @@ -132,7 +132,12 @@ fn select_best_match<'a>(track: &ImportTrack, candidates: &'a [Track]) -> (Optio let mut best_quality = 0.0f32; for candidate in candidates { - if !candidate.streamable { + // Absence is not unavailability: only an explicit `streamable: false` + // disqualifies a candidate. Before `Track.streamable` became + // `Option<bool>`, a `/track/search` payload that omitted the key + // disqualified EVERY candidate, so the import matched nothing at all — + // and did it silently, since "no match" is a normal import outcome. + if !candidate.is_streamable() { continue; } @@ -152,6 +157,69 @@ fn select_best_match<'a>(track: &ImportTrack, candidates: &'a [Track]) -> (Optio (best, best_score) } +/// The confidence floor [`match_tracks`] gates on, exposed so a UI that shows +/// the RANKED list can label a weak candidate instead of silently offering it +/// as if it were a match. Nothing here applies it — the importer still owns +/// that decision, and the replacement flow deliberately shows sub-threshold +/// rows because a human confirms the swap. +pub const MIN_MATCH_SCORE: f32 = MIN_SCORE; + +/// Every candidate for `track`, best first — the ordering [`select_best_match`] +/// picks its winner out of, handed over whole instead of collapsed to one row. +/// +/// This exists for the "find an available version" flow of a track Qobuz pulled +/// from the catalogue. There a HUMAN confirms the swap, so the surface needs the +/// order and every candidate's score, not just the winner; the reference (Tauri) +/// showed Qobuz's raw relevance order with no scoring at all, which is what this +/// replaces. +/// +/// Purely ADDITIVE by construction: [`match_tracks`] and [`select_best_match`] +/// are untouched, so the importer's behaviour cannot move because of it. +/// +/// The same three rules as `select_best_match`, in the same order, deliberately: +/// +/// 1. Non-streamable candidates are DROPPED, not ranked low. Offering a +/// replacement that is itself unplayable is the one outcome the whole +/// feature exists to prevent. +/// 2. The score is [`score_candidate`] verbatim — the ISRC short-circuit to +/// 1.0 first, then title 0.6 / artist 0.3 / album 0.1 plus the duration +/// bonus. +/// 3. Equal scores are broken by [`quality_score`] (bit depth first, sample +/// rate second). That is the owner's *"eliminan una calidad y agregan +/// otra"* case: the same recording re-published one tier up or down. +/// +/// # Why the score is BUCKETED rather than compared with a tolerance +/// +/// `select_best_match` treats scores within `0.01` as equal. Written directly +/// into a comparator that window is NOT transitive (a≈b, b≈c, a<c), and since +/// Rust 1.81 `slice::sort_by` may PANIC on a comparator that does not implement +/// a total order. Rounding to hundredths first is the same window expressed as +/// a key, so the ordering is total and the head of this list is still the track +/// `select_best_match` would have returned. +/// +/// The sort is STABLE, so candidates tied on both score and quality keep +/// Qobuz's own relevance order — the reference's ordering, kept as the last +/// tiebreak instead of being scrambled. +pub fn rank_candidates(track: &ImportTrack, candidates: &[Track]) -> Vec<(Track, f32)> { + let mut ranked: Vec<(Track, f32)> = candidates + .iter() + .filter(|candidate| candidate.is_streamable()) + .map(|candidate| (candidate.clone(), score_candidate(track, candidate))) + .collect(); + + // The 0.01 equality window of `select_best_match`, as a transitive key. + fn bucket(score: f32) -> i32 { + (score * 100.0).round() as i32 + } + + ranked.sort_by(|(a_track, a_score), (b_track, b_score)| { + bucket(*b_score) + .cmp(&bucket(*a_score)) + .then_with(|| quality_score(b_track).total_cmp(&quality_score(a_track))) + }); + ranked +} + fn score_candidate(track: &ImportTrack, candidate: &Track) -> f32 { if let (Some(isrc), Some(candidate_isrc)) = (&track.isrc, &candidate.isrc) { if isrc.eq_ignore_ascii_case(candidate_isrc) { @@ -329,6 +397,7 @@ mod tests { version: None, work: None, isrc: None, + audio_info: None, duration: 0, track_number: 0, media_number: None, @@ -341,7 +410,9 @@ mod tests { hires_streamable: false, maximum_sampling_rate: None, maximum_bit_depth: None, - streamable: true, + streamable: Some(true), + streamable_at: None, + release_date_stream: None, parental_warning: false, playlist_track_id: None, performers: None, @@ -357,6 +428,11 @@ mod tests { image: ImageSet::default(), label: None, genre: None, + artist: None, + version: None, + streamable: None, + streamable_at: None, + release_date_stream: None, } } @@ -483,7 +559,9 @@ mod tests { fn select_best_match_skips_non_streamable() { let source = import_track("hey jude", "the beatles"); let mut perfect = qobuz_track(1, "hey jude", "the beatles"); - perfect.streamable = false; + // Explicit `Some(false)` — the skip must fire on the server SAYING no, + // never on a payload that simply stayed quiet (see `is_streamable`). + perfect.streamable = Some(false); let weaker = qobuz_track(2, "hey jude na na", "the beatles"); let candidates = [perfect, weaker]; @@ -535,6 +613,74 @@ mod tests { assert_eq!(score, 0.0); } + // ── rank_candidates ── + + #[test] + fn rank_candidates_drops_non_streamable() { + // The dropped one is the PERFECT match, so a rank that merely scored it + // low would still surface it first — only an actual drop passes. + let source = import_track("hey jude", "the beatles"); + let mut dead = qobuz_track(1, "hey jude", "the beatles"); + dead.streamable = Some(false); + let live = qobuz_track(2, "hey jude na na", "the beatles"); + + let ranked = rank_candidates(&source, &[dead, live]); + assert_eq!(ranked.len(), 1); + assert_eq!(ranked[0].0.id, 2); + } + + #[test] + fn rank_candidates_head_is_select_best_match() { + // The contract that lets the modal preselect row 0: the head of the + // ranked list IS the winner the importer would have taken. + let source = import_track("hey jude", "the beatles"); + let weak = qobuz_track(1, "let it be", "the beatles"); + let strong = qobuz_track(2, "hey jude", "the beatles"); + + let candidates = [weak, strong]; + let (best, _) = select_best_match(&source, &candidates); + let ranked = rank_candidates(&source, &candidates); + assert_eq!(ranked[0].0.id, best.map(|t| t.id).unwrap()); + assert!(ranked[0].1 > ranked[1].1); + } + + #[test] + fn rank_candidates_isrc_hit_leads_with_score_one() { + // The "same recording, new album id" case: a candidate whose TEXT is + // wrong but whose ISRC matches must still lead at 1.0. + let mut source = import_track("Completely Different", "Nobody"); + source.isrc = Some("uskO11600123".to_string()); + let mut relink = qobuz_track(1, "Other Title", "Other Artist"); + relink.isrc = Some("USKO11600123".to_string()); + let decoy = qobuz_track(2, "Completely Different", "Nobody"); + + let ranked = rank_candidates(&source, &[decoy, relink]); + assert_eq!(ranked[0].0.id, 1); + assert_eq!(ranked[0].1, 1.0); + } + + #[test] + fn rank_candidates_equal_scores_prefer_the_better_tier() { + // The owner's "eliminan una calidad y agregan otra" case, and the reason + // the score is bucketed: these two score identically. + let source = import_track("hey jude", "the beatles"); + let mut cd = qobuz_track(1, "hey jude", "the beatles"); + cd.maximum_bit_depth = Some(16); + cd.maximum_sampling_rate = Some(44.1); + let mut hires = qobuz_track(2, "hey jude", "the beatles"); + hires.maximum_bit_depth = Some(24); + hires.maximum_sampling_rate = Some(192.0); + + let ranked = rank_candidates(&source, &[cd, hires]); + assert_eq!(ranked[0].0.id, 2); + } + + #[test] + fn rank_candidates_empty_is_empty_not_a_panic() { + let source = import_track("hey jude", "the beatles"); + assert!(rank_candidates(&source, &[]).is_empty()); + } + // ── quality_score ── #[test] diff --git a/crates/qbz-playlist-import/src/models.rs b/crates/qbz-playlist-import/src/models.rs index 767f81f26..4af379e8d 100644 --- a/crates/qbz-playlist-import/src/models.rs +++ b/crates/qbz-playlist-import/src/models.rs @@ -2,21 +2,46 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Where a playlist came FROM, as a CLASS — never a format. +/// +/// The four streaming variants are the original set. The four added by the +/// 2.0.3 expansion are per-class on purpose (design §4.3): `provider_display_name` +/// returns `&'static str` and cannot hold an owned string, so the IDENTITY of a +/// source — the filename, the ListenBrainz playlist title, the Last.fm +/// username — belongs in `ImportPlaylist.name` / `.provider_id`, which are +/// free-form. "Imported from Playlist file" plus the filename in `.name` is the +/// right split; `M3u`/`Pls`/`Xspf` variants would bloat two exhaustive matches +/// for no UX gain. +/// +/// JSON stays separate from File because the user is told a different thing +/// about it — it is read best-effort and the track count is the gate. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum ImportProvider { Spotify, AppleMusic, Tidal, Deezer, + /// XSPF / PLS / M3U / M3U8. + File, + /// Best-effort JSON. + Json, + ListenBrainz, + LastFm, } impl ImportProvider { + /// The wire/log form. `importer.rs`'s `format!("Imported from {}", …)` + /// default description reads this, so a new variant needs nothing there. pub fn as_str(&self) -> &'static str { match self { ImportProvider::Spotify => "spotify", ImportProvider::AppleMusic => "apple_music", ImportProvider::Tidal => "tidal", ImportProvider::Deezer => "deezer", + ImportProvider::File => "file", + ImportProvider::Json => "json", + ImportProvider::ListenBrainz => "listenbrainz", + ImportProvider::LastFm => "lastfm", } } } @@ -55,8 +80,20 @@ pub struct ImportSummary { pub provider: ImportProvider, pub playlist_name: String, pub total_tracks: u32, + /// DISTINCT Qobuz tracks actually added — the playlist's real length. pub matched_tracks: u32, + /// Source rows that found no Qobuz track at all. + /// + /// It used to be `total - matched`, which silently counted DUPLICATES as + /// skipped: a source row that matched a track another row had already + /// matched is deduplicated before the add, so it is neither in + /// `matched_tracks` nor a failure. One real Last.fm import reported + /// "Skipped: 271" when 255 of those had matched perfectly well and only 16 + /// had missed. Now the three numbers are disjoint and sum to + /// `total_tracks`. pub skipped_tracks: u32, + /// Source rows that matched a track ALREADY contributed by an earlier row. + pub duplicate_tracks: u32, pub qobuz_playlist_ids: Vec<u64>, pub parts_created: u32, pub matches: Vec<TrackMatch>, diff --git a/crates/qbz-playlist-import/src/sources/file/decode.rs b/crates/qbz-playlist-import/src/sources/file/decode.rs new file mode 100644 index 000000000..5c16f0603 --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/file/decode.rs @@ -0,0 +1,243 @@ +//! Bytes -> text, and the two string chores every text format shares. + +use crate::models::ImportTrack; + +/// Strip a UTF-8 / UTF-16 BOM. +pub(crate) fn strip_bom(bytes: &[u8]) -> &[u8] { + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + &bytes[3..] + } else if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) { + &bytes[2..] + } else { + bytes + } +} + +/// Decode a legacy playlist body. +/// +/// Strict UTF-8 first. On failure, WINDOWS-1252 — not because it is likely to +/// be right in the abstract, but because `.m3u` and `.pls` predate UTF-8 by a +/// decade and every Windows player that ever wrote one wrote 1252. Without the +/// fallback "Björk" and "Sigur Rós" arrive as replacement characters and stop +/// matching, which is a silent quality loss, not a crash. +/// +/// UTF-16 is deliberately NOT attempted: the BOM is stripped above and a +/// UTF-16 body without one is indistinguishable from binary. Such a file falls +/// out at format detection as unrecognized, which is the honest answer. +pub(crate) fn decode_text(bytes: &[u8]) -> String { + let body = strip_bom(bytes); + match std::str::from_utf8(body) { + Ok(s) => s.to_string(), + Err(_) => { + let (cow, _, _) = encoding_rs::WINDOWS_1252.decode(body); + cow.into_owned() + } + } +} + +/// Split lines on `\n`, `\r\n` AND a lone `\r` (classic Mac exports), trimming +/// each. `str::lines` does not handle the lone `\r`. +pub(crate) fn split_lines(text: &str) -> Vec<&str> { + text.split(['\n', '\r']) + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect() +} + +/// The `#EXTINF` / `TitleN` "Artist - Title" convention. +/// +/// LOSSY, AND THE LOSS IS WORTH NAMING: split on the FIRST " - ", so +/// "Bob Dylan - Ballad of a Thin Man" is right and "Godspeed You! Black +/// Emperor - Dead Flag Blues - Part 2" keeps the tail in the title, which is +/// the better of the two mistakes available. A row with no " - " becomes +/// title-only with an empty artist — and the matcher tops out at 0.6 for a +/// title alone, below its 0.65 threshold, so such a row almost always lands in +/// `skipped_tracks`. That is the honest outcome of a format that never carried +/// the artist separately; it is reported, not hidden. +pub(crate) fn split_artist_title(raw: &str) -> (String, String) { + let raw = raw.trim(); + match raw.split_once(" - ") { + Some((artist, title)) => (artist.trim().to_string(), title.trim().to_string()), + None => (String::new(), raw.to_string()), + } +} + +/// The filename without its directory or extension — the last-resort title, +/// and the playlist name when the format carries none. +pub(crate) fn file_stem(path: &str) -> String { + let name = path + .rsplit(['/', '\\']) + .next() + .unwrap_or(path); + match name.rsplit_once('.') { + // A leading dot is not an extension separator (".hidden"). + Some((stem, _)) if !stem.is_empty() => stem.to_string(), + _ => name.to_string(), + } +} + +/// A URL-decoded, directory-stripped stem for a `<location>` / file path. +pub(crate) fn location_stem(location: &str) -> String { + let cleaned = location + .trim() + .trim_start_matches("file://") + .split(['?', '#']) + .next() + .unwrap_or(location); + file_stem(&percent_decode(cleaned)) +} + +/// Minimal percent-decoding. The crate has no `percent-encoding` dependency and +/// this is the only place that needs it: XSPF `<location>` is a URI, so +/// "Sigur%20R%C3%B3s.flac" has to come back as text before the stem is taken. +/// Invalid escapes are left verbatim rather than dropped. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out: Vec<u8> = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); + if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(v); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// A track with a derived title, or `None` when even the fallbacks are empty. +/// The ONE skip rule shared by the three file parsers. +pub(crate) fn track_or_skip( + title: String, + artist: String, + album: Option<String>, + duration_ms: Option<u64>, + isrc: Option<String>, +) -> Option<ImportTrack> { + let title = title.trim().to_string(); + if title.is_empty() { + return None; + } + Some(ImportTrack { + title, + artist: artist.trim().to_string(), + album: album.map(|a| a.trim().to_string()).filter(|a| !a.is_empty()), + duration_ms, + isrc, + provider_id: None, + provider_url: None, + }) +} + +/// Uppercase + strip every non-alphanumeric, then accept only the 12-character +/// ISRC shape. +/// +/// NORMALIZATION IS REQUIRED, not cosmetic: `match_qobuz.rs` compares with +/// `eq_ignore_ascii_case` and NO hyphen stripping, while Qobuz stores the bare +/// 12-character form. A tagged "US-KO1-16-00123" would therefore never fire the +/// score-1.0 fast path — the single highest-value match this whole pipeline has. +pub(crate) fn normalize_isrc(raw: &str) -> Option<String> { + let cleaned: String = raw + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_uppercase()) + .collect(); + if is_isrc_shaped(&cleaned) { + Some(cleaned) + } else { + None + } +} + +/// `CCXXXYYNNNNN` — 2 letters, 3 alphanumerics, 7 digits. +pub(crate) fn is_isrc_shaped(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 12 + && b[0..2].iter().all(|c| c.is_ascii_alphabetic()) + && b[2..5].iter().all(|c| c.is_ascii_alphanumeric()) + && b[5..12].iter().all(|c| c.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bom_is_stripped_and_utf8_survives() { + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice("Björk".as_bytes()); + assert_eq!(decode_text(&bytes), "Björk"); + } + + #[test] + fn windows_1252_accents_are_recovered() { + // "Sigur Rós" in Windows-1252: ó is 0xF3, which is invalid UTF-8 alone. + let bytes = b"Sigur R\xF3s".to_vec(); + assert!(std::str::from_utf8(&bytes).is_err()); + assert_eq!(decode_text(&bytes), "Sigur Rós"); + } + + #[test] + fn lines_split_on_lone_cr() { + assert_eq!(split_lines("a\rb\r\nc\nd"), vec!["a", "b", "c", "d"]); + } + + #[test] + fn artist_title_splits_on_the_first_dash_only() { + assert_eq!( + split_artist_title("Bob Dylan - Ballad of a Thin Man"), + ("Bob Dylan".to_string(), "Ballad of a Thin Man".to_string()) + ); + // The tail stays with the title. + assert_eq!( + split_artist_title("GY!BE - Dead Flag Blues - Part 2"), + ("GY!BE".to_string(), "Dead Flag Blues - Part 2".to_string()) + ); + // No separator: title only, empty artist. + assert_eq!( + split_artist_title("Untitled"), + (String::new(), "Untitled".to_string()) + ); + // A hyphen without spaces is NOT the separator. + assert_eq!( + split_artist_title("Jay-Z"), + (String::new(), "Jay-Z".to_string()) + ); + } + + #[test] + fn stems_drop_directory_and_extension() { + assert_eq!(file_stem("/music/Road Trip.m3u"), "Road Trip"); + assert_eq!(file_stem("C:\\lists\\Party.pls"), "Party"); + assert_eq!(file_stem("plain"), "plain"); + assert_eq!(file_stem(".hidden"), ".hidden"); + } + + #[test] + fn location_stem_percent_decodes() { + assert_eq!( + location_stem("file:///music/Sigur%20R%C3%B3s%20-%20Hoppipolla.flac"), + "Sigur Rós - Hoppipolla" + ); + // A bad escape survives verbatim rather than eating the string. + assert_eq!(location_stem("/m/100%.flac"), "100%"); + } + + #[test] + fn isrc_normalizes_to_the_bare_form_qobuz_stores() { + assert_eq!( + normalize_isrc("US-KO1-16-00123").as_deref(), + Some("USKO11600123") + ); + assert_eq!(normalize_isrc("usko11600123").as_deref(), Some("USKO11600123")); + // A MusicBrainz recording id is NOT an ISRC and must never pass — it + // would misfire the score-1.0 short circuit. + assert_eq!(normalize_isrc("b1a9c0e9-1b47-4f3d-9a1e-000000000000"), None); + assert_eq!(normalize_isrc("too-short"), None); + } +} diff --git a/crates/qbz-playlist-import/src/sources/file/m3u.rs b/crates/qbz-playlist-import/src/sources/file/m3u.rs new file mode 100644 index 000000000..32c875690 --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/file/m3u.rs @@ -0,0 +1,219 @@ +//! M3U / M3U8. +//! +//! A line grammar, hand-rolled — no dependency. Every `m3u` crate on crates.io +//! is either HLS-focused or unmaintained, and the whole format is: comment +//! lines starting `#`, everything else a path or URL. +//! +//! Directives read: `#EXTM3U` (header), `#EXTINF:<secs>,<Artist - Title>`, +//! `#EXTALB:<album>`, `#EXTART:<artist>`, `#PLAYLIST:<name>`. + +use super::decode::{file_stem, location_stem, split_artist_title, split_lines, track_or_skip}; +use crate::errors::PlaylistImportError; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +/// The tags that make an `.m3u8` an HLS STREAM manifest rather than a playlist. +/// +/// `.m3u8` is the extension for both, and they are not remotely the same file. +/// Without this guard a user who drops a stream manifest in gets a playlist of +/// segment filenames — garbage that then fails to match, with nothing telling +/// them why. +const HLS_MARKERS: &[&str] = &[ + "#EXT-X-STREAM-INF", + "#EXT-X-TARGETDURATION", + "#EXT-X-VERSION", + "#EXT-X-MEDIA", + "#EXT-X-PLAYLIST-TYPE", +]; + +pub(crate) fn looks_like_hls(text: &str) -> bool { + let head: String = text.chars().take(4096).collect::<String>().to_uppercase(); + HLS_MARKERS.iter().any(|m| head.contains(m)) +} + +pub(crate) fn parse(text: &str, filename: &str) -> Result<ImportPlaylist, PlaylistImportError> { + if looks_like_hls(text) { + return Err(PlaylistImportError::HlsManifest); + } + + let mut name = file_stem(filename); + let mut tracks: Vec<ImportTrack> = Vec::new(); + + // Pending directive state, consumed by the next non-comment line. + let mut pending_title = String::new(); + let mut pending_artist = String::new(); + let mut pending_album: Option<String> = None; + let mut pending_secs: Option<u64> = None; + + for line in split_lines(text) { + if let Some(rest) = strip_tag(line, "#EXTINF:") { + // "<seconds>,<text>" — seconds may be -1 (unknown) or fractional. + let (secs, text) = match rest.split_once(',') { + Some((s, t)) => (s.trim(), t.trim()), + None => ("", rest.trim()), + }; + pending_secs = parse_secs(secs); + let (artist, title) = split_artist_title(text); + // ONLY overwrite when the split actually found an artist. A + // "#EXTART:Sigur Ros" followed by an #EXTINF with no " - " is a + // real shape, and assigning the empty half unconditionally threw + // the artist away — the row then went out title-only, which the + // matcher tops out at 0.6 for and drops. + if !artist.is_empty() { + pending_artist = artist; + } + pending_title = title; + continue; + } + if let Some(rest) = strip_tag(line, "#EXTALB:") { + pending_album = Some(rest.trim().to_string()).filter(|s| !s.is_empty()); + continue; + } + if let Some(rest) = strip_tag(line, "#EXTART:") { + // Only fills a gap — an "Artist - Title" #EXTINF already won. + if pending_artist.is_empty() { + pending_artist = rest.trim().to_string(); + } + continue; + } + if let Some(rest) = strip_tag(line, "#PLAYLIST:") { + let n = rest.trim(); + if !n.is_empty() { + name = n.to_string(); + } + continue; + } + if line.starts_with('#') { + // Any other directive, including a bare #EXTM3U. + continue; + } + + // A media line. Its OWN stem is the title fallback — a bare-path M3U + // carries nothing else. + let title = if pending_title.is_empty() { + location_stem(line) + } else { + std::mem::take(&mut pending_title) + }; + let artist = std::mem::take(&mut pending_artist); + let album = pending_album.take(); + let duration_ms = pending_secs.take().map(|s| s * 1000); + if let Some(t) = track_or_skip(title, artist, album, duration_ms, None) { + tracks.push(t); + } + pending_title.clear(); + } + + Ok(ImportPlaylist { + provider: ImportProvider::File, + provider_id: filename.to_string(), + name, + description: None, + tracks, + }) +} + +fn strip_tag<'a>(line: &'a str, tag: &str) -> Option<&'a str> { + if line.len() >= tag.len() && line[..tag.len()].eq_ignore_ascii_case(tag) { + Some(&line[tag.len()..]) + } else { + None + } +} + +/// `#EXTINF` seconds. `-1` means unknown, and a fractional value is legal. +fn parse_secs(raw: &str) -> Option<u64> { + let v: f64 = raw.trim().parse().ok()?; + if v <= 0.0 { + return None; + } + Some(v.round() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extinf_rows_carry_artist_title_and_duration() { + let body = "#EXTM3U\n\ + #EXTINF:245,Bob Dylan - Ballad of a Thin Man\n\ + /music/dylan/thin_man.flac\n\ + #EXTINF:-1,Unknown Length Song\n\ + /music/x.flac\n"; + let p = parse(body, "/lists/Road Trip.m3u").unwrap(); + assert_eq!(p.name, "Road Trip"); + assert_eq!(p.tracks.len(), 2); + assert_eq!(p.tracks[0].artist, "Bob Dylan"); + assert_eq!(p.tracks[0].title, "Ballad of a Thin Man"); + // SECONDS x 1000 — the M3U field is seconds, unlike XSPF's. + assert_eq!(p.tracks[0].duration_ms, Some(245_000)); + // -1 is "unknown", not a duration. + assert_eq!(p.tracks[1].duration_ms, None); + assert_eq!(p.tracks[1].artist, ""); + assert_eq!(p.tracks[1].title, "Unknown Length Song"); + } + + #[test] + fn bare_paths_fall_back_to_the_file_stem() { + let body = "/music/Sigur%20R%C3%B3s%20-%20Hoppipolla.flac\nC:\\m\\Track Two.mp3\n"; + let p = parse(body, "list.m3u").unwrap(); + assert_eq!(p.tracks.len(), 2); + // The stem is the whole title; the " - " is NOT split here, because + // a path stem is not the "Artist - Title" convention. + assert_eq!(p.tracks[0].title, "Sigur Rós - Hoppipolla"); + assert_eq!(p.tracks[1].title, "Track Two"); + } + + #[test] + fn extalb_extart_and_playlist_directives_are_read() { + let body = "#EXTM3U\n\ + #PLAYLIST:Summer 2026\n\ + #EXTALB:Agaetis Byrjun\n\ + #EXTART:Sigur Ros\n\ + #EXTINF:300,Svefn-g-englar\n\ + a.flac\n"; + let p = parse(body, "whatever.m3u").unwrap(); + assert_eq!(p.name, "Summer 2026"); + assert_eq!(p.tracks[0].album.as_deref(), Some("Agaetis Byrjun")); + // No " - " in the EXTINF text, so #EXTART fills the artist. + assert_eq!(p.tracks[0].artist, "Sigur Ros"); + assert_eq!(p.tracks[0].title, "Svefn-g-englar"); + } + + #[test] + fn an_extinf_artist_beats_extart() { + let body = "#EXTART:Wrong\n#EXTINF:100,Right - Song\na.flac\n"; + let p = parse(body, "x.m3u").unwrap(); + assert_eq!(p.tracks[0].artist, "Right"); + } + + #[test] + fn crlf_and_lone_cr_both_parse() { + let body = "#EXTM3U\r\n#EXTINF:10,A - B\r\na.flac\r#EXTINF:20,C - D\rb.flac\r"; + let p = parse(body, "x.m3u").unwrap(); + assert_eq!(p.tracks.len(), 2); + assert_eq!(p.tracks[1].title, "D"); + } + + #[test] + fn an_hls_manifest_is_refused_not_parsed() { + let body = "#EXTM3U\n\ + #EXT-X-VERSION:3\n\ + #EXT-X-TARGETDURATION:10\n\ + #EXTINF:9.9,\n\ + seg0.ts\n"; + assert!(matches!( + parse(body, "stream.m3u8"), + Err(PlaylistImportError::HlsManifest) + )); + } + + #[test] + fn a_master_playlist_is_refused_too() { + let body = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1280000\nhigh.m3u8\n"; + assert!(matches!( + parse(body, "master.m3u8"), + Err(PlaylistImportError::HlsManifest) + )); + } +} diff --git a/crates/qbz-playlist-import/src/sources/file/mod.rs b/crates/qbz-playlist-import/src/sources/file/mod.rs new file mode 100644 index 000000000..0af3105af --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/file/mod.rs @@ -0,0 +1,159 @@ +//! Playlist FILES — XSPF / PLS / M3U / M3U8. +//! +//! Filesystem-free by design (ADR-006): the app reads bytes through `rfd` and +//! passes them with the filename. The crate never opens a path, which is what +//! keeps it testable with `&[u8]` literals and correct inside a Flatpak, where +//! the portal — not the app — owns file access. +//! +//! # ONLY THE TRACK LIST IS READ +//! +//! The paths inside these files are never opened, never copied and never added +//! to the Local Library. Each entry becomes a title/artist/album/duration +//! probe and is matched against the Qobuz catalog like any other import. This +//! is a product promise, not an implementation detail — the modal says it in +//! so many words, and no code here may quietly grow a file read. + +pub(crate) mod decode; +mod m3u; +mod pls; +mod xspf; + +use super::{guard_size, FileFormat}; +use crate::errors::PlaylistImportError; +use crate::models::ImportPlaylist; + +/// Parse a playlist file. +/// +/// Empty results are an ERROR, not an empty playlist: a file that produced no +/// tracks means the user picked the wrong thing, or the format is subtly not +/// what it claimed, and "imported 0 tracks" is a worse answer than saying so. +pub fn parse( + format: FileFormat, + bytes: &[u8], + filename: &str, +) -> Result<ImportPlaylist, PlaylistImportError> { + guard_size(bytes)?; + let text = decode::decode_text(bytes); + let playlist = match format { + FileFormat::M3u => m3u::parse(&text, filename)?, + FileFormat::Pls => pls::parse(&text, filename)?, + FileFormat::Xspf => xspf::parse(&text, filename)?, + }; + if playlist.tracks.is_empty() { + return Err(PlaylistImportError::EmptyPlaylist); + } + Ok(playlist) +} + +/// Decide the format from the bytes, with the extension as a hint only. +/// +/// THE SNIFF WINS. Extensions are renamed, mailed, downloaded through services +/// that rewrite them and typed by hand; the first few bytes of an XML document +/// are not. The extension breaks ties the content cannot — specifically, a +/// bare-path M3U with no `#EXTM3U` header is indistinguishable from a text file +/// on content alone, so there the extension is the whole signal. +pub fn detect_format(bytes: &[u8], filename: &str) -> Result<FileFormat, PlaylistImportError> { + guard_size(bytes)?; + let text = decode::decode_text(bytes); + let head: String = text.chars().take(4096).collect(); + let trimmed = head.trim_start(); + let lower = trimmed.to_ascii_lowercase(); + + // XML first: unambiguous, and cheap to be sure about. + if trimmed.starts_with('<') && lower.contains("<playlist") { + return Ok(FileFormat::Xspf); + } + // PLS: the header, or (headerless, which is common) a FileN key. + if lower.starts_with("[playlist]") || has_pls_key(&lower) { + return Ok(FileFormat::Pls); + } + // Extended M3U. The HLS guard runs in the PARSER, not here: detection's + // job is "which parser", and refusing a stream manifest is a parse-time + // answer the caller can localize. + if lower.starts_with("#extm3u") { + return Ok(FileFormat::M3u); + } + // Basic M3U — content-free, so the extension is the only evidence. + let ext = filename + .rsplit_once('.') + .map(|(_, e)| e.to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "m3u" | "m3u8" => Ok(FileFormat::M3u), + "pls" => Ok(FileFormat::Pls), + "xspf" => Ok(FileFormat::Xspf), + _ => Err(PlaylistImportError::UnrecognizedFormat), + } +} + +/// A `FileN=` line anywhere in the head, for the headerless PLS case. +fn has_pls_key(lower_head: &str) -> bool { + lower_head.split(['\n', '\r']).any(|line| { + let line = line.trim_start(); + if !line.starts_with("file") { + return false; + } + let rest = &line[4..]; + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + !digits.is_empty() && rest[digits.len()..].trim_start().starts_with('=') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xml_is_sniffed_as_xspf_whatever_the_extension_says() { + let body = br#"<?xml version="1.0"?><playlist><trackList/></playlist>"#; + assert_eq!(detect_format(body, "mislabeled.m3u").unwrap(), FileFormat::Xspf); + } + + #[test] + fn a_pls_header_or_a_bare_file_key_is_enough() { + assert_eq!(detect_format(b"[playlist]\nFile1=a\n", "x.txt").unwrap(), FileFormat::Pls); + assert_eq!(detect_format(b"File1=a.flac\nTitle1=A\n", "x.txt").unwrap(), FileFormat::Pls); + } + + #[test] + fn extm3u_is_sniffed_and_a_bare_list_falls_back_to_the_extension() { + assert_eq!(detect_format(b"#EXTM3U\na.flac\n", "x.txt").unwrap(), FileFormat::M3u); + assert_eq!(detect_format(b"/music/a.flac\n", "list.m3u8").unwrap(), FileFormat::M3u); + } + + #[test] + fn an_unknown_extension_with_unknown_content_is_refused() { + assert!(matches!( + detect_format(b"just some prose\n", "notes.txt"), + Err(PlaylistImportError::UnrecognizedFormat) + )); + } + + #[test] + fn a_bom_does_not_hide_the_format() { + let mut body = vec![0xEF, 0xBB, 0xBF]; + body.extend_from_slice(b"[playlist]\nFile1=a\n"); + assert_eq!(detect_format(&body, "x.pls").unwrap(), FileFormat::Pls); + } + + #[test] + fn oversize_is_refused_before_anything_else() { + let big = vec![b'a'; super::super::MAX_IMPORT_BYTES + 1]; + assert!(matches!( + detect_format(&big, "x.m3u"), + Err(PlaylistImportError::FileTooLarge) + )); + assert!(matches!( + parse(FileFormat::M3u, &big, "x.m3u"), + Err(PlaylistImportError::FileTooLarge) + )); + } + + #[test] + fn a_file_that_yields_no_tracks_is_an_error_not_an_empty_playlist() { + assert!(matches!( + parse(FileFormat::M3u, b"#EXTM3U\n", "x.m3u"), + Err(PlaylistImportError::EmptyPlaylist) + )); + } +} diff --git a/crates/qbz-playlist-import/src/sources/file/pls.rs b/crates/qbz-playlist-import/src/sources/file/pls.rs new file mode 100644 index 000000000..ca8c458c9 --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/file/pls.rs @@ -0,0 +1,162 @@ +//! PLS — an INI whose keys carry the row index as a suffix. +//! +//! ```ini +//! [playlist] +//! File1=/music/a.flac +//! Title1=Bob Dylan - Ballad of a Thin Man +//! Length1=245 +//! NumberOfEntries=1 +//! ``` +//! +//! `NumberOfEntries` is DELIBERATELY IGNORED as an iteration bound: real files +//! disagree with their own header often enough (hand edits, truncated writes) +//! that trusting it drops rows that are right there. The rows discovered are +//! the rows imported. + +use std::collections::BTreeMap; + +use super::decode::{file_stem, location_stem, split_artist_title, split_lines, track_or_skip}; +use crate::errors::PlaylistImportError; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +#[derive(Default)] +struct Row { + file: Option<String>, + title: Option<String>, + length: Option<i64>, +} + +pub(crate) fn parse(text: &str, filename: &str) -> Result<ImportPlaylist, PlaylistImportError> { + // BTreeMap so the rows come out in index order regardless of file order — + // a hand-edited PLS can easily interleave them. + let mut rows: BTreeMap<u32, Row> = BTreeMap::new(); + + for line in split_lines(text) { + if line.starts_with('[') || line.starts_with(';') || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim(); + let Some((prefix, idx)) = split_indexed_key(key) else { + continue; + }; + let row = rows.entry(idx).or_default(); + // LAST WINS on a duplicate key — the INI convention, and the only + // reading under which a hand-appended correction takes effect. + match prefix.as_str() { + "file" => row.file = Some(value.to_string()), + "title" => row.title = Some(value.to_string()), + "length" => row.length = value.parse::<i64>().ok(), + _ => {} + } + } + + let mut tracks: Vec<ImportTrack> = Vec::new(); + for row in rows.into_values() { + let (artist, title) = match row.title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + Some(t) => split_artist_title(t), + // No TitleN: the FileN stem is all there is. + None => ( + String::new(), + row.file.as_deref().map(location_stem).unwrap_or_default(), + ), + }; + // `-1` (and 0) mean unknown. Seconds -> ms. + let duration_ms = row + .length + .filter(|l| *l > 0) + .map(|l| l as u64 * 1000); + if let Some(t) = track_or_skip(title, artist, None, duration_ms, None) { + tracks.push(t); + } + } + + Ok(ImportPlaylist { + provider: ImportProvider::File, + provider_id: filename.to_string(), + name: file_stem(filename), + description: None, + tracks, + }) +} + +/// `"Title12"` -> `("title", 12)`. Case-insensitive, because PLS writers +/// disagree about it (`FILE1`, `File1`, `file1` all occur). +fn split_indexed_key(key: &str) -> Option<(String, u32)> { + let digits_start = key.len() - key.chars().rev().take_while(|c| c.is_ascii_digit()).count(); + if digits_start == key.len() || digits_start == 0 { + return None; + } + let idx: u32 = key[digits_start..].parse().ok()?; + Some((key[..digits_start].trim().to_ascii_lowercase(), idx)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "[playlist]\n\ + File1=/music/dylan/thin_man.flac\n\ + Title1=Bob Dylan - Ballad of a Thin Man\n\ + Length1=245\n\ + File2=/music/x/Second%20Song.flac\n\ + Length2=-1\n\ + NumberOfEntries=2\n\ + Version=2\n"; + + #[test] + fn numbered_keys_become_rows_in_index_order() { + let p = parse(SAMPLE, "/lists/Party.pls").unwrap(); + assert_eq!(p.name, "Party"); + assert_eq!(p.tracks.len(), 2); + assert_eq!(p.tracks[0].artist, "Bob Dylan"); + assert_eq!(p.tracks[0].title, "Ballad of a Thin Man"); + assert_eq!(p.tracks[0].duration_ms, Some(245_000)); + // No TitleN -> the FileN stem, percent-decoded. + assert_eq!(p.tracks[1].title, "Second Song"); + assert_eq!(p.tracks[1].duration_ms, None); + } + + #[test] + fn rows_out_of_order_still_come_out_in_index_order() { + let body = "[playlist]\nTitle2=B - Two\nFile2=b.flac\nTitle1=A - One\nFile1=a.flac\n"; + let p = parse(body, "x.pls").unwrap(); + assert_eq!(p.tracks[0].title, "One"); + assert_eq!(p.tracks[1].title, "Two"); + } + + #[test] + fn keys_are_case_insensitive_and_duplicates_take_the_last() { + let body = "[PLAYLIST]\nFILE1=a.flac\ntitle1=First - Wrong\nTitle1=First - Right\n"; + let p = parse(body, "x.pls").unwrap(); + assert_eq!(p.tracks[0].title, "Right"); + } + + #[test] + fn a_missing_header_still_parses() { + // Half the PLS files in the wild have no [playlist] line. + let body = "File1=a.flac\nTitle1=A - B\n"; + let p = parse(body, "x.pls").unwrap(); + assert_eq!(p.tracks.len(), 1); + } + + #[test] + fn number_of_entries_does_not_bound_the_rows() { + // The header lies; the rows win. + let body = "[playlist]\nNumberOfEntries=1\nFile1=a.flac\nTitle1=A - 1\n\ + File2=b.flac\nTitle2=B - 2\nFile3=c.flac\nTitle3=C - 3\n"; + let p = parse(body, "x.pls").unwrap(); + assert_eq!(p.tracks.len(), 3); + } + + #[test] + fn a_row_with_neither_title_nor_file_is_dropped() { + let body = "[playlist]\nLength1=100\nFile2=b.flac\nTitle2=B - Two\n"; + let p = parse(body, "x.pls").unwrap(); + assert_eq!(p.tracks.len(), 1); + assert_eq!(p.tracks[0].title, "Two"); + } +} diff --git a/crates/qbz-playlist-import/src/sources/file/xspf.rs b/crates/qbz-playlist-import/src/sources/file/xspf.rs new file mode 100644 index 000000000..14f397997 --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/file/xspf.rs @@ -0,0 +1,212 @@ +//! XSPF — the XML playlist format. +//! +//! `roxmltree`, not `quick-xml`: XSPF files are tiny (the byte wall forecloses +//! the streaming case that would justify an event state machine), and a +//! read-only DOM walked by LOCAL element name is thirty lines instead of a +//! parser with modes. Both crates were already in the lock, so neither costs a +//! new download. +//! +//! Namespace-lenient by construction: every lookup is `tag_name().name()`, +//! which is the local part. Files written with `xmlns="http://xspf.org/ns/0/"` +//! and files written without it walk identically — and both exist in the wild. + +use super::decode::{is_isrc_shaped, location_stem, track_or_skip}; +use crate::errors::PlaylistImportError; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +pub(crate) fn parse(text: &str, filename: &str) -> Result<ImportPlaylist, PlaylistImportError> { + let doc = roxmltree::Document::parse(text) + .map_err(|e| PlaylistImportError::Parse(format!("XSPF: {e}")))?; + let root = doc.root_element(); + + let name = child_text(root, "title") + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| super::decode::file_stem(filename)); + let description = child_text(root, "annotation").filter(|s| !s.is_empty()); + + let mut tracks: Vec<ImportTrack> = Vec::new(); + // `<trackList>` may be nested under the root or (malformed but common) + // appear at any depth; descendants covers both without a special case. + for track in root + .descendants() + .filter(|n| n.is_element() && n.tag_name().name() == "track") + { + let location = child_text(track, "location").unwrap_or_default(); + let title = child_text(track, "title") + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| location_stem(&location)); + // `<creator>` is a CLEAN artist field — never run the "Artist - Title" + // splitter on it. XSPF is the one file format that carries the two + // separately, which is exactly why its rows match well. + let artist = child_text(track, "creator").unwrap_or_default(); + let album = child_text(track, "album"); + // *** <duration> IS ALREADY MILLISECONDS. No x1000. *** + // This is the single most common porting error in this format: M3U and + // PLS are seconds and XSPF is not, so a copied line multiplies a + // correct value by a thousand and every duration bonus turns into a + // duration penalty. + let duration_ms = child_text(track, "duration") + .and_then(|d| d.trim().parse::<u64>().ok()) + .filter(|d| *d > 0); + let isrc = find_isrc(track); + + if let Some(t) = track_or_skip(title, artist, album, duration_ms, isrc) { + tracks.push(t); + } + } + + Ok(ImportPlaylist { + provider: ImportProvider::File, + provider_id: filename.to_string(), + name, + description, + tracks, + }) +} + +fn child_text<'a>(node: roxmltree::Node<'a, 'a>, name: &str) -> Option<String> { + node.children() + .find(|c| c.is_element() && c.tag_name().name() == name) + .and_then(|c| c.text()) + .map(|t| t.trim().to_string()) +} + +/// Sweep the track's subtree for an ISRC. +/// +/// XSPF has no ISRC element; taggers put it inside `<extension>` under wildly +/// different vendor element names, so the only portable rule is "any text node +/// under this track that IS an ISRC". Shape-checked, never guessed. +/// +/// `<identifier>` is EXCLUDED ON PURPOSE: it is where MusicBrainz recording ids +/// live, and a MBID landing in `isrc` would fire the matcher's score-1.0 +/// short-circuit against a completely unrelated Qobuz track. The shape check +/// already rejects a UUID, and the exclusion means we never even ask. +fn find_isrc(track: roxmltree::Node) -> Option<String> { + for n in track.descendants() { + if !n.is_element() { + continue; + } + if n.tag_name().name() == "identifier" { + continue; + } + // An `isrc`-named element wins outright when its text is shaped right. + let text = n.text().map(str::trim).unwrap_or(""); + if text.is_empty() { + continue; + } + let cleaned: String = text + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_uppercase()) + .collect(); + if is_isrc_shaped(&cleaned) { + return Some(cleaned); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + const NAMESPACED: &str = r#"<?xml version="1.0" encoding="UTF-8"?> +<playlist version="1" xmlns="http://xspf.org/ns/0/"> + <title>Road Trip + Songs for the drive + + + file:///music/Sigur%20R%C3%B3s.flac + Hoppipolla + Sigur Ros + Takk... + 268000 + + +"#; + + #[test] + fn namespaced_documents_walk_by_local_name() { + let p = parse(NAMESPACED, "ignored.xspf").unwrap(); + assert_eq!(p.name, "Road Trip"); + assert_eq!(p.description.as_deref(), Some("Songs for the drive")); + assert_eq!(p.tracks.len(), 1); + assert_eq!(p.tracks[0].title, "Hoppipolla"); + // is a clean artist — NOT run through the dash splitter. + assert_eq!(p.tracks[0].artist, "Sigur Ros"); + assert_eq!(p.tracks[0].album.as_deref(), Some("Takk...")); + } + + #[test] + fn duration_is_milliseconds_and_is_not_multiplied() { + let p = parse(NAMESPACED, "x.xspf").unwrap(); + // 268000 ms = 4:28. If this ever reads 268_000_000 the x1000 crept in. + assert_eq!(p.tracks[0].duration_ms, Some(268_000)); + } + + #[test] + fn a_document_without_the_namespace_parses_identically() { + let body = r#" + SongBand1000 + "#; + let p = parse(body, "no-ns.xspf").unwrap(); + assert_eq!(p.tracks[0].title, "Song"); + assert_eq!(p.tracks[0].duration_ms, Some(1000)); + } + + #[test] + fn a_missing_title_falls_back_to_the_location_stem() { + let body = r#" + file:///m/Track%20Nine.flac + "#; + let p = parse(body, "x.xspf").unwrap(); + assert_eq!(p.tracks[0].title, "Track Nine"); + } + + #[test] + fn a_missing_playlist_title_falls_back_to_the_filename() { + let body = r#"A"#; + let p = parse(body, "/lists/My Mix.xspf").unwrap(); + assert_eq!(p.name, "My Mix"); + } + + #[test] + fn an_isrc_in_an_extension_is_found_and_normalized() { + // A vendor extension with its namespace properly DECLARED — an + // undeclared prefix is invalid XML and roxmltree is right to refuse it. + let body = r#" + A + us-ko1-16-00123 + "#; + let p = parse(body, "x.xspf").unwrap(); + assert_eq!(p.tracks[0].isrc.as_deref(), Some("USKO11600123")); + } + + #[test] + fn a_plain_isrc_element_in_an_extension_is_found_too() { + let body = r#" + A + USKO11600123 + "#; + let p = parse(body, "x.xspf").unwrap(); + assert_eq!(p.tracks[0].isrc.as_deref(), Some("USKO11600123")); + } + + #[test] + fn an_identifier_mbid_never_becomes_an_isrc() { + let body = r#" + A + https://musicbrainz.org/recording/b1a9c0e9-1b47-4f3d-9a1e-000000000000 + "#; + let p = parse(body, "x.xspf").unwrap(); + assert_eq!(p.tracks[0].isrc, None); + } + + #[test] + fn malformed_xml_is_a_parse_error_not_a_panic() { + assert!(matches!( + parse("", "x.xspf"), + Err(PlaylistImportError::Parse(_)) + )); + } +} diff --git a/crates/qbz-playlist-import/src/sources/json.rs b/crates/qbz-playlist-import/src/sources/json.rs new file mode 100644 index 000000000..922137fee --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/json.rs @@ -0,0 +1,638 @@ +//! Best-effort JSON. +//! +//! "Somebody exported their library as JSON and wants their playlist." There is +//! no schema to target, so this locates the track list BY SHAPE and harvests +//! fields by synonym, under hard caps that make a pathological file impossible +//! to weaponise. +//! +//! # Why shape and not depth +//! +//! The obvious rule — "the first array within two levels of the root" — is +//! wrong on real exports, and it is wrong in the direction that loses data. +//! The track array's depth is simply not stable: a flat `[...]` is 0, +//! `{tracks:[...]}` is 1, `{playlist:{tracks:[...]}}` is 2 and +//! `{data:{playlist:{items:[...]}}}` — an ordinary GraphQL envelope — is 3. A +//! hard two-level cap silently drops the last one. "First array" also has no +//! answer for a sibling `artists:[...]` or `images:[...]` that happens to come +//! earlier in document order. +//! +//! So depth is demoted from LOCATOR to DoS BOUND, and the locator becomes "the +//! largest array of track-shaped objects anywhere inside the guard". +//! +//! # The one false positive worth naming +//! +//! An array of ALBUM objects usually carries both a title and an artist, which +//! is exactly the both-fields test that admits a track. A discography dump +//! would import album names as track titles. Two things stop it: +//! +//! 1. **Container demotion.** An element that itself contains a nested +//! track-shaped array is a CONTAINER (an album with its tracks, a section +//! with its items), and an array of containers is disqualified. The real +//! track list, nested one level down, wins instead. +//! 2. **The preview count.** The user sees "Found N tracks" before Import. On a +//! discography that number is the album count, which is visibly wrong. +//! +//! With both, the shape locator is a superset of the depth rule on well-formed +//! exports. It is not unconditionally more precise, and this comment is the +//! place that says so. + +use serde_json::Value; + +use super::guard_size; +use crate::errors::PlaylistImportError; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +/// Depth guard for the structural walk. A BOUND, not a locator — nothing about +/// correctness depends on it, only the walk's cost. +const MAX_DEPTH: usize = 8; +/// Node-visit budget. Bounds adversarial WIDTH the way the byte wall bounds +/// size: a 16 MiB file of two-byte array elements is still millions of nodes. +/// On exhaustion the walk stops descending and keeps the best candidate found +/// so far — graceful degradation, never an error. +const MAX_NODES: usize = 2_000_000; +/// Output cap. The pipeline already splits at 2000 per playlist, so this is at +/// most five parts. +const MAX_TRACKS: usize = 10_000; +/// How far inside one element a field may hide. TWO object-descents, which is +/// exactly `item.track.album.name` — the deepest real field in any streaming +/// export. This one IS a locator and it is tight on purpose. +const ELEM_FIELD_DEPTH: usize = 2; + +const TITLE_KEYS: &[&str] = &["title", "name", "track", "song", "trackname", "songname"]; +const ARTIST_KEYS: &[&str] = &[ + "artist", + "artists", + "creator", + "performer", + "performers", + "albumartist", + "artistname", + "by", +]; +const ALBUM_KEYS: &[&str] = &["album", "release", "albumname", "albumtitle", "collection"]; +const DURATION_KEYS: &[&str] = &[ + "duration", + "durationms", + "length", + "time", + "runtime", + "durationseconds", +]; +/// Object-valued keys that WRAP the real track. `track` and `song` are also +/// title synonyms — the collision is resolved by VALUE TYPE, never by name: a +/// scalar `track:"Song"` is a title, an object `track:{...}` is a wrapper. +const WRAPPER_KEYS: &[&str] = &["track", "item", "node", "song", "metadata"]; +const PLAYLIST_NAME_KEYS: &[&str] = &["name", "title", "playlistname"]; + +/// Lowercase and drop `_`/`-`/spaces, so `duration_ms`, `durationMs` and +/// `Duration MS` are one key. +fn norm_key(k: &str) -> String { + k.chars() + .filter(|c| *c != '_' && *c != '-' && *c != ' ') + .flat_map(|c| c.to_lowercase()) + .collect() +} + +pub fn parse(bytes: &[u8], filename: &str) -> Result { + guard_size(bytes)?; + // serde_json's own 128-deep recursion limit pre-empts a stack attack during + // the parse itself, before our walk guard ever runs. + let root: Value = serde_json::from_slice(bytes) + .map_err(|e| PlaylistImportError::Parse(format!("JSON: {e}")))?; + + let mut budget = MAX_NODES; + let mut best: Option = None; + locate(&root, 0, &mut budget, &mut best, None); + + let Some(best) = best else { + return Err(PlaylistImportError::JsonShapeUnrecognized); + }; + + let arr = best + .array + .as_array() + .expect("candidate is always an array"); + let mut tracks: Vec = Vec::with_capacity(arr.len().min(MAX_TRACKS)); + for elem in arr.iter() { + if tracks.len() >= MAX_TRACKS { + log::warn!( + "[qbz-playlist-import] JSON: truncated at {MAX_TRACKS} tracks ({} in the file)", + arr.len() + ); + break; + } + if let Some(t) = extract_track(elem) { + tracks.push(t); + } + } + + if tracks.is_empty() { + return Err(PlaylistImportError::EmptyPlaylist); + } + + // The playlist name comes from the ROOT object or the array's PARENT — + // never from inside the array, so a track title can never become the + // playlist name. + let name = best + .parent_name + .or_else(|| scalar_name(&root)) + .unwrap_or_else(|| super::file::decode::file_stem(filename)); + + Ok(ImportPlaylist { + provider: ImportProvider::Json, + provider_id: filename.to_string(), + name, + description: None, + tracks, + }) +} + +// --------------------------------------------------------------------------- +// (a) Structural pass — locate by shape +// --------------------------------------------------------------------------- + +struct Candidate<'a> { + array: &'a Value, + shaped: usize, + depth: usize, + parent_name: Option, +} + +/// Depth-first walk. At every array, score it; keep the best. +/// +/// "Best" is the greatest count of track-shaped elements; ties break to the +/// SHALLOWER array, then to document order (the first one seen wins, because a +/// later equal candidate never replaces it below). +fn locate<'a>( + node: &'a Value, + depth: usize, + budget: &mut usize, + best: &mut Option>, + parent_name: Option<&str>, +) { + if depth > MAX_DEPTH || *budget == 0 { + return; + } + *budget -= 1; + + match node { + Value::Array(items) => { + let shaped = items.iter().filter(|e| is_track_shaped(e)).count(); + // A MAJORITY must be track-shaped: one stray object inside an + // `images` array cannot promote it. + let qualifies = shaped >= 1 && shaped * 2 >= items.len(); + // Container demotion — see the module header. An array whose + // elements each hold their own track-shaped array is a list of + // albums/sections, not of tracks. + let container_like = items.iter().take(32).any(has_nested_track_array); + if qualifies && !container_like { + let better = match best { + None => true, + Some(b) => shaped > b.shaped || (shaped == b.shaped && depth < b.depth), + }; + if better { + *best = Some(Candidate { + array: node, + shaped, + depth, + parent_name: parent_name.map(str::to_string), + }); + } + } + // Descend regardless: the real list may be INSIDE a container + // array, and that is precisely the case demotion exists to reach. + for item in items { + locate(item, depth + 1, budget, best, parent_name); + if *budget == 0 { + return; + } + } + } + Value::Object(map) => { + // This object's own name/title labels any array it holds. + let here = scalar_name(node); + for (_k, v) in map.iter() { + locate( + v, + depth + 1, + budget, + best, + here.as_deref().or(parent_name), + ); + if *budget == 0 { + return; + } + } + } + _ => {} + } +} + +/// Does this element hold a nested array that is itself track-shaped? +/// Checked ONE level down only — that is where an album keeps its `tracks`. +fn has_nested_track_array(elem: &Value) -> bool { + let Value::Object(map) = elem else { + return false; + }; + map.values().any(|v| match v { + Value::Array(inner) => { + let shaped = inner.iter().take(16).filter(|e| is_track_shaped(e)).count(); + shaped >= 1 && shaped * 2 >= inner.len().min(16) + } + _ => false, + }) +} + +/// THE SAME BOUNDED PROBE EXTRACTION USES. Detection and extraction must not +/// drift: an element that passes here and yields nothing there would inflate +/// the candidate score against a real list. +fn is_track_shaped(elem: &Value) -> bool { + let target = unwrap_element(elem); + let Value::Object(_) = target else { + return false; + }; + harvest_str(target, TITLE_KEYS, ELEM_FIELD_DEPTH).is_some() + && harvest_artist(target, ELEM_FIELD_DEPTH).is_some() +} + +// --------------------------------------------------------------------------- +// (b) Field extraction — synonym harvest +// --------------------------------------------------------------------------- + +/// Unwrap ONE object-valued wrapper key (`{track:{...}}` -> the inner object). +/// A SCALAR under the same key is a title, not a wrapper, so it never unwraps. +fn unwrap_element(elem: &Value) -> &Value { + let Value::Object(map) = elem else { + return elem; + }; + for (k, v) in map.iter() { + if v.is_object() && WRAPPER_KEYS.contains(&norm_key(k).as_str()) { + return v; + } + } + elem +} + +fn extract_track(elem: &Value) -> Option { + let t = unwrap_element(elem); + let title = harvest_str(t, TITLE_KEYS, ELEM_FIELD_DEPTH)?; + let title = title.trim().to_string(); + // KEEP RULE: title required, artist optional. + // + // The matcher's arithmetic is why. A title-less row maxes at artist 0.3 + + // album 0.1 = 0.4, below the 0.65 threshold — mathematically unmatchable, + // so dropping it loses nothing and keeps the preview count honest. A + // title-ONLY row maxes at 0.6 and clears only on an exact normalized hit + // plus the duration bonus; it is kept, and if it misses it surfaces in + // `skipped_tracks` where the user can see it. + if title.is_empty() { + return None; + } + Some(ImportTrack { + title, + artist: harvest_artist(t, ELEM_FIELD_DEPTH).unwrap_or_default(), + album: harvest_str(t, ALBUM_KEYS, ELEM_FIELD_DEPTH).filter(|s| !s.trim().is_empty()), + duration_ms: harvest_duration(t, ELEM_FIELD_DEPTH), + isrc: harvest_str(t, &["isrc"], ELEM_FIELD_DEPTH) + .and_then(|s| super::file::decode::normalize_isrc(&s)), + provider_id: None, + provider_url: None, + }) +} + +/// First non-empty scalar under any of `keys`, within `depth` object descents. +/// Direct fields win over nested ones (breadth before depth). +fn harvest_str(node: &Value, keys: &[&str], depth: usize) -> Option { + let Value::Object(map) = node else { + return None; + }; + for (k, v) in map.iter() { + if !keys.contains(&norm_key(k).as_str()) { + continue; + } + if let Some(s) = as_non_empty_str(v) { + return Some(s); + } + // The synonym matched but its value is an OBJECT — `album:{name:"Al"}`, + // which is the Spotify/Apple shape and by far the commonest way an + // album arrives. Read the name out of it rather than walking past a + // key that already told us it is the right one. + if v.is_object() { + if let Some(s) = harvest_str(v, &["name", "title"], 1) { + return Some(s); + } + } + } + if depth == 0 { + return None; + } + for (_k, v) in map.iter() { + if v.is_object() { + if let Some(s) = harvest_str(v, keys, depth - 1) { + return Some(s); + } + } + } + None +} + +fn as_non_empty_str(v: &Value) -> Option { + match v { + Value::String(s) if !s.trim().is_empty() => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// Artists come in four shapes: `"A"`, `["A","B"]`, `[{name:"A"}]`, `{name:"A"}`. +fn harvest_artist(node: &Value, depth: usize) -> Option { + let Value::Object(map) = node else { + return None; + }; + for (k, v) in map.iter() { + if !ARTIST_KEYS.contains(&norm_key(k).as_str()) { + continue; + } + if let Some(s) = artist_value(v) { + return Some(s); + } + } + if depth == 0 { + return None; + } + for (_k, v) in map.iter() { + if v.is_object() { + if let Some(s) = harvest_artist(v, depth - 1) { + return Some(s); + } + } + } + None +} + +fn artist_value(v: &Value) -> Option { + match v { + Value::String(s) if !s.trim().is_empty() => Some(s.clone()), + Value::Object(_) => harvest_str(v, &["name", "artistname", "title"], 1), + Value::Array(items) => { + let names: Vec = items + .iter() + .filter_map(|i| match i { + Value::String(s) if !s.trim().is_empty() => Some(s.clone()), + Value::Object(_) => harvest_str(i, &["name", "artistname", "title"], 1), + _ => None, + }) + .collect(); + if names.is_empty() { + None + } else { + Some(names.join(", ")) + } + } + _ => None, + } +} + +/// Duration in ms. +/// +/// TRUST THE KEY NAME FIRST. `duration_ms` is milliseconds and `runtime` is +/// seconds, and both are unambiguous. Only a genuinely unit-less key +/// (`duration`, `time`) falls back to the magnitude rule. +/// +/// The magnitude rule's known blind spots, stated rather than hidden: a track +/// longer than 10 000 s expressed in seconds (a DJ mix, an audiobook chapter) +/// reads as ~10 s of ms, and a sub-10 s value in ms reads as seconds. The +/// damage is bounded — duration is a ±0.05 bonus in the matcher and never a +/// gate — but a wrongly-"close" value can hand out a spurious bonus, which is +/// why the key name wins whenever there is one. +fn harvest_duration(node: &Value, depth: usize) -> Option { + let Value::Object(map) = node else { + return None; + }; + for (k, v) in map.iter() { + let nk = norm_key(k); + if !DURATION_KEYS.contains(&nk.as_str()) { + continue; + } + let Some(n) = as_f64(v) else { continue }; + if n <= 0.0 { + continue; + } + let ms = match nk.as_str() { + "durationms" => n, + "length" | "runtime" | "durationseconds" => n * 1000.0, + // Ambiguous: `duration`, `time`. + _ => { + if n >= 10_000.0 { + n + } else { + n * 1000.0 + } + } + }; + return Some(ms.round() as u64); + } + if depth == 0 { + return None; + } + for (_k, v) in map.iter() { + if v.is_object() { + if let Some(d) = harvest_duration(v, depth - 1) { + return Some(d); + } + } + } + None +} + +fn as_f64(v: &Value) -> Option { + match v { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.trim().parse::().ok(), + _ => None, + } +} + +/// A `name`/`title`/`playlistName` scalar directly on this object. +fn scalar_name(node: &Value) -> Option { + let Value::Object(map) = node else { + return None; + }; + for (k, v) in map.iter() { + if PLAYLIST_NAME_KEYS.contains(&norm_key(k).as_str()) { + if let Value::String(s) = v { + let s = s.trim(); + if !s.is_empty() { + return Some(s.to_string()); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(json: &str) -> Result { + parse(json.as_bytes(), "export.json") + } + + #[test] + fn a_flat_array_of_tracks() { + let doc = r#"[{"title":"A","artist":"X"},{"title":"B","artist":"Y"}]"#; + let r = p(doc).unwrap(); + assert_eq!(r.tracks.len(), 2); + assert_eq!(r.tracks[0].title, "A"); + assert_eq!(r.name, "export"); // the filename stem + } + + #[test] + fn a_named_wrapper_object() { + let doc = r#"{"name":"My Mix","tracks":[{"name":"A","artist":"X"}]}"#; + let r = p(doc).unwrap(); + assert_eq!(r.name, "My Mix"); + assert_eq!(r.tracks.len(), 1); + } + + #[test] + fn the_spotify_shape_unwraps_its_track_key() { + let doc = r#"{"name":"Liked","tracks":{"items":[ + {"added_at":"2026","track":{"name":"A","artists":[{"name":"X"},{"name":"Y"}], + "album":{"name":"Al"},"duration_ms":211000}} + ]}}"#; + let r = p(doc).unwrap(); + assert_eq!(r.tracks.len(), 1); + assert_eq!(r.tracks[0].title, "A"); + assert_eq!(r.tracks[0].artist, "X, Y"); + assert_eq!(r.tracks[0].album.as_deref(), Some("Al")); + assert_eq!(r.tracks[0].duration_ms, Some(211_000)); + } + + #[test] + fn a_deep_graphql_envelope_is_found_by_shape() { + // Depth 3 from the root — the case a hard two-level cap would drop. + let doc = r#"{"data":{"playlist":{"title":"Deep","items":[ + {"title":"A","artist":"X"},{"title":"B","artist":"Y"}]}}}"#; + let r = p(doc).unwrap(); + assert_eq!(r.tracks.len(), 2); + assert_eq!(r.name, "Deep"); + } + + #[test] + fn a_sibling_artists_array_does_not_win() { + // `artists` has a title-like `name` but no artist-like key -> rejected. + let doc = r#"{"artists":[{"name":"X"},{"name":"Y"},{"name":"Z"}], + "tracks":[{"title":"A","artist":"X"}]}"#; + let r = p(doc).unwrap(); + assert_eq!(r.tracks.len(), 1); + assert_eq!(r.tracks[0].title, "A"); + } + + #[test] + fn an_album_array_is_demoted_and_the_real_tracks_win() { + // The discography shape: each element has BOTH a title and an artist, + // which is exactly what would fool a naive both-fields test. + let doc = r#"{"albums":[ + {"title":"Album One","artist":"X","tracks":[ + {"title":"T1","artist":"X"},{"title":"T2","artist":"X"}]}, + {"title":"Album Two","artist":"X","tracks":[ + {"title":"T3","artist":"X"}]} + ]}"#; + let r = p(doc).unwrap(); + let titles: Vec<&str> = r.tracks.iter().map(|t| t.title.as_str()).collect(); + // The album NAMES must not be the import. + assert!(!titles.contains(&"Album One")); + assert!(titles.contains(&"T1")); + } + + #[test] + fn a_scalar_track_key_is_a_title_and_an_object_one_is_a_wrapper() { + let scalar = r#"[{"track":"Song Name","artist":"X"}]"#; + assert_eq!(p(scalar).unwrap().tracks[0].title, "Song Name"); + let object = r#"[{"track":{"title":"Inner","artist":"X"}}]"#; + assert_eq!(p(object).unwrap().tracks[0].title, "Inner"); + } + + #[test] + fn duration_trusts_the_key_name_before_the_magnitude() { + // Explicit ms. + let ms = r#"[{"title":"A","artist":"X","duration_ms":180000}]"#; + assert_eq!(p(ms).unwrap().tracks[0].duration_ms, Some(180_000)); + // Explicitly seconds, even at a magnitude the fallback would call ms. + let secs = r#"[{"title":"A","artist":"X","runtime":12000}]"#; + assert_eq!(p(secs).unwrap().tracks[0].duration_ms, Some(12_000_000)); + // Ambiguous + small -> seconds (the Deezer shape). + let amb_s = r#"[{"title":"A","artist":"X","duration":200}]"#; + assert_eq!(p(amb_s).unwrap().tracks[0].duration_ms, Some(200_000)); + // Ambiguous + large -> already ms. + let amb_ms = r#"[{"title":"A","artist":"X","duration":200000}]"#; + assert_eq!(p(amb_ms).unwrap().tracks[0].duration_ms, Some(200_000)); + // Numeric strings parse. + let str_n = r#"[{"title":"A","artist":"X","duration":"200"}]"#; + assert_eq!(p(str_n).unwrap().tracks[0].duration_ms, Some(200_000)); + } + + #[test] + fn isrc_is_normalized_to_the_bare_form() { + let doc = r#"[{"title":"A","artist":"X","isrc":"US-KO1-16-00123"}]"#; + assert_eq!(p(doc).unwrap().tracks[0].isrc.as_deref(), Some("USKO11600123")); + // Junk in an isrc field is dropped, not passed through — it would + // otherwise misfire the matcher's score-1.0 short circuit. + let junk = r#"[{"title":"A","artist":"X","isrc":"not-an-isrc"}]"#; + assert_eq!(p(junk).unwrap().tracks[0].isrc, None); + } + + #[test] + fn a_title_less_row_is_dropped_and_a_title_only_row_is_kept() { + // Both rows must be present for the array to qualify, so give the + // second one an artist and no title. + let doc = r#"{"tracks":[{"title":"Kept","artist":"X"},{"artist":"Y"}]}"#; + let r = p(doc).unwrap(); + assert_eq!(r.tracks.len(), 1); + assert_eq!(r.tracks[0].title, "Kept"); + } + + #[test] + fn no_track_shaped_array_fails_loud() { + let doc = r#"{"settings":{"volume":80},"paths":["/a","/b"]}"#; + assert!(matches!(p(doc), Err(PlaylistImportError::JsonShapeUnrecognized))); + } + + #[test] + fn invalid_json_is_a_parse_error() { + assert!(matches!(p("{nope"), Err(PlaylistImportError::Parse(_)))); + } + + #[test] + fn oversize_is_refused_before_the_parse() { + let big = vec![b'a'; super::super::MAX_IMPORT_BYTES + 1]; + assert!(matches!( + parse(&big, "x.json"), + Err(PlaylistImportError::FileTooLarge) + )); + } + + #[test] + fn the_output_is_capped() { + let mut items = String::from("["); + for i in 0..(MAX_TRACKS + 50) { + if i > 0 { + items.push(','); + } + items.push_str(&format!(r#"{{"title":"T{i}","artist":"X"}}"#)); + } + items.push(']'); + let r = p(&items).unwrap(); + assert_eq!(r.tracks.len(), MAX_TRACKS); + } + + #[test] + fn the_playlist_name_never_comes_from_inside_the_array() { + // Every element has a `name`; the playlist name must not be "A". + let doc = r#"[{"name":"A","artist":"X"},{"name":"B","artist":"Y"}]"#; + let r = p(doc).unwrap(); + assert_eq!(r.name, "export"); + } +} diff --git a/crates/qbz-playlist-import/src/sources/mod.rs b/crates/qbz-playlist-import/src/sources/mod.rs new file mode 100644 index 000000000..5ee620aea --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/mod.rs @@ -0,0 +1,180 @@ +//! Where a playlist can come FROM. +//! +//! Design: `qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md`. +//! +//! # An enum, not a trait (ADR-007) +//! +//! The set of source classes is CLOSED and known at compile time. There is no +//! plugin story, no registry, nothing loaded at runtime — so a trait plus +//! dynamic dispatch would buy an extension point nobody can use and cost an +//! indirection plus a `Box` on every path. Adding a source here is a new +//! variant and one arm of [`PlaylistSource::resolve`], and the compiler names +//! every place that has to grow. +//! +//! # Everything lands on ONE type +//! +//! Every arm returns [`ImportPlaylist`], which is exactly what the existing +//! URL scrapers already produce. Downstream — the Qobuz matcher, the 2000-track +//! split, the create-and-add loop, the progress sink, the summary — never +//! learns that a second kind of source exists. That is the whole reason the +//! expansion is additive: `importer::import_prepared_playlist` takes the +//! resolved playlist and does not care who resolved it. +//! +//! # The byte wall is here, not per-parser +//! +//! [`MAX_IMPORT_BYTES`] is checked before any parse. The APP checks it too, +//! before it reads the file at all (a 2 GB pick must never reach RAM); this is +//! defense in depth for every other caller, and it is O(1) on a length. + +pub mod file; +pub mod json; +pub mod service; + +use crate::errors::PlaylistImportError; +use crate::models::ImportPlaylist; + +/// Refuse anything larger, before allocating or parsing. +/// +/// 16 MiB. The largest realistic export — 10 000 tracks at ~1.5 KB of verbose +/// metadata — is about 15 MB; a 500-track export is under 1 MB. So this is +/// comfortably above legitimate use and roughly three orders of magnitude below +/// anything that could pressure the box. It is ONE constant: dropping it to +/// 8 MiB costs no code. +pub const MAX_IMPORT_BYTES: usize = 16 * 1024 * 1024; + +/// Refuse a body over the wall. Shared by every byte-taking source. +pub(crate) fn guard_size(bytes: &[u8]) -> Result<(), PlaylistImportError> { + if bytes.len() > MAX_IMPORT_BYTES { + return Err(PlaylistImportError::FileTooLarge); + } + Ok(()) +} + +/// The playlist-file formats this build reads. `M3u8` is not a variant: an +/// `.m3u8` is an M3U that promises to be UTF-8, and the decoder already tries +/// UTF-8 first — a second variant would fork the parser for an encoding hint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileFormat { + M3u, + Pls, + Xspf, +} + +/// The three Last.fm player stations reachable without authentication. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LastFmStation { + Library, + Mix, + Recommended, +} + +impl LastFmStation { + /// The path segment under `/player/station/user//`. + pub fn slug(self) -> &'static str { + match self { + LastFmStation::Library => "library", + LastFmStation::Mix => "mix", + LastFmStation::Recommended => "recommended", + } + } + + /// Index order of the station picker (0 Library / 1 Mix / 2 Recommended). + pub fn from_index(i: i32) -> LastFmStation { + match i { + 1 => LastFmStation::Mix, + 2 => LastFmStation::Recommended, + _ => LastFmStation::Library, + } + } +} + +/// One resolvable playlist source. +/// +/// `File` and `Json` carry BYTES, not a path, and that is deliberate: the crate +/// stays filesystem-free (ADR-006 — it runs headless, in tests, and on a +/// sandboxed Flatpak where the app owns the portal). The app reads through +/// `rfd` and hands the bytes over with the filename, which is used for the +/// playlist name and as a format hint. +#[derive(Debug, Clone)] +pub enum PlaylistSource { + /// A streaming-provider URL — the pre-expansion path, untouched. + Url(String), + File { + format: FileFormat, + bytes: Vec, + filename: String, + }, + Json { + bytes: Vec, + filename: String, + }, + /// A ListenBrainz playlist by MBID. The token is optional — public reads + /// work without one; with one the user's own private playlists resolve and + /// the rate limit is higher. + ListenBrainz { + mbid: String, + token: Option, + }, + LastFmStation { + user: String, + station: LastFmStation, + }, + LastFmPlaylist { + user: String, + playlist_id: String, + }, +} + +impl PlaylistSource { + pub async fn resolve(&self) -> Result { + match self { + // The File / Json arms are pure synchronous work in an async fn. + // They are cheap (a bounded parse over at most 16 MiB) and the + // caller is already on a tokio task, so there is nothing to move + // to a blocking pool. + PlaylistSource::Url(u) => crate::importer::preview_public_playlist(u).await, + PlaylistSource::File { + format, + bytes, + filename, + } => file::parse(*format, bytes, filename), + PlaylistSource::Json { bytes, filename } => json::parse(bytes, filename), + PlaylistSource::ListenBrainz { mbid, token } => { + service::listenbrainz::fetch(mbid, token.as_deref()).await + } + PlaylistSource::LastFmStation { user, station } => { + service::lastfm::fetch_station(user, *station).await + } + PlaylistSource::LastFmPlaylist { user, playlist_id } => { + service::lastfm::fetch_playlist(user, playlist_id).await + } + } + } + + /// A short label for the log line, before the playlist is resolved. + pub fn label(&self) -> String { + match self { + PlaylistSource::Url(u) => u.trim().to_string(), + PlaylistSource::File { filename, .. } | PlaylistSource::Json { filename, .. } => { + filename.clone() + } + PlaylistSource::ListenBrainz { mbid, .. } => mbid.clone(), + PlaylistSource::LastFmStation { user, station } => { + format!("{user}/{}", station.slug()) + } + PlaylistSource::LastFmPlaylist { user, playlist_id } => { + format!("{user}/playlists/{playlist_id}") + } + } + } + + /// Whether resolving this source needs the network. File and JSON do not, + /// which is what lets a preview succeed offline (the IMPORT half still + /// needs a session, and is gated separately). + pub fn needs_network(&self) -> bool { + !matches!( + self, + PlaylistSource::File { .. } | PlaylistSource::Json { .. } + ) + } +} diff --git a/crates/qbz-playlist-import/src/sources/service/lastfm.rs b/crates/qbz-playlist-import/src/sources/service/lastfm.rs new file mode 100644 index 000000000..62be9ed73 --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/service/lastfm.rs @@ -0,0 +1,491 @@ +//! Last.fm — the three player stations, and a user playlist page. +//! +//! SCRAPER CLASS, and treated as such: undocumented endpoints and, for one of +//! the two paths, page HTML. Every parse is defensive — a shape change yields +//! fewer rows or an empty list, never a panic and never a hard failure that +//! blames the user. Same discipline as `providers/apple.rs`. +//! +//! No authentication and no Last.fm API key. Both paths are public reads, which +//! is why this module does NOT reuse `qbz-integrations`'s scrobble client: that +//! one exists to WRITE with the user's credentials, and importing a public +//! playlist must not need them. +//! +//! | input | what it means | +//! |---|---| +//! | `last.fm/user//playlists/` | that playlist, imported directly | +//! | `last.fm/user/` or a bare `` | pick one of three stations | +//! +//! The stations are JSON (`/player/station/user//?ajax=1`); the +//! specific playlist is NOT — the ajax path 404s for a playlist id, so its +//! tracklist comes out of the page's `chartlist` table. + +use serde_json::Value; + +use super::super::LastFmStation; +use super::BROWSER_USER_AGENT; +use crate::errors::PlaylistImportError; +use crate::http::http; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +const BASE: &str = "https://www.last.fm"; +/// Hard request cap for a station. It is a BACKSTOP, not the normal exit — see +/// `fetch_station`, which stops as soon as the pool stops yielding new tracks. +const MAX_PAGES: usize = 20; +/// Consecutive all-duplicate draws that mean the pool is exhausted. Two, not +/// one: a radio can repeat a whole batch by chance. +const MAX_DRY_DRAWS: usize = 2; +/// HOW MANY TRACKS A STATION IS ALLOWED TO PROMISE. +/// +/// This is the number the user sees in "Found N tracks" before they press +/// Import, so it has to be a number we can actually deliver. A station is a +/// radio: the pool is effectively unbounded and every extra draw returns mostly +/// repeats, so "keep pulling until it runs dry" quietly turns into "pull 20 +/// times and announce 469 tracks" for a playlist that lands at 198. +/// +/// 200 is the honest bound for a DYNAMIC source — the station is different +/// every day, so the value of a bigger single import is low and the cost (draw +/// after draw of duplicates, Last.fm rate-limiting the burst, hundreds of +/// wasted Qobuz searches) is real. Owner ruling 2026-08-19: "no debemos dar +/// falsas expectativas de 'traere casi 500 tracks' cuando solo podemos traer la +/// mitad." +const MAX_STATION_TRACKS: usize = 200; + +/// What a pasted string means. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LastFmTarget { + Profile { user: String }, + Playlist { user: String, id: String }, +} + +/// Cheap, synchronous, safe per keystroke. +/// +/// PLAYLIST IS CHECKED FIRST. A playlist URL contains a profile URL as a +/// prefix, so testing the profile shape first would classify every playlist as +/// a profile and silently show the station picker instead of importing. +pub fn detect(input: &str) -> Option { + let s = input.trim(); + if s.is_empty() { + return None; + } + if s.contains("last.fm") { + let after_user = s.split("/user/").nth(1)?; + let mut parts = after_user.split(['?', '#']).next().unwrap_or("").split('/'); + let user = parts.next().unwrap_or("").trim(); + if !is_username(user) { + return None; + } + if parts.next() == Some("playlists") { + if let Some(id) = parts.next().map(str::trim).filter(|i| !i.is_empty()) { + return Some(LastFmTarget::Playlist { + user: user.to_string(), + id: id.to_string(), + }); + } + } + return Some(LastFmTarget::Profile { + user: user.to_string(), + }); + } + if is_username(s) { + return Some(LastFmTarget::Profile { + user: s.to_string(), + }); + } + None +} + +fn is_username(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// One of the three stations. +/// +/// # `?page=N` IS NOT PAGINATION, and treating it as such was a real bug +/// +/// A station is a RADIO. Every request is a fresh draw from the same +/// recommendation pool, not the next slice of an ordered list — measured +/// against a live profile on 2026-08-19: pages 1 and 2 returned overlapping +/// tracks, and the batch sizes varied (20, 20, 36) instead of being a stable +/// page length. +/// +/// So the original loop — "fetch 20 pages, stop at the first empty one" — never +/// hit its exit condition, always burned all 20 requests (Last.fm starts +/// refusing after about four in quick succession), and returned a pile of +/// duplicates. One real import produced 469 rows holding 198 distinct tracks; +/// the other 271 were reported to the user as "skipped", which is not what +/// happened to them. +/// +/// The fix is to stop counting requests and start counting NEW tracks: +/// deduplicate as we draw, stop at [`MAX_STATION_TRACKS`], and give up early +/// after [`MAX_DRY_DRAWS`] consecutive draws that add nothing. [`MAX_PAGES`] +/// stays as a backstop on the request count. +/// +/// The count this returns IS the count the user is shown and IS the number of +/// rows the import will try to add. Those three used to be different numbers. +pub async fn fetch_station( + user: &str, + station: LastFmStation, +) -> Result { + let mut tracks: Vec = Vec::new(); + let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); + let mut dry = 0usize; + let mut draws = 0usize; + + for page in 1..=MAX_PAGES { + let url = format!( + "{BASE}/player/station/user/{user}/{}?ajax=1&page={page}", + station.slug() + ); + // A FAILED DRAW IS NOT A FAILED IMPORT once we have rows. Last.fm rate- + // limits a burst of these, and propagating that would throw away + // everything already collected — which is exactly what `?` used to do. + let body = match get_text(&url).await { + Ok(b) => b, + Err(e) if !tracks.is_empty() => { + log::warn!("[qbz-playlist-import] last.fm station draw {page} failed: {e}"); + break; + } + Err(e) => return Err(e), + }; + draws += 1; + let doc: Value = match serde_json::from_str(&body) { + Ok(v) => v, + // A shape change (or a rate-limit HTML body) must not fail an + // import that already has rows. + Err(e) => { + log::warn!("[qbz-playlist-import] last.fm station draw {page}: {e}"); + break; + } + }; + let drawn: Vec = doc["playlist"] + .as_array() + .into_iter() + .flatten() + .filter_map(map_station_entry) + .collect(); + if drawn.is_empty() { + break; + } + let before = tracks.len(); + for t in drawn { + if tracks.len() >= MAX_STATION_TRACKS { + break; + } + let key = (t.artist.to_lowercase(), t.title.to_lowercase()); + if seen.insert(key) { + tracks.push(t); + } + } + if tracks.len() >= MAX_STATION_TRACKS { + log::info!( + "[qbz-playlist-import] last.fm station reached its {MAX_STATION_TRACKS}-track cap" + ); + break; + } + if tracks.len() == before { + dry += 1; + if dry >= MAX_DRY_DRAWS { + break; + } + } else { + dry = 0; + } + } + log::info!( + "[qbz-playlist-import] last.fm station {}/{}: {} distinct tracks in {draws} draw(s)", + user, + station.slug(), + tracks.len() + ); + if tracks.is_empty() { + return Err(PlaylistImportError::EmptyPlaylist); + } + Ok(ImportPlaylist { + provider: ImportProvider::LastFm, + provider_id: format!("{user}/{}", station.slug()), + name: format!("{user} — {}", station_label(station)), + description: None, + tracks, + }) +} + +fn station_label(station: LastFmStation) -> &'static str { + // Not localized: it is part of a NAME the playlist is created under, and a + // playlist should not be renamed by changing the app's language. + match station { + LastFmStation::Library => "Library", + LastFmStation::Mix => "Mix", + LastFmStation::Recommended => "Recommendations", + } +} + +fn map_station_entry(e: &Value) -> Option { + let title = e["_name"] + .as_str() + .or_else(|| e["name"].as_str()) + .unwrap_or("") + .trim() + .to_string(); + if title.is_empty() { + return None; + } + let artist = e["artists"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x["_name"].as_str().or_else(|| x["name"].as_str())) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + let path = e["url"].as_str().unwrap_or(""); + Some(ImportTrack { + title, + artist, + album: e["primary_album"]["name"] + .as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + // The station feed's `duration` is SECONDS. + duration_ms: e["duration"].as_u64().filter(|d| *d > 0).map(|d| d * 1000), + isrc: None, + provider_id: path + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .map(str::to_string), + provider_url: (!path.is_empty()).then(|| format!("{BASE}{path}")), + }) +} + +/// A specific user playlist, scraped from its page. +pub async fn fetch_playlist( + user: &str, + playlist_id: &str, +) -> Result { + let mut tracks: Vec = Vec::new(); + let mut name = format!("{user} — Playlist"); + for page in 1..=MAX_PAGES { + let url = format!("{BASE}/user/{user}/playlists/{playlist_id}?page={page}"); + let html = get_text(&url).await?; + if page == 1 { + if let Some(t) = scrape_title(&html) { + name = t; + } + } + let page_tracks = scrape_chartlist(&html); + if page_tracks.is_empty() { + break; + } + tracks.extend(page_tracks); + } + if tracks.is_empty() { + return Err(PlaylistImportError::EmptyPlaylist); + } + Ok(ImportPlaylist { + provider: ImportProvider::LastFm, + provider_id: format!("{user}/{playlist_id}"), + name, + description: None, + tracks, + }) +} + +/// The rows of a Last.fm `chartlist` table. +/// +/// It reads the `data-track-name` / `data-artist-name` ATTRIBUTES rather than +/// the visible cell text, and that is not a style choice: the visible text is +/// truncated with an ellipsis at the column width, so scraping it would import +/// "Everything In Its Right Pla…" and lose the match. The attributes carry the +/// full strings. +fn scrape_chartlist(html: &str) -> Vec { + let mut out = Vec::new(); + let mut cursor = 0usize; + while let Some(rel) = html[cursor..].find("data-track-name=\"") { + let start = cursor + rel; + let Some(title) = attr_value(html, start, "data-track-name=\"") else { + cursor = start + 17; + continue; + }; + // The artist attribute lives on the same element; look ahead a bounded + // window rather than scanning the rest of the document. + let window_end = (start + 2048).min(html.len()); + let artist = html[start..window_end] + .find("data-artist-name=\"") + .and_then(|r| attr_value(html, start + r, "data-artist-name=\"")) + .unwrap_or_default(); + if !title.trim().is_empty() { + out.push(ImportTrack { + title: decode_entities(title.trim()), + artist: decode_entities(artist.trim()), + album: None, + duration_ms: None, + isrc: None, + provider_id: None, + provider_url: None, + }); + } + cursor = start + 17; + } + out +} + +fn attr_value(html: &str, at: usize, attr: &str) -> Option { + let rest = &html[at + attr.len()..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +fn scrape_title(html: &str) -> Option { + let start = html.find("")? + 7; + let end = html[start..].find("")? + start; + let raw = decode_entities(html[start..end].trim()); + // Last.fm titles read " | Last.fm". + let cleaned = raw.split(" | ").next().unwrap_or(&raw).trim().to_string(); + (!cleaned.is_empty()).then_some(cleaned) +} + +/// The five XML entities plus `'`. A full HTML entity table is not worth a +/// dependency for track titles. +fn decode_entities(s: &str) -> String { + s.replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") +} + +async fn get_text(url: &str) -> Result { + let resp = http() + .get(url) + // The crate's shared client sends no User-Agent and Last.fm's CDN 403s + // that. Per-request, so nothing else on the shared client changes. + .header(reqwest::header::USER_AGENT, BROWSER_USER_AGENT) + .send() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))?; + if !resp.status().is_success() { + return Err(PlaylistImportError::Http(format!( + "Last.fm returned {}", + resp.status() + ))); + } + resp.text() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_playlist_url_is_never_read_as_a_profile() { + assert_eq!( + detect("https://www.last.fm/user/rob/playlists/12345678"), + Some(LastFmTarget::Playlist { + user: "rob".into(), + id: "12345678".into() + }) + ); + assert_eq!( + detect("https://www.last.fm/user/rob"), + Some(LastFmTarget::Profile { user: "rob".into() }) + ); + assert_eq!( + detect("https://www.last.fm/user/rob/library"), + Some(LastFmTarget::Profile { user: "rob".into() }) + ); + assert_eq!( + detect(" rob-2 "), + Some(LastFmTarget::Profile { user: "rob-2".into() }) + ); + assert_eq!(detect(""), None); + assert_eq!(detect("https://example.com/user/rob"), None); + } + + #[test] + fn a_station_entry_maps_and_its_duration_is_seconds() { + let e: Value = serde_json::from_str( + r#"{"_name":"Song","artists":[{"_name":"A"},{"_name":"B"}], + "primary_album":{"name":"Al"},"duration":214, + "url":"/music/A/_/Song"}"#, + ) + .unwrap(); + let t = map_station_entry(&e).unwrap(); + assert_eq!(t.title, "Song"); + assert_eq!(t.artist, "A, B"); + assert_eq!(t.album.as_deref(), Some("Al")); + // 214 SECONDS -> ms. + assert_eq!(t.duration_ms, Some(214_000)); + assert_eq!(t.provider_url.as_deref(), Some("https://www.last.fm/music/A/_/Song")); + } + + #[test] + fn a_station_entry_without_a_name_is_dropped() { + let e: Value = serde_json::from_str(r#"{"artists":[{"_name":"A"}]}"#).unwrap(); + assert!(map_station_entry(&e).is_none()); + } + + #[test] + fn chartlist_rows_come_from_the_attributes_not_the_truncated_text() { + let html = r#" + + Everything In Its Right Pla… + + + + + "#; + let rows = scrape_chartlist(html); + assert_eq!(rows.len(), 2); + // The FULL title, not the ellipsized cell text. + assert_eq!(rows[0].title, "Everything In Its Right Place"); + assert_eq!(rows[0].artist, "Radiohead"); + // Entities are decoded. + assert_eq!(rows[1].artist, "Sigur R&s"); + } + + /// The draw loop's bookkeeping, isolated from the network: a radio hands + /// back overlapping batches, and what comes out must be distinct and + /// bounded by the cap the user is promised. + #[test] + fn overlapping_draws_dedupe_and_stop_at_the_cap() { + let mut seen: std::collections::HashSet<(String, String)> = + std::collections::HashSet::new(); + let mut kept = 0usize; + // 40 draws of 30 entries each, cycling through a 250-track pool — + // the shape a station actually has. + for draw in 0..40 { + for i in 0..30 { + if kept >= MAX_STATION_TRACKS { + break; + } + let idx = (draw * 7 + i) % 250; + if seen.insert(("band".into(), format!("song{idx}"))) { + kept += 1; + } + } + } + assert_eq!(kept, MAX_STATION_TRACKS, "the cap is what bounds the result"); + assert_eq!(seen.len(), kept, "nothing repeats in the output"); + } + + #[test] + fn an_html_page_with_no_chartlist_yields_no_rows_rather_than_failing() { + assert!(scrape_chartlist("nothing here").is_empty()); + } + + #[test] + fn the_page_title_is_cleaned_of_the_site_suffix() { + assert_eq!( + scrape_title("Road Trip | Last.fm").as_deref(), + Some("Road Trip") + ); + } +} diff --git a/crates/qbz-playlist-import/src/sources/service/listenbrainz.rs b/crates/qbz-playlist-import/src/sources/service/listenbrainz.rs new file mode 100644 index 000000000..7ff807ed7 --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/service/listenbrainz.rs @@ -0,0 +1,266 @@ +//! ListenBrainz — a playlist by MBID, and the "created for you" list. +//! +//! Two public endpoints, no auth required: +//! +//! | what | endpoint | +//! |---|---| +//! | one playlist | `GET /1/playlist/` | +//! | "created for you" | `GET /1/user//playlists/createdfor?count=N` | +//! +//! Both return JSPF. The interesting detail is that JSPF `duration` IS +//! MILLISECONDS — same as XSPF, which JSPF is the JSON serialization of — so it +//! goes through unmultiplied. +//! +//! MBID -> ISRC enrichment is an explicit NON-GOAL. It would unlock the +//! matcher's score-1.0 fast path, but it costs one MusicBrainz request per +//! track against a 1 req/s rate limit and a new dependency. Backlog, not here. + +use serde_json::Value; + +use super::LB_USER_AGENT; +use crate::errors::PlaylistImportError; +use crate::http::http; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +const API: &str = "https://api.listenbrainz.org"; +/// How many "created for you" entries to offer. The endpoint's own default is +/// 25; the list is a dropdown, not a page. +const CREATED_FOR_COUNT: usize = 25; + +/// What a pasted string means. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LbTarget { + /// A playlist MBID (pasted bare or lifted out of a listenbrainz.org URL). + Mbid(String), + /// A username — the "created for you" list is fetched for it. + User(String), +} + +/// One entry of the "created for you" dropdown. +#[derive(Debug, Clone)] +pub struct LbPlaylistOption { + pub mbid: String, + pub title: String, +} + +/// Cheap, synchronous, and safe to run on every keystroke. +/// +/// MBID FIRST: a URL is checked before a bare username, because +/// `listenbrainz.org/playlist/` also contains something that looks like a +/// path segment a loose username rule would grab. +pub fn detect(input: &str) -> Option { + let s = input.trim(); + if s.is_empty() { + return None; + } + if s.contains("listenbrainz.org") { + // .../playlist/ (trailing slash and query tolerated) + if let Some(rest) = s.split("/playlist/").nth(1) { + let mbid = rest + .split(['/', '?', '#']) + .next() + .unwrap_or("") + .trim() + .to_string(); + if is_mbid(&mbid) { + return Some(LbTarget::Mbid(mbid)); + } + } + // .../user/ + if let Some(rest) = s.split("/user/").nth(1) { + let user = rest.split(['/', '?', '#']).next().unwrap_or("").trim(); + if is_username(user) { + return Some(LbTarget::User(user.to_string())); + } + } + return None; + } + if is_mbid(s) { + return Some(LbTarget::Mbid(s.to_string())); + } + if is_username(s) { + return Some(LbTarget::User(s.to_string())); + } + None +} + +/// 8-4-4-4-12 hex. +fn is_mbid(s: &str) -> bool { + let parts: Vec<&str> = s.split('-').collect(); + parts.len() == 5 + && [8usize, 4, 4, 4, 12] + .iter() + .zip(&parts) + .all(|(n, p)| p.len() == *n && p.chars().all(|c| c.is_ascii_hexdigit())) +} + +/// Conservative: no spaces, no slashes, no dots — enough to reject a stray URL +/// or a sentence without pretending to know the service's real rules. +fn is_username(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// The "created for you" dropdown's contents. +pub async fn list_created_for( + user: &str, + token: Option<&str>, +) -> Result, PlaylistImportError> { + let url = format!("{API}/1/user/{user}/playlists/createdfor?count={CREATED_FOR_COUNT}"); + let doc = get_json(&url, token).await?; + let mut out = Vec::new(); + for entry in doc["playlists"].as_array().into_iter().flatten() { + let pl = &entry["playlist"]; + let Some(id) = pl["identifier"].as_str() else { + continue; + }; + // `identifier` is a URI: .../playlist/ + let mbid = id.rsplit('/').next().unwrap_or("").to_string(); + if !is_mbid(&mbid) { + continue; + } + out.push(LbPlaylistOption { + mbid, + title: pl["title"].as_str().unwrap_or("Playlist").to_string(), + }); + } + Ok(out) +} + +/// One playlist, by MBID. +pub async fn fetch(mbid: &str, token: Option<&str>) -> Result { + let doc = get_json(&format!("{API}/1/playlist/{mbid}"), token).await?; + let pl = &doc["playlist"]; + let tracks: Vec = pl["track"] + .as_array() + .into_iter() + .flatten() + .filter_map(map_track) + .collect(); + if tracks.is_empty() { + return Err(PlaylistImportError::EmptyPlaylist); + } + Ok(ImportPlaylist { + provider: ImportProvider::ListenBrainz, + provider_id: mbid.to_string(), + name: pl["title"].as_str().unwrap_or("ListenBrainz playlist").to_string(), + description: pl["annotation"] + .as_str() + .map(str::to_string) + .filter(|s| !s.trim().is_empty()), + tracks, + }) +} + +/// JSPF track -> `ImportTrack`. Defensive: a shape change drops the row +/// instead of failing the import. +fn map_track(t: &Value) -> Option { + let title = t["title"].as_str().unwrap_or("").trim().to_string(); + if title.is_empty() { + return None; + } + // `identifier` is the recording MBID as a URI; it goes to `provider_id` and + // NEVER to `isrc` — JSPF carries no ISRC, and an MBID in that field would + // misfire the matcher's exact-ISRC short circuit. + let recording_mbid = t["identifier"] + .as_str() + .or_else(|| t["identifier"][0].as_str()) + .and_then(|s| s.rsplit('/').next()) + .filter(|s| is_mbid(s)) + .map(str::to_string); + Some(ImportTrack { + title, + artist: t["creator"].as_str().unwrap_or("").trim().to_string(), + album: t["album"] + .as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + // JSPF duration is MILLISECONDS (it is XSPF in JSON) — no x1000. + duration_ms: t["duration"].as_u64().filter(|d| *d > 0), + isrc: None, + provider_url: recording_mbid + .as_deref() + .map(|m| format!("https://listenbrainz.org/track/{m}")), + provider_id: recording_mbid, + }) +} + +async fn get_json(url: &str, token: Option<&str>) -> Result { + let mut req = http().get(url).header(reqwest::header::USER_AGENT, LB_USER_AGENT); + if let Some(t) = token.map(str::trim).filter(|t| !t.is_empty()) { + req = req.header(reqwest::header::AUTHORIZATION, format!("Token {t}")); + } + let resp = req + .send() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))?; + if !resp.status().is_success() { + return Err(PlaylistImportError::Http(format!( + "ListenBrainz returned {}", + resp.status() + ))); + } + resp.json::() + .await + .map_err(|e| PlaylistImportError::Parse(format!("ListenBrainz: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MBID: &str = "1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d"; + + #[test] + fn detect_reads_urls_mbids_and_usernames() { + assert_eq!( + detect(&format!("https://listenbrainz.org/playlist/{MBID}")), + Some(LbTarget::Mbid(MBID.into())) + ); + // Trailing slash and query survive. + assert_eq!( + detect(&format!("https://listenbrainz.org/playlist/{MBID}/?x=1")), + Some(LbTarget::Mbid(MBID.into())) + ); + assert_eq!( + detect("https://listenbrainz.org/user/rob"), + Some(LbTarget::User("rob".into())) + ); + assert_eq!(detect(MBID), Some(LbTarget::Mbid(MBID.into()))); + assert_eq!(detect(" rob_2 "), Some(LbTarget::User("rob_2".into()))); + assert_eq!(detect(""), None); + assert_eq!(detect("not a name"), None); + assert_eq!(detect("https://example.com/x"), None); + } + + #[test] + fn jspf_duration_is_milliseconds() { + let t: Value = serde_json::from_str( + r#"{"title":"Song","creator":"Band","album":"Al","duration":214000}"#, + ) + .unwrap(); + let m = map_track(&t).unwrap(); + assert_eq!(m.duration_ms, Some(214_000)); + assert_eq!(m.artist, "Band"); + assert_eq!(m.album.as_deref(), Some("Al")); + } + + #[test] + fn the_recording_mbid_lands_in_provider_id_never_in_isrc() { + let t: Value = serde_json::from_str(&format!( + r#"{{"title":"Song","creator":"B","identifier":"https://musicbrainz.org/recording/{MBID}"}}"# + )) + .unwrap(); + let m = map_track(&t).unwrap(); + assert_eq!(m.provider_id.as_deref(), Some(MBID)); + assert_eq!(m.isrc, None); + } + + #[test] + fn a_titleless_track_is_dropped_rather_than_failing_the_import() { + let t: Value = serde_json::from_str(r#"{"creator":"B"}"#).unwrap(); + assert!(map_track(&t).is_none()); + } +} diff --git a/crates/qbz-playlist-import/src/sources/service/mod.rs b/crates/qbz-playlist-import/src/sources/service/mod.rs new file mode 100644 index 000000000..e88c19344 --- /dev/null +++ b/crates/qbz-playlist-import/src/sources/service/mod.rs @@ -0,0 +1,44 @@ +//! Public-read services: ListenBrainz and Last.fm. +//! +//! # No authentication, by design +//! +//! Both arms read PUBLIC data with a username or a URL. That matches what the +//! importer already is — you import a public Spotify playlist without logging +//! into Spotify — and it honours the standing "integrations are strictly +//! opt-in" rule: connecting an account in Settings only PREFILLS the field, it +//! is never required, and nothing here writes to either service. +//! +//! The one optional credential is the ListenBrainz token, sent when the user +//! has already connected one: it raises the rate limit and reaches their own +//! private playlists. Absent, public reads work exactly the same. +//! +//! # No `qbz-integrations` edge +//! +//! `qbz-integrations` has a ListenBrainz client already, and it is the wrong +//! tool here for two concrete reasons, both verified in its source rather than +//! assumed: `get_playlist_tracks` returns tracks with NO duration field, and it +//! DROPS the playlist title and annotation (those come from the separate list +//! call). A direct `GET /1/playlist/` reads title, annotation and +//! per-track JSPF duration in one request, in about thirty lines. +//! +//! It also keeps `cargo test -p qbz-playlist-import` free of that crate's +//! bundled `rusqlite` and `discord-rich-presence`, which is the only real +//! build-cost difference — and it is confined to this crate's own test loop. +//! (The final binary already links `qbz-integrations`, so the edge would have +//! changed app build cost by zero. That argument is not being made.) + +pub mod lastfm; +pub mod listenbrainz; + +/// A browser-like User-Agent for the scraper-class calls. +/// +/// The crate's shared client sends NO User-Agent, which is right for the +/// providers that already work. Last.fm's CDN 403s an empty-UA request, so +/// every call in `lastfm.rs` sets this per-request. ListenBrainz asks for a +/// descriptive one instead and gets [`LB_USER_AGENT`]. +pub(crate) const BROWSER_USER_AGENT: &str = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) \ + Chrome/126.0.0.0 Safari/537.36"; + +/// ListenBrainz asks API clients to identify themselves. +pub(crate) const LB_USER_AGENT: &str = "QBZ/2.0 ( https://github.com/vicrodh/qbz )"; diff --git a/crates/qbz-plex/Cargo.toml b/crates/qbz-plex/Cargo.toml index 97c6e0496..9fb10e119 100644 --- a/crates/qbz-plex/Cargo.toml +++ b/crates/qbz-plex/Cargo.toml @@ -7,13 +7,14 @@ description = "Plex LAN-only integration for QBZ: HTTP client, XML metadata pars [dependencies] # SQLite cache (match the workspace's bundled SQLite usage) -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } # HTTP client for the Plex Media Server LAN API reqwest = { workspace = true } # DTO serialization (camelCase envelopes shared with the frontend) serde = { workspace = true } +serde_json.workspace = true # Platform data dir (plex_cache.db location) dirs = "6" diff --git a/crates/qbz-plex/src/lib.rs b/crates/qbz-plex/src/lib.rs index b45e2d265..38ac251a1 100644 --- a/crates/qbz-plex/src/lib.rs +++ b/crates/qbz-plex/src/lib.rs @@ -4,14 +4,21 @@ //! so playback uses original media bytes served by Plex Media Server. use std::collections::hash_map::DefaultHasher; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::time::Duration; use reqwest::header::{HeaderMap, HeaderValue}; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; +const MAX_PLEX_TRACK_PAGE_BYTES: usize = 32 * 1024 * 1024; +const DEFAULT_PLEX_TRACK_PAGE_SIZE: u64 = 250; +/// Album metadata is LAN-local and cheap, but a missing cover must not turn +/// into one request on every boot forever. Positive and negative results are +/// both rechecked weekly so a cover added later in Plex eventually appears. +pub const PLEX_ALBUM_ARTWORK_MAX_AGE_SECS: u64 = 7 * 24 * 60 * 60; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlexServerInfo { @@ -35,7 +42,12 @@ pub struct PlexTrack { pub artist: Option, pub album: Option, pub duration_ms: Option, + /// Track/item thumb. Plex may expose a disc-specific cover here. pub artwork_path: Option, + /// Parent album thumb, kept separately so item art can win without losing + /// a stable collection fallback. + #[serde(default)] + pub collection_artwork_path: Option, pub part_key: Option, pub container: Option, pub codec: Option, @@ -46,11 +58,47 @@ pub struct PlexTrack { pub track_number: Option, pub disc_number: Option, pub year: Option, + /// Every `` Plex supplied. `genre` remains the compatibility + /// primary value for older callers and cache rows. + pub genres: Vec, pub genre: Option, /// `parentRatingKey` — the Plex album this track belongs to. Distinct per /// physical album/edition, so it separates two albums that share the same /// title+artist (which `album_key`, a title+artist hash, collapses into one). pub parent_rating_key: Option, + /// MusicBrainz recording id from the item's `` + /// child (requires `includeGuids=1`; only the Plex Music agent writes + /// it). `None` otherwise — never synthesised. + #[serde(default)] + pub recording_mbid: Option, +} + +#[derive(Debug, Clone)] +pub struct PlexTrackPage { + pub tracks: Vec, + pub offset: u64, + pub response_size: u64, + pub total_size: u64, +} + +impl PlexTrackPage { + pub fn next_start(&self) -> u64 { + self.offset.saturating_add(self.response_size) + } + + pub fn has_more(&self) -> bool { + self.next_start() < self.total_size + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlexSectionSyncState { + pub section_key: String, + pub generation: u64, + pub next_start: u64, + pub total_size: Option, + pub observed_rows: u64, + pub resumed: bool, } #[derive(Debug, Clone, Serialize)] @@ -128,10 +176,12 @@ pub struct PlexCachedTrack { pub bit_depth: Option, pub sample_rate: u32, pub artwork_path: Option, + pub collection_artwork_path: Option, pub source: String, pub album_key: String, pub track_number: Option, pub disc_number: Option, + pub year: Option, /// The Plex album (`parentRatingKey`) this track belongs to — used to split /// distinct same-title albums into separate versions in the album view. pub parent_rating_key: Option, @@ -179,6 +229,7 @@ struct TrackBuilder { album: Option, duration_ms: Option, artwork_path: Option, + collection_artwork_path: Option, part_key: Option, container: Option, codec: Option, @@ -189,8 +240,10 @@ struct TrackBuilder { track_number: Option, disc_number: Option, year: Option, + genres: Vec, genre: Option, parent_rating_key: Option, + recording_mbid: Option, } fn normalize_base_url(base_url: &str) -> String { @@ -204,6 +257,33 @@ fn now_epoch_secs() -> i64 { .unwrap_or(0) } +fn plex_genres_json(track: &PlexTrack) -> String { + let mut genres = Vec::::new(); + for value in track.genres.iter().chain(track.genre.iter()) { + let value = value.trim(); + if !value.is_empty() + && !genres + .iter() + .any(|existing| existing.eq_ignore_ascii_case(value)) + { + genres.push(value.to_string()); + } + } + serde_json::to_string(&genres).unwrap_or_else(|_| "[]".to_string()) +} + +fn plex_genres_from_json(raw: Option<&str>, primary: Option<&str>) -> Vec { + let mut genres = raw + .and_then(|value| serde_json::from_str::>(value).ok()) + .unwrap_or_default(); + if genres.is_empty() { + if let Some(value) = primary.filter(|value| !value.trim().is_empty()) { + genres.push(value.trim().to_string()); + } + } + genres +} + fn open_plex_cache_db() -> Result { let data_dir = dirs::data_dir() .ok_or("Could not determine data directory")? @@ -215,6 +295,9 @@ fn open_plex_cache_db() -> Result { let conn = Connection::open(db_path) .map_err(|e| format!("Failed to open Plex cache database: {}", e))?; + conn.busy_timeout(Duration::from_millis(2_500)) + .map_err(|e| format!("Failed to set Plex cache busy timeout: {}", e))?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") .map_err(|e| format!("Failed to enable WAL for Plex cache database: {}", e))?; @@ -236,6 +319,7 @@ fn open_plex_cache_db() -> Result { album TEXT, duration_ms INTEGER, artwork_path TEXT, + collection_artwork_path TEXT, part_key TEXT, container TEXT, codec TEXT, @@ -243,9 +327,33 @@ fn open_plex_cache_db() -> Result { bitrate_kbps INTEGER, sampling_rate_hz INTEGER, bit_depth INTEGER, + genres_json TEXT NOT NULL DEFAULT '[]', updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_plex_cache_tracks_section ON plex_cache_tracks(section_key); + + CREATE TABLE IF NOT EXISTS plex_cache_section_sync ( + section_key TEXT PRIMARY KEY, + server_id TEXT, + generation INTEGER NOT NULL DEFAULT 0, + next_start INTEGER NOT NULL DEFAULT 0, + total_size INTEGER, + observed_rows INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'idle', + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + -- `/library/sections//all?type=10` does not always put + -- `parentThumb` on Track rows, even when the album has a cover. Keep + -- the authoritative album-metadata answer separately: it is scoped by + -- server because Plex rating keys are only server-local identities. + CREATE TABLE IF NOT EXISTS plex_cache_album_artwork ( + server_id TEXT NOT NULL, + parent_rating_key TEXT NOT NULL, + artwork_path TEXT, + checked_at INTEGER NOT NULL, + PRIMARY KEY(server_id, parent_rating_key) + ); ", ) .map_err(|e| format!("Failed to initialize Plex cache schema: {}", e))?; @@ -259,11 +367,19 @@ fn open_plex_cache_db() -> Result { "album_key TEXT", "year INTEGER", "genre TEXT", + "genres_json TEXT NOT NULL DEFAULT '[]'", + "collection_artwork_path TEXT", // parent_rating_key (2026-06-08): the Plex album a track belongs to, so // two same-title albums no longer interleave in the album view. NULL on // pre-migration rows (and rows synced before this column) — a re-sync // backfills it; the album view falls back to one version meanwhile. "parent_rating_key TEXT", + // Generation 0 is the legacy snapshot. The first completed paged sync + // promotes observed rows to generation 1 and only then prunes 0. + "sync_generation INTEGER NOT NULL DEFAULT 0", + // Identity (2026-08-28): the MusicBrainz recording id from the item's + // Guid. NULL until the next sync re-reads the section with Guids. + "recording_mbid TEXT", ] { let stmt = format!("ALTER TABLE plex_cache_tracks ADD COLUMN {col}"); let _ = conn.execute(&stmt, []); @@ -324,8 +440,49 @@ fn open_plex_cache_db() -> Result { Ok(conn) } +/// The default-timeout Plex client, built ONCE. +/// +/// It used to be rebuilt on every call, and a fresh `reqwest::Client` is not +/// free: new connection pool (so no keep-alive to reuse), fresh TLS handshake, +/// and a fresh DNS resolution. On Plex that last one is the expensive part — +/// LAN servers are addressed as `https://..plex.direct:32400`, and +/// `*.plex.direct` resolves through Plex's own nameservers, i.e. over the +/// INTERNET. So every play paid an internet DNS round trip plus a TLS handshake +/// before the first byte of a file that was sitting on the local network. +/// +/// That is the shape the owner reported (2026-08-13): playing a Plex track took +/// LONGER than streaming a Qobuz track from the internet, which no amount of +/// LAN transfer time can explain — but a per-call client can, because the Qobuz +/// path reuses one long-lived client and this one threw its away every time. +/// +/// Cached here rather than at the call sites so every Plex entry point gets it. +/// `build_plex_client_with_timeout` stays uncached: its callers pick a short +/// bound on purpose (quality hydration), and those are genuinely different +/// clients. +static PLEX_CLIENT: std::sync::OnceLock> = + std::sync::OnceLock::new(); + fn build_plex_client() -> Result { - build_plex_client_with_timeout(Duration::from_secs(120)) + PLEX_CLIENT + .get_or_init(|| build_plex_client_with_timeout(Duration::from_secs(120))) + .clone() +} + +/// Non-secret client identity Plex expects on media-part requests. +/// +/// Metadata and full-body playback already use [`build_plex_client`], which +/// installs this profile as default headers. Progressive playback resolves the +/// URL here but performs the byte transfer in the shared player transport, so +/// it must carry the same profile with the resolved location. The token stays +/// in the redacted query URL; none of these values is a credential. +pub fn plex_media_request_headers() -> Vec<(String, String)> { + vec![ + ("X-Plex-Product".into(), "QBZ".into()), + ("X-Plex-Version".into(), "0.1-poc".into()), + ("X-Plex-Device".into(), "QBZ Desktop".into()), + ("X-Plex-Platform".into(), std::env::consts::OS.into()), + ("X-Plex-Client-Identifier".into(), "qbz-plex-lan-poc".into()), + ] } /// Build the LAN Plex client with a caller-chosen request timeout. Quality @@ -387,6 +544,47 @@ fn with_token(url: &str, token: &str) -> String { format!("{url}{sep}X-Plex-Token={token}") } +/// Ask Plex for the original media bytes rather than its bare part route. +/// +/// Some Plex servers advertise a valid `/library/parts/.../file` key but +/// answer HTTP 500 when that key is fetched without `download=1`; the same +/// part immediately serves ranged `audio/flac` bytes with the flag. Plex Web +/// and Plexamp hide that server quirk behind their delivery negotiation. QBZ +/// needs the explicit flag because it Range-streams the original part itself. +fn as_download(url: &str) -> String { + if url.split_once('?').is_some_and(|(_, query)| { + query.split('&').any(|pair| { + pair.split_once('=') + .is_some_and(|(key, value)| key.eq_ignore_ascii_case("download") && value == "1") + }) + }) { + return url.to_string(); + } + let sep = if url.contains('?') { "&" } else { "?" }; + format!("{url}{sep}download=1") +} + +/// Render a request failure without reqwest's URL. Plex authentication is +/// carried in the query string, and reqwest's Display output includes that URL +/// for several error kinds. Callers may surface these strings in UI logs, so +/// only retain a coarse reason and the non-sensitive numeric status. +fn safe_http_error(context: &str, error: &reqwest::Error) -> String { + let reason = if error.is_timeout() { + "request timed out".to_string() + } else if let Some(status) = error.status() { + format!("server returned HTTP {}", status.as_u16()) + } else if error.is_connect() { + "connection failed".to_string() + } else if error.is_decode() { + "response decoding failed".to_string() + } else if error.is_body() { + "response body failed".to_string() + } else { + "request failed".to_string() + }; + format!("{context}: {reason}") +} + fn parse_u64(v: Option) -> Option { v.and_then(|s| s.parse::().ok()) } @@ -584,6 +782,29 @@ fn parse_music_sections(xml: &str) -> Vec { sections } +/// The track listing is not an artwork authority: Plex may omit both `thumb` +/// and `parentThumb` there while `/library/metadata/` still +/// exposes the album cover (and that cover can point at a *different* rating +/// key). Read the returned token instead of synthesizing a URL from the album +/// id; the latter is a real 404 for inherited/default Plex artwork. +fn parse_album_artwork_path(xml: &str) -> Option { + let directory_thumb = collect_start_tags(xml, "Directory") + .into_iter() + .find(|tag| get_attr(tag, "type").as_deref() == Some("album")) + .and_then(|tag| get_attr(&tag, "thumb")); + directory_thumb + .or_else(|| { + collect_start_tags(xml, "Image") + .into_iter() + .find(|tag| get_attr(tag, "type").as_deref() == Some("coverPoster")) + .and_then(|tag| get_attr(&tag, "url")) + }) + .map(|path| path.trim().to_string()) + .filter(|path| { + !path.is_empty() && (path.starts_with("/library/") || path.starts_with("/photo/")) + }) +} + fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option { // Plex exposes album release year via parentYear on the Track element // (same structure that drives the Plex client's year display); fall back @@ -597,12 +818,27 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option { parse_u32(iso.and_then(|s| s.get(..4).map(|s| s.to_string()))) }); - // Genre may appear either as an attribute or as a nested . - let genre_inline = get_attr(start_tag, "genre"); - let genre_nested = collect_start_tags(inner_xml, "Genre") - .into_iter() - .find_map(|tag| get_attr(&tag, "tag")); - let genre = genre_inline.or(genre_nested); + // Genre may appear either as an attribute or as several nested + // `` rows. Preserve all of them; the old singular field + // remains the first value for compatibility. + let mut genres = Vec::::new(); + if let Some(value) = get_attr(start_tag, "genre") { + if !value.trim().is_empty() { + genres.push(value.trim().to_string()); + } + } + for tag in collect_start_tags(inner_xml, "Genre") { + if let Some(value) = get_attr(&tag, "tag").filter(|value| !value.trim().is_empty()) { + let value = value.trim().to_string(); + if !genres + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&value)) + { + genres.push(value); + } + } + } + let genre = genres.first().cloned(); let mut t = TrackBuilder { rating_key: get_attr(start_tag, "ratingKey"), @@ -611,12 +847,16 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option { .or_else(|| get_attr(start_tag, "originalTitle")), album: get_attr(start_tag, "parentTitle"), duration_ms: parse_u64(get_attr(start_tag, "duration")), - artwork_path: get_attr(start_tag, "thumb") - .or_else(|| get_attr(start_tag, "parentThumb")) - .or_else(|| get_attr(start_tag, "grandparentThumb")), + // Preserve the specificity hierarchy instead of flattening it here. + // A track/disc thumb may differ from the parent album cover; callers + // choose item -> collection -> placeholder for playback, while album + // aggregation chooses collection -> item. + artwork_path: get_attr(start_tag, "thumb"), + collection_artwork_path: get_attr(start_tag, "parentThumb"), track_number: parse_u32(get_attr(start_tag, "index")), disc_number: parse_u32(get_attr(start_tag, "parentIndex")), year, + genres, genre, parent_rating_key: get_attr(start_tag, "parentRatingKey"), ..Default::default() @@ -685,6 +925,21 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option { } } + // `` — present only with includeGuids=1 and + // only for tracks the Plex Music agent matched. Other schemes (plex://) + // are not identities anyone else can join on, so they are ignored. + for guid_tag in collect_start_tags(inner_xml, "Guid") { + if let Some(mbid) = get_attr(&guid_tag, "id") + .as_deref() + .and_then(|id| id.strip_prefix("mbid://")) + .map(str::trim) + .filter(|id| !id.is_empty()) + { + t.recording_mbid = Some(mbid.to_string()); + break; + } + } + let (Some(rating_key), Some(title)) = (t.rating_key, t.title) else { return None; }; @@ -696,6 +951,7 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option { album: t.album, duration_ms: t.duration_ms, artwork_path: t.artwork_path, + collection_artwork_path: t.collection_artwork_path, part_key: t.part_key, container: t.container, codec: t.codec, @@ -706,8 +962,10 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option { track_number: t.track_number, disc_number: t.disc_number, year: t.year, + genres: t.genres, genre: t.genre, parent_rating_key: t.parent_rating_key, + recording_mbid: t.recording_mbid, }) } @@ -728,6 +986,60 @@ fn parse_tracks(xml: &str, limit: Option) -> Vec { tracks } +fn parse_track_page( + xml: &str, + requested_start: u64, + requested_size: u64, +) -> Result { + let container = find_first_tag(xml, "MediaContainer") + .ok_or_else(|| "Plex track page has no MediaContainer".to_string())?; + let offset = parse_u64(get_attr(&container, "offset")).unwrap_or(requested_start); + let response_size = parse_u64(get_attr(&container, "size")) + .ok_or_else(|| "Plex track page has no size".to_string())?; + let total_size = parse_u64(get_attr(&container, "totalSize")) + .ok_or_else(|| "Plex track page has no totalSize".to_string())?; + if offset != requested_start { + return Err(format!( + "Plex track page offset mismatch: requested {requested_start}, received {offset}" + )); + } + if response_size > requested_size { + return Err(format!( + "Plex track page exceeded requested size: requested {requested_size}, received {response_size}" + )); + } + + let blocks = collect_tag_blocks(xml, "Track"); + if blocks.len() as u64 != response_size { + return Err(format!( + "Plex track page size mismatch: container {response_size}, XML {}", + blocks.len() + )); + } + let mut tracks = Vec::with_capacity(blocks.len()); + let mut ids = HashSet::with_capacity(blocks.len()); + for (start_tag, inner_xml) in blocks { + let track = parse_track_block(&start_tag, &inner_xml) + .ok_or_else(|| "Plex track page contains an incomplete Track".to_string())?; + if !ids.insert(track.rating_key.clone()) { + return Err("Plex track page contains a duplicate ratingKey".to_string()); + } + tracks.push(track); + } + let next_start = offset.saturating_add(response_size); + if next_start > total_size || (response_size == 0 && offset < total_size) { + return Err(format!( + "Plex track page cannot advance safely: offset {offset}, size {response_size}, total {total_size}" + )); + } + Ok(PlexTrackPage { + tracks, + offset, + response_size, + total_size, + }) +} + fn synthetic_track_id(rating_key: &str) -> u64 { let mut hasher = DefaultHasher::new(); rating_key.hash(&mut hasher); @@ -766,12 +1078,12 @@ pub async fn plex_ping(base_url: String, token: String) -> Result, ) -> Result, String> { + let effective_limit = limit.filter(|v| *v > 0); + let mut tracks = Vec::new(); + let mut start = 0_u64; + loop { + let remaining = effective_limit + .map(|limit| u64::from(limit).saturating_sub(tracks.len() as u64)) + .unwrap_or(DEFAULT_PLEX_TRACK_PAGE_SIZE); + if remaining == 0 { + break; + } + let page_size = remaining.min(DEFAULT_PLEX_TRACK_PAGE_SIZE); + let page = plex_get_section_tracks_page( + base_url.clone(), + token.clone(), + section_key.clone(), + start, + page_size, + ) + .await?; + start = page.next_start(); + let has_more = page.has_more(); + tracks.extend(page.tracks); + if !has_more { + break; + } + } + Ok(tracks) +} + +pub async fn plex_get_section_tracks_page( + base_url: String, + token: String, + section_key: String, + start: u64, + page_size: u64, +) -> Result { + if page_size == 0 { + return Err("Plex track page size must be positive".to_string()); + } let client = build_plex_client()?; let base = normalize_base_url(&base_url); - let list_url = format!("{base}/library/sections/{section_key}/all?type=10"); + // includeGuids=1: the item's external ids (``) + // travel with the row; without it Plex omits them from listings. + let list_url = format!("{base}/library/sections/{section_key}/all?type=10&includeGuids=1"); let url = with_token(&list_url, &token); + let mut response = client + .get(url) + .header("X-Plex-Container-Start", start.to_string()) + .header("X-Plex-Container-Size", page_size.to_string()) + .send() + .await + .map_err(|e| safe_http_error("Plex tracks page request", &e))? + .error_for_status() + .map_err(|e| safe_http_error("Plex tracks page request", &e))?; + if response + .content_length() + .is_some_and(|bytes| bytes > MAX_PLEX_TRACK_PAGE_BYTES as u64) + { + return Err("Plex tracks page exceeds the bounded response size".to_string()); + } + let mut body = Vec::with_capacity( + response + .content_length() + .unwrap_or(0) + .min(MAX_PLEX_TRACK_PAGE_BYTES as u64) as usize, + ); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| safe_http_error("Failed to read Plex tracks page", &e))? + { + if body.len().saturating_add(chunk.len()) > MAX_PLEX_TRACK_PAGE_BYTES { + return Err("Plex tracks page exceeds the bounded response size".to_string()); + } + body.extend_from_slice(&chunk); + } + let xml = String::from_utf8(body) + .map_err(|_| "Plex tracks page is not valid UTF-8 XML".to_string())?; + parse_track_page(&xml, start, page_size) +} +/// Fetch the album-level cover Plex omitted from its flat track listing. +/// `parent_rating_key` is a path segment supplied by the same Plex server; it +/// is still validated before interpolation so malformed cache data cannot +/// escape the metadata endpoint. +pub async fn plex_get_album_artwork_path( + base_url: String, + token: String, + parent_rating_key: String, +) -> Result, String> { + let key = parent_rating_key.trim(); + if key.is_empty() || key.chars().any(|ch| matches!(ch, '/' | '?' | '#')) { + return Err("Invalid Plex album rating key".to_string()); + } + let client = build_plex_client()?; + let base = normalize_base_url(&base_url); + let url = with_token(&format!("{base}/library/metadata/{key}"), &token); let xml = client .get(url) .send() .await - .map_err(|e| format!("Plex tracks request failed: {}", e))? + .map_err(|e| safe_http_error("Plex album metadata request", &e))? .error_for_status() - .map_err(|e| format!("Plex tracks status error: {}", e))? + .map_err(|e| safe_http_error("Plex album metadata request", &e))? .text() .await - .map_err(|e| format!("Failed to read Plex tracks response: {}", e))?; - - // Treat limit=0 as "no limit" to match frontend semantics. - let effective_limit = limit.filter(|v| *v > 0); - Ok(parse_tracks(&xml, effective_limit)) + .map_err(|e| safe_http_error("Failed to read Plex album metadata response", &e))?; + Ok(parse_album_artwork_path(&xml)) } pub async fn plex_get_track_metadata( @@ -851,12 +1252,12 @@ async fn plex_get_track_metadata_with_client( .get(url) .send() .await - .map_err(|e| format!("Plex track metadata request failed: {}", e))? + .map_err(|e| safe_http_error("Plex track metadata request", &e))? .error_for_status() - .map_err(|e| format!("Plex track metadata status error: {}", e))? + .map_err(|e| safe_http_error("Plex track metadata request", &e))? .text() .await - .map_err(|e| format!("Failed to read Plex track metadata response: {}", e))?; + .map_err(|e| safe_http_error("Failed to read Plex track metadata response", &e))?; parse_tracks(&xml, Some(1)) .into_iter() @@ -923,12 +1324,12 @@ pub async fn plex_auth_pin_start(client_identifier: String) -> Result() .await - .map_err(|e| format!("Failed to parse Plex auth pin response: {}", e))?; + .map_err(|e| safe_http_error("Failed to parse Plex auth pin response", &e))?; Ok(PlexPinStartResult { pin_id: pin.id, @@ -953,12 +1354,12 @@ pub async fn plex_auth_pin_check( let pin = request .send() .await - .map_err(|e| format!("Plex auth pin check request failed: {}", e))? + .map_err(|e| safe_http_error("Plex auth pin check request", &e))? .error_for_status() - .map_err(|e| format!("Plex auth pin check status error: {}", e))? + .map_err(|e| safe_http_error("Plex auth pin check request", &e))? .json::() .await - .map_err(|e| format!("Failed to parse Plex auth pin check response: {}", e))?; + .map_err(|e| safe_http_error("Failed to parse Plex auth pin check response", &e))?; Ok(PlexPinCheckResult { authorized: pin.auth_token.is_some(), @@ -1008,6 +1409,11 @@ pub fn plex_cache_save_sections( .map_err(|e| format!("Failed to start Plex cache sections transaction: {}", e))?; let now = now_epoch_secs(); + // A successful `/library/sections` response is the authoritative list. + // Replace it inside this transaction so a server failure before this call + // preserves the old list, while removed libraries do not remain as ghosts. + tx.execute("DELETE FROM plex_cache_sections", []) + .map_err(|e| format!("Failed to replace Plex cache sections: {}", e))?; for section in §ions { tx.execute( "INSERT INTO plex_cache_sections (section_key, title, server_id, updated_at) @@ -1026,6 +1432,111 @@ pub fn plex_cache_save_sections( Ok(sections.len()) } +/// Collection artwork as seen by every cache reader. Direct `parentThumb` +/// remains authoritative; the metadata table only fills the hole left by the +/// flat track endpoint. Keep this expression centralized so Albums, Tracks, +/// playlists and the source registry cannot disagree about the same row. +const EFFECTIVE_COLLECTION_ARTWORK_SQL: &str = + "COALESCE(NULLIF(plex_cache_tracks.collection_artwork_path,''), + (SELECT NULLIF(album_art.artwork_path,'') + FROM plex_cache_album_artwork album_art + WHERE album_art.server_id = + COALESCE(NULLIF(plex_cache_tracks.server_id,''),'default') + AND album_art.parent_rating_key = plex_cache_tracks.parent_rating_key))"; + +fn album_artwork_candidates_in( + conn: &Connection, + server_id: &str, + stale_before: i64, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT DISTINCT tracks.parent_rating_key + FROM plex_cache_tracks tracks + LEFT JOIN plex_cache_album_artwork album_art + ON album_art.server_id = COALESCE(NULLIF(tracks.server_id,''),'default') + AND album_art.parent_rating_key = tracks.parent_rating_key + WHERE COALESCE(NULLIF(tracks.server_id,''),'default') = ?1 + AND COALESCE(tracks.parent_rating_key,'') != '' + AND COALESCE(tracks.artwork_path,'') = '' + AND COALESCE(tracks.collection_artwork_path,'') = '' + AND (album_art.parent_rating_key IS NULL OR album_art.checked_at <= ?2) + ORDER BY tracks.parent_rating_key", + ) + .map_err(|e| format!("Failed to prepare Plex album artwork candidates: {e}"))?; + let rows = stmt + .query_map(params![server_id, stale_before], |row| { + row.get::<_, String>(0) + }) + .map_err(|e| format!("Failed to query Plex album artwork candidates: {e}"))?; + rows.collect::>>() + .map_err(|e| format!("Failed to read Plex album artwork candidate: {e}")) +} + +/// Album ids whose track rows omit both item and parent art, excluding recent +/// positive *and* negative metadata checks. +pub fn plex_cache_album_artwork_candidates( + server_id: Option, +) -> Result, String> { + let conn = open_plex_cache_db()?; + let server_id = server_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default"); + let stale_before = now_epoch_secs() + .saturating_sub(i64::try_from(PLEX_ALBUM_ARTWORK_MAX_AGE_SECS).unwrap_or(i64::MAX)); + album_artwork_candidates_in(&conn, server_id, stale_before) +} + +fn save_album_artwork_in( + conn: &mut Connection, + server_id: &str, + rows: &[(String, Option)], + checked_at: i64, +) -> Result { + if rows.is_empty() { + return Ok(0); + } + let tx = conn + .transaction() + .map_err(|e| format!("Failed to start Plex album artwork transaction: {e}"))?; + let mut saved = 0usize; + for (parent_rating_key, artwork_path) in rows { + saved = saved.saturating_add( + tx.execute( + "INSERT INTO plex_cache_album_artwork( + server_id,parent_rating_key,artwork_path,checked_at + ) VALUES (?1,?2,?3,?4) + ON CONFLICT(server_id,parent_rating_key) DO UPDATE SET + artwork_path=excluded.artwork_path, + checked_at=excluded.checked_at", + params![server_id, parent_rating_key, artwork_path, checked_at], + ) + .map_err(|e| format!("Failed to save Plex album artwork: {e}"))?, + ); + } + tx.commit() + .map_err(|e| format!("Failed to commit Plex album artwork: {e}"))?; + Ok(saved) +} + +/// Persist successful metadata responses. `None` is intentional negative +/// caching, not an error: it prevents a genuinely coverless album from being +/// fetched on every application start. +pub fn plex_cache_save_album_artwork( + server_id: Option, + rows: Vec<(String, Option)>, +) -> Result { + let mut conn = open_plex_cache_db()?; + let server_id = server_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default"); + save_album_artwork_in(&mut conn, server_id, &rows, now_epoch_secs()) +} + pub fn plex_cache_get_tracks( section_key: Option, limit: Option, @@ -1036,15 +1547,15 @@ pub fn plex_cache_get_tracks( if let Some(section) = section_key { let mut stmt = conn - .prepare( + .prepare(&format!( "SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container, codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number, - year, genre, parent_rating_key + year, genre, genres_json, parent_rating_key, {EFFECTIVE_COLLECTION_ARTWORK_SQL}, recording_mbid FROM plex_cache_tracks WHERE section_key = ?1 ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, disc_number, track_number, title COLLATE NOCASE LIMIT ?2", - ) + )) .map_err(|e| format!("Failed to prepare Plex cache tracks query: {}", e))?; let rows = stmt .query_map(params![section, max], |row| { @@ -1057,8 +1568,9 @@ pub fn plex_cache_get_tracks( album: row .get::<_, Option>(3)? .map(|v| decode_xml_entities(v.trim())), - duration_ms: row.get(4)?, + duration_ms: row.get::<_, Option>(4)?.map(|v| v as u64), artwork_path: row.get(5)?, + collection_artwork_path: row.get(19)?, part_key: row.get(6)?, container: row.get(7)?, codec: row.get(8)?, @@ -1069,8 +1581,13 @@ pub fn plex_cache_get_tracks( track_number: row.get(13)?, disc_number: row.get(14)?, year: row.get::<_, Option>(15)?.map(|v| v as u32), + genres: plex_genres_from_json( + row.get::<_, Option>(17)?.as_deref(), + row.get::<_, Option>(16)?.as_deref(), + ), genre: row.get(16)?, - parent_rating_key: row.get(17)?, + parent_rating_key: row.get(18)?, + recording_mbid: row.get(20)?, }) }) .map_err(|e| format!("Failed to query Plex cache tracks: {}", e))?; @@ -1079,14 +1596,14 @@ pub fn plex_cache_get_tracks( } } else { let mut stmt = conn - .prepare( + .prepare(&format!( "SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container, codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number, - year, genre, parent_rating_key + year, genre, genres_json, parent_rating_key, {EFFECTIVE_COLLECTION_ARTWORK_SQL}, recording_mbid FROM plex_cache_tracks ORDER BY updated_at DESC LIMIT ?1", - ) + )) .map_err(|e| format!("Failed to prepare Plex cache tracks query: {}", e))?; let rows = stmt .query_map(params![max], |row| { @@ -1099,8 +1616,9 @@ pub fn plex_cache_get_tracks( album: row .get::<_, Option>(3)? .map(|v| decode_xml_entities(v.trim())), - duration_ms: row.get(4)?, + duration_ms: row.get::<_, Option>(4)?.map(|v| v as u64), artwork_path: row.get(5)?, + collection_artwork_path: row.get(19)?, part_key: row.get(6)?, container: row.get(7)?, codec: row.get(8)?, @@ -1111,8 +1629,13 @@ pub fn plex_cache_get_tracks( track_number: row.get(13)?, disc_number: row.get(14)?, year: row.get::<_, Option>(15)?.map(|v| v as u32), + genres: plex_genres_from_json( + row.get::<_, Option>(17)?.as_deref(), + row.get::<_, Option>(16)?.as_deref(), + ), genre: row.get(16)?, - parent_rating_key: row.get(17)?, + parent_rating_key: row.get(18)?, + recording_mbid: row.get(20)?, }) }) .map_err(|e| format!("Failed to query Plex cache tracks: {}", e))?; @@ -1143,10 +1666,9 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result Result>(3)? .map(|v| decode_xml_entities(v.trim())), - duration_ms: row.get(4)?, + duration_ms: row.get::<_, Option>(4)?.map(|v| v as u64), artwork_path: row.get(5)?, + collection_artwork_path: row.get(19)?, part_key: row.get(6)?, container: row.get(7)?, codec: row.get(8)?, @@ -1175,8 +1698,13 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result>(15)?.map(|v| v as u32), + genres: plex_genres_from_json( + row.get::<_, Option>(17)?.as_deref(), + row.get::<_, Option>(16)?.as_deref(), + ), genre: row.get(16)?, - parent_rating_key: row.get(17)?, + parent_rating_key: row.get(18)?, + recording_mbid: row.get(20)?, }) }) .map_err(|e| format!("Failed to query Plex cache tracks by keys: {}", e))?; @@ -1190,11 +1718,17 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result Result, String> { let conn = open_plex_cache_db()?; let mut stmt = conn - .prepare( - "SELECT artist, album, duration_ms, artwork_path, container, sampling_rate_hz, bit_depth, year, genre + .prepare(&format!( + "SELECT artist, album, duration_ms, artwork_path, {EFFECTIVE_COLLECTION_ARTWORK_SQL}, + container, sampling_rate_hz, bit_depth, year, genre FROM plex_cache_tracks", - ) - .map_err(|e| format!("Failed to prepare Plex cache album aggregation query: {}", e))?; + )) + .map_err(|e| { + format!( + "Failed to prepare Plex cache album aggregation query: {}", + e + ) + })?; let rows = stmt .query_map([], |row| { @@ -1204,10 +1738,11 @@ pub fn plex_cache_get_albums() -> Result, String> { row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, + row.get::<_, Option>(5)?, row.get::<_, Option>(6)?, row.get::<_, Option>(7)?, - row.get::<_, Option>(8)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, )) }) .map_err(|e| { @@ -1225,6 +1760,7 @@ pub fn plex_cache_get_albums() -> Result, String> { album_opt, duration_ms_opt, artwork_path, + collection_artwork_path, container, sampling_rate_hz_opt, bit_depth_opt, @@ -1241,6 +1777,9 @@ pub fn plex_cache_get_albums() -> Result, String> { .unwrap_or_else(|| "Unknown Album".to_string()); let album = normalize_album_title(Some(&artist), &album_raw); let album_key = plex_album_key(&artist, &album); + let representative_artwork = collection_artwork_path + .filter(|path| !path.is_empty()) + .or_else(|| artwork_path.clone().filter(|path| !path.is_empty())); let entry = grouped .entry(album_key.clone()) @@ -1248,7 +1787,7 @@ pub fn plex_cache_get_albums() -> Result, String> { id: album_key.clone(), title: album.clone(), artist: artist.clone(), - artwork_path: artwork_path.clone(), + artwork_path: representative_artwork.clone(), track_count: 0, total_duration_secs: 0, format: container.clone().unwrap_or_else(|| "flac".to_string()), @@ -1271,8 +1810,8 @@ pub fn plex_cache_get_albums() -> Result, String> { if let Some(duration_ms) = duration_ms_opt { entry.total_duration_secs += (duration_ms as u64) / 1000; } - if entry.artwork_path.is_none() && artwork_path.is_some() { - entry.artwork_path = artwork_path; + if entry.artwork_path.is_none() { + entry.artwork_path = representative_artwork; } if let Some(container_value) = container { if entry.format == "flac" || entry.format.is_empty() { @@ -1334,8 +1873,16 @@ pub fn plex_cache_count_tracks() -> Result { pub fn plex_cache_get_artists() -> Result, String> { let conn = open_plex_cache_db()?; let mut stmt = conn - .prepare("SELECT artist, album, artwork_path FROM plex_cache_tracks") - .map_err(|e| format!("Failed to prepare Plex cache artist aggregation query: {}", e))?; + .prepare(&format!( + "SELECT artist, album, artwork_path, {EFFECTIVE_COLLECTION_ARTWORK_SQL} + FROM plex_cache_tracks" + )) + .map_err(|e| { + format!( + "Failed to prepare Plex cache artist aggregation query: {}", + e + ) + })?; let rows = stmt .query_map([], |row| { @@ -1343,9 +1890,15 @@ pub fn plex_cache_get_artists() -> Result, String> { row.get::<_, Option>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, )) }) - .map_err(|e| format!("Failed to query Plex cache tracks for artist aggregation: {}", e))?; + .map_err(|e| { + format!( + "Failed to query Plex cache tracks for artist aggregation: {}", + e + ) + })?; struct Acc { name: String, @@ -1358,7 +1911,7 @@ pub fn plex_cache_get_artists() -> Result, String> { let mut grouped: HashMap = HashMap::new(); for row in rows { - let (artist_opt, album_opt, artwork_path) = + let (artist_opt, album_opt, artwork_path, collection_artwork_path) = row.map_err(|e| format!("Failed to read Plex cache artist row: {}", e))?; let artist = artist_opt .map(|v| decode_xml_entities(v.trim())) @@ -1380,7 +1933,10 @@ pub fn plex_cache_get_artists() -> Result, String> { entry.albums.insert(album_key); entry.track_count += 1; if entry.artwork_path.is_none() { - if let Some(p) = artwork_path.filter(|p| !p.is_empty()) { + if let Some(p) = artwork_path + .filter(|p| !p.is_empty()) + .or_else(|| collection_artwork_path.filter(|p| !p.is_empty())) + { entry.artwork_path = Some(p); } } @@ -1401,18 +1957,35 @@ pub fn plex_cache_get_artists() -> Result, String> { pub fn plex_cache_get_album_tracks(album_key: String) -> Result, String> { let conn = open_plex_cache_db()?; + plex_cache_get_album_tracks_in(&conn, &album_key) +} + +fn plex_cache_get_album_tracks_in( + conn: &Connection, + album_key: &str, +) -> Result, String> { + // The derived catalog carries Plex's source-native parentRatingKey so an + // edition can resolve directly. Legacy cards keep using the content hash. + // Supporting both here preserves the old reader while avoiding a scan of + // every cached album for the native F1 detail route. + let (column, key) = album_key + .strip_prefix("plex:album:") + .map(|key| ("parent_rating_key", key)) + .unwrap_or(("album_key", album_key)); + let sql = format!( + "SELECT rating_key, title, artist, album, duration_ms, container, bit_depth, + sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key, + {EFFECTIVE_COLLECTION_ARTWORK_SQL} + FROM plex_cache_tracks + WHERE {column} = ?1 + ORDER BY disc_number, track_number, title COLLATE NOCASE" + ); let mut stmt = conn - .prepare( - "SELECT rating_key, title, artist, album, duration_ms, container, bit_depth, - sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key - FROM plex_cache_tracks - WHERE album_key = ?1 - ORDER BY disc_number, track_number, title COLLATE NOCASE", - ) + .prepare(&sql) .map_err(|e| format!("Failed to prepare Plex cache album tracks query: {}", e))?; let rows = stmt - .query_map(params![album_key], |row| { + .query_map(params![key], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -1426,6 +1999,7 @@ pub fn plex_cache_get_album_tracks(album_key: String) -> Result>(9)?, row.get::<_, Option>(10)?, row.get::<_, Option>(11)?, + row.get::<_, Option>(12)?, )) }) .map_err(|e| format!("Failed to query Plex cache album tracks: {}", e))?; @@ -1445,6 +2019,7 @@ pub fn plex_cache_get_album_tracks(album_key: String) -> Result Result Result, +) -> Result, String> { + plex_cache_search_tracks_page(query, 0, limit.unwrap_or(u32::MAX) as u64, "default") +} + +/// One deterministic Plex candidate page for a cross-source Tracks merge. +/// `None` on the compatibility helper now means unbounded instead of silently +/// hiding every track after row 5,000. +pub fn plex_cache_search_tracks_page( + query: String, + offset: u64, + limit: u64, + sort: &str, +) -> Result, String> { + plex_cache_search_tracks_page_filtered(query, offset, limit, sort, &[], false, &[]) +} + +pub fn plex_cache_search_tracks_page_filtered( + query: String, + offset: u64, + limit: u64, + sort: &str, + formats: &[String], + other_formats: bool, + quality_tiers: &[String], ) -> Result, String> { let conn = open_plex_cache_db()?; - let max = limit.unwrap_or(5000) as i64; + plex_cache_search_tracks_page_filtered_in( + &conn, + &query, + offset, + limit, + sort, + formats, + other_formats, + quality_tiers, + ) +} + +#[cfg(test)] +fn plex_cache_search_tracks_page_in( + conn: &Connection, + query: &str, + offset: u64, + limit: u64, + sort: &str, +) -> Result, String> { + plex_cache_search_tracks_page_filtered_in(conn, query, offset, limit, sort, &[], false, &[]) +} + +fn plex_cache_search_tracks_page_filtered_in( + conn: &Connection, + query: &str, + offset: u64, + limit: u64, + sort: &str, + formats: &[String], + other_formats: bool, + quality_tiers: &[String], +) -> Result, String> { + let order = match sort { + "title-asc" => "sort_title COLLATE NOCASE, sort_artist COLLATE NOCASE, rating_key", + "title-desc" => "sort_title COLLATE NOCASE DESC, sort_artist COLLATE NOCASE, rating_key", + "artist-asc" => "sort_artist COLLATE NOCASE, sort_album COLLATE NOCASE, disc_number, track_number, rating_key", + "artist-desc" => "sort_artist COLLATE NOCASE DESC, sort_album COLLATE NOCASE, disc_number, track_number, rating_key", + "group-artist" => "sort_artist COLLATE NOCASE, sort_album COLLATE NOCASE, sort_title COLLATE NOCASE, rating_key", + "year-desc" => "year IS NULL, year DESC, sort_album COLLATE NOCASE, disc_number, track_number, rating_key", + "year-asc" => "year IS NULL, year ASC, sort_album COLLATE NOCASE, disc_number, track_number, rating_key", + "added-desc" => "sort_album COLLATE NOCASE, disc_number, track_number, rating_key", + _ => "sort_album COLLATE NOCASE, sort_artist COLLATE NOCASE, disc_number, track_number, sort_title COLLATE NOCASE, rating_key", + }; + let media_filter = cached_track_media_filter_sql( + "container", + "bit_depth", + "sampling_rate_hz", + formats, + other_formats, + quality_tiers, + ); let needle = format!("%{}%", query.to_lowercase()); let mut stmt = conn - .prepare( + .prepare(&format!( "SELECT rating_key, title, artist, album, duration_ms, container, bit_depth, - sampling_rate_hz, artwork_path, track_number, disc_number + sampling_rate_hz, artwork_path, track_number, disc_number, year, + {EFFECTIVE_COLLECTION_ARTWORK_SQL}, + TRIM(title) AS sort_title, + COALESCE(NULLIF(TRIM(artist), ''), 'Unknown Artist') AS sort_artist, + CASE + WHEN TRIM(COALESCE(artist, '')) != '' + AND SUBSTR(TRIM(COALESCE(album, '')), 1, LENGTH(TRIM(artist)) + 3) + = TRIM(artist) || ' - ' + THEN TRIM(SUBSTR(TRIM(album), LENGTH(TRIM(artist)) + 4)) + WHEN TRIM(COALESCE(artist, '')) != '' + AND SUBSTR(TRIM(COALESCE(album, '')), 1, LENGTH(TRIM(artist)) + 3) + = TRIM(artist) || ' — ' + THEN TRIM(SUBSTR(TRIM(album), LENGTH(TRIM(artist)) + 4)) + WHEN TRIM(COALESCE(artist, '')) != '' + AND SUBSTR(TRIM(COALESCE(album, '')), 1, LENGTH(TRIM(artist)) + 3) + = TRIM(artist) || ' – ' + THEN TRIM(SUBSTR(TRIM(album), LENGTH(TRIM(artist)) + 4)) + WHEN TRIM(COALESCE(artist, '')) != '' + AND SUBSTR(TRIM(COALESCE(album, '')), 1, LENGTH(TRIM(artist)) + 2) + = TRIM(artist) || ': ' + THEN TRIM(SUBSTR(TRIM(album), LENGTH(TRIM(artist)) + 3)) + ELSE COALESCE(NULLIF(TRIM(album), ''), 'Unknown Album') + END AS sort_album FROM plex_cache_tracks - WHERE ?1 = '' OR + WHERE (?1 = '' OR lower(title) LIKE ?2 OR lower(COALESCE(artist, '')) LIKE ?2 OR - lower(COALESCE(album, '')) LIKE ?2 - ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, disc_number, track_number, title COLLATE NOCASE - LIMIT ?3", - ) + lower(COALESCE(album, '')) LIKE ?2) \ + {media_filter} + ORDER BY {order} + LIMIT ?3 OFFSET ?4" + )) .map_err(|e| format!("Failed to prepare Plex cache search query: {}", e))?; let rows = stmt - .query_map(params![query.trim(), needle, max], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, Option>(6)?, - row.get::<_, Option>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, Option>(9)?, - row.get::<_, Option>(10)?, - )) - }) + .query_map( + params![query.trim(), needle, limit as i64, offset as i64], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, Option>(11)?, + row.get::<_, Option>(12)?, + )) + }, + ) .map_err(|e| format!("Failed to query Plex cache search tracks: {}", e))?; let mut tracks = Vec::new(); @@ -1529,6 +2209,8 @@ pub fn plex_cache_search_tracks( artwork_path, track_number_opt, disc_number_opt, + year_opt, + collection_artwork_path, ) = row.map_err(|e| format!("Failed to read Plex cache search row: {}", e))?; let artist = artist_opt .map(|v| decode_xml_entities(v.trim())) @@ -1550,10 +2232,12 @@ pub fn plex_cache_search_tracks( bit_depth: bit_depth_opt.map(|v| v as u32), sample_rate: sampling_rate_opt.map(|v| v as u32).unwrap_or(44100), artwork_path, + collection_artwork_path, source: "plex".to_string(), album_key: plex_album_key(&artist, &album), track_number: track_number_opt.map(|v| v as u32), disc_number: disc_number_opt.map(|v| v as u32), + year: year_opt.map(|v| v as u32), // Flat search list — version grouping doesn't apply here. parent_rating_key: None, }); @@ -1561,6 +2245,63 @@ pub fn plex_cache_search_tracks( Ok(tracks) } +fn cached_track_media_filter_sql( + format_col: &str, + depth_col: &str, + rate_col: &str, + formats: &[String], + other_formats: bool, + quality_tiers: &[String], +) -> String { + let known = ["flac", "alac", "ape", "wav", "mp3", "aac"]; + let selected = known + .iter() + .copied() + .filter(|value| { + formats + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(value)) + }) + .collect::>(); + let mut clauses = Vec::new(); + if !selected.is_empty() || other_formats { + let mut values = selected + .into_iter() + .map(|value| format!("LOWER(COALESCE({format_col},''))='{value}'")) + .collect::>(); + if other_formats { + values.push(format!( + "LOWER(COALESCE({format_col},'')) NOT IN ('flac','alac','ape','wav','mp3','aac')" + )); + } + clauses.push(format!("AND ({})", values.join(" OR "))); + } + let hires = quality_tiers.iter().any(|value| value == "hires"); + let cd = quality_tiers.iter().any(|value| value == "cd"); + let lossy = quality_tiers.iter().any(|value| value == "lossy"); + if hires || cd || lossy { + let khz = format!( + "CASE WHEN COALESCE({rate_col},0)>=1000 THEN COALESCE({rate_col},0)/1000.0 ELSE COALESCE({rate_col},0) END" + ); + let mut values = Vec::new(); + if hires { + values.push(format!( + "(LOWER(COALESCE({format_col},'')) IN ('dsd','dsf','dff') OR COALESCE({depth_col},0)>=24)" + )); + } + if cd { + values.push(format!( + "(LOWER(COALESCE({format_col},'')) NOT IN ('mp3','dsd','dsf','dff') AND (({depth_col} IS NOT NULL AND {depth_col}<24) OR ({depth_col} IS NULL AND {khz}>=44.1)))" + )); + } + if lossy { + values.push(format!("LOWER(COALESCE({format_col},''))='mp3'")); + } + clauses.push(format!("AND ({})", values.join(" OR "))); + } + clauses.join(" ") +} + /// Bulk by-rating-key lookup in the `PlexCachedTrack` shape (synthetic /// playback id + normalized album + content-hash album key — the same /// row mapping `plex_cache_search_tracks` produces, so a row resolved @@ -1581,10 +2322,10 @@ pub fn plex_cache_get_cached_tracks_by_keys( .join(","); let sql = format!( "SELECT rating_key, title, artist, album, duration_ms, container, bit_depth, - sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key + sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key, + {EFFECTIVE_COLLECTION_ARTWORK_SQL} FROM plex_cache_tracks - WHERE rating_key IN ({})", - placeholders + WHERE rating_key IN ({placeholders})" ); let mut stmt = conn .prepare(&sql) @@ -1604,6 +2345,7 @@ pub fn plex_cache_get_cached_tracks_by_keys( row.get::<_, Option>(9)?, row.get::<_, Option>(10)?, row.get::<_, Option>(11)?, + row.get::<_, Option>(12)?, )) }) .map_err(|e| format!("Failed to query Plex cache tracks by keys: {}", e))?; @@ -1622,6 +2364,7 @@ pub fn plex_cache_get_cached_tracks_by_keys( track_number_opt, disc_number_opt, parent_rating_key, + collection_artwork_path, ) = row.map_err(|e| format!("Failed to read Plex cache by-keys row: {}", e))?; let artist = artist_opt .map(|v| decode_xml_entities(v.trim())) @@ -1643,16 +2386,380 @@ pub fn plex_cache_get_cached_tracks_by_keys( bit_depth: bit_depth_opt.map(|v| v as u32), sample_rate: sampling_rate_opt.map(|v| v as u32).unwrap_or(44100), artwork_path, + collection_artwork_path, source: "plex".to_string(), album_key: plex_album_key(&artist, &album), track_number: track_number_opt.map(|v| v as u32), disc_number: disc_number_opt.map(|v| v as u32), + year: None, parent_rating_key, }); } Ok(tracks) } +#[derive(Debug)] +struct StoredSectionSync { + state: PlexSectionSyncState, + server_id: Option, + status: String, +} + +fn nonnegative_u64(value: i64) -> u64 { + value.max(0) as u64 +} + +fn load_section_sync_in( + conn: &Connection, + section_key: &str, +) -> Result, String> { + conn.query_row( + "SELECT server_id,generation,next_start,total_size,observed_rows,status + FROM plex_cache_section_sync WHERE section_key=?1", + params![section_key], + |row| { + Ok(StoredSectionSync { + state: PlexSectionSyncState { + section_key: section_key.to_string(), + generation: nonnegative_u64(row.get(1)?), + next_start: nonnegative_u64(row.get(2)?), + total_size: row.get::<_, Option>(3)?.map(nonnegative_u64), + observed_rows: nonnegative_u64(row.get(4)?), + resumed: false, + }, + server_id: row.get(0)?, + status: row.get(5)?, + }) + }, + ) + .optional() + .map_err(|e| format!("Failed to read Plex section sync state: {}", e)) +} + +fn begin_section_sync_in( + conn: &mut Connection, + server_id: Option, + section_key: &str, +) -> Result { + let tx = conn + .transaction() + .map_err(|e| format!("Failed to start Plex section sync transaction: {}", e))?; + let previous = load_section_sync_in(&tx, section_key)?; + let resumable = previous.as_ref().is_some_and(|stored| { + stored.server_id == server_id + && matches!( + stored.status.as_str(), + "running" | "interrupted" | "cancelled" + ) + }); + if resumable { + let mut state = previous.expect("checked above").state; + state.resumed = true; + tx.execute( + "UPDATE plex_cache_section_sync + SET status='running',updated_at=?2 WHERE section_key=?1", + params![section_key, now_epoch_secs()], + ) + .map_err(|e| format!("Failed to resume Plex section sync: {}", e))?; + tx.commit() + .map_err(|e| format!("Failed to commit Plex section resume: {}", e))?; + return Ok(state); + } + + let generation = previous + .map(|stored| stored.state.generation) + .unwrap_or(0) + .saturating_add(1) + .min(i64::MAX as u64) + .max(1); + tx.execute( + "INSERT INTO plex_cache_section_sync( + section_key,server_id,generation,next_start,total_size,observed_rows,status,updated_at + ) VALUES (?1,?2,?3,0,NULL,0,'running',?4) + ON CONFLICT(section_key) DO UPDATE SET + server_id=excluded.server_id,generation=excluded.generation,next_start=0, + total_size=NULL,observed_rows=0,status='running',updated_at=excluded.updated_at", + params![section_key, server_id, generation as i64, now_epoch_secs()], + ) + .map_err(|e| format!("Failed to begin Plex section sync: {}", e))?; + tx.commit() + .map_err(|e| format!("Failed to commit Plex section start: {}", e))?; + Ok(PlexSectionSyncState { + section_key: section_key.to_string(), + generation, + next_start: 0, + total_size: None, + observed_rows: 0, + resumed: false, + }) +} + +pub fn plex_cache_begin_section_sync( + server_id: Option, + section_key: String, +) -> Result { + let mut conn = open_plex_cache_db()?; + begin_section_sync_in(&mut conn, server_id, §ion_key) +} + +fn apply_section_page_in( + conn: &mut Connection, + section_key: &str, + generation: u64, + page: &PlexTrackPage, +) -> Result { + if page.response_size != page.tracks.len() as u64 { + return Err("Plex page track count does not match its response size".to_string()); + } + let tx = conn + .transaction() + .map_err(|e| format!("Failed to start Plex page transaction: {}", e))?; + let stored = load_section_sync_in(&tx, section_key)? + .ok_or_else(|| "Plex section sync state is missing".to_string())?; + if stored.status != "running" || stored.state.generation != generation { + return Err("Plex section generation is no longer current".to_string()); + } + if stored.state.next_start != page.offset { + return Err(format!( + "Plex section checkpoint mismatch: expected {}, received {}", + stored.state.next_start, page.offset + )); + } + if stored + .state + .total_size + .is_some_and(|total| total != page.total_size) + { + return Err("Plex section totalSize changed during sync".to_string()); + } + + let now = now_epoch_secs(); + let generation_i64 = generation.min(i64::MAX as u64) as i64; + let mut insert = tx + .prepare_cached( + "INSERT INTO plex_cache_tracks( + rating_key,section_key,server_id,title,artist,album,duration_ms,artwork_path, + collection_artwork_path,part_key,container,codec,channels,bitrate_kbps, + sampling_rate_hz,bit_depth, + track_number,disc_number,album_key,year,genre,genres_json,parent_rating_key,updated_at, + sync_generation,recording_mbid + ) VALUES ( + ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18, + ?19,?20,?21,?22,?23,?24,?25,?26 + ) + ON CONFLICT(rating_key) DO UPDATE SET + recording_mbid=COALESCE(excluded.recording_mbid,plex_cache_tracks.recording_mbid), + section_key=excluded.section_key,server_id=excluded.server_id, + title=excluded.title,artist=excluded.artist,album=excluded.album, + duration_ms=excluded.duration_ms,artwork_path=excluded.artwork_path, + collection_artwork_path=excluded.collection_artwork_path, + part_key=excluded.part_key, + container=COALESCE(excluded.container,plex_cache_tracks.container), + codec=COALESCE(excluded.codec,plex_cache_tracks.codec), + channels=COALESCE(excluded.channels,plex_cache_tracks.channels), + bitrate_kbps=COALESCE(excluded.bitrate_kbps,plex_cache_tracks.bitrate_kbps), + sampling_rate_hz=COALESCE( + excluded.sampling_rate_hz,plex_cache_tracks.sampling_rate_hz + ), + bit_depth=COALESCE(excluded.bit_depth,plex_cache_tracks.bit_depth), + track_number=excluded.track_number,disc_number=excluded.disc_number, + album_key=excluded.album_key,year=excluded.year,genre=excluded.genre, + genres_json=excluded.genres_json, + parent_rating_key=excluded.parent_rating_key,updated_at=excluded.updated_at, + sync_generation=excluded.sync_generation", + ) + .map_err(|e| format!("Failed to prepare Plex page upsert: {}", e))?; + for track in &page.tracks { + let genres_json = plex_genres_json(track); + let artist = track + .artist + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("Unknown Artist"); + let album_raw = track + .album + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("Unknown Album"); + let album = normalize_album_title(Some(artist), album_raw); + insert + .execute(params![ + track.rating_key, + section_key, + stored.server_id, + track.title, + track.artist, + track.album, + track.duration_ms.map(|value| value as i64), + track.artwork_path, + track.collection_artwork_path, + track.part_key, + track.container, + track.codec, + track.channels.map(|value| value as i64), + track.bitrate_kbps.map(|value| value as i64), + track.sampling_rate_hz.map(|value| value as i64), + track.bit_depth.map(|value| value as i64), + track.track_number.map(|value| value as i64), + track.disc_number.map(|value| value as i64), + plex_album_key(artist, &album), + track.year.map(|value| value as i64), + track.genre, + genres_json, + track.parent_rating_key, + now, + generation_i64, + track.recording_mbid.clone(), + ]) + .map_err(|e| format!("Failed to upsert Plex page track: {}", e))?; + } + drop(insert); + + let observed_rows = stored + .state + .observed_rows + .saturating_add(page.tracks.len() as u64); + let actual: i64 = tx + .query_row( + "SELECT COUNT(*) FROM plex_cache_tracks + WHERE section_key=?1 AND sync_generation=?2", + params![section_key, generation_i64], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to verify Plex page identities: {}", e))?; + if nonnegative_u64(actual) != observed_rows { + return Err("Plex section repeated a ratingKey across pages".to_string()); + } + let next_start = page.next_start(); + tx.execute( + "UPDATE plex_cache_section_sync + SET next_start=?3,total_size=?4,observed_rows=?5,status='running',updated_at=?6 + WHERE section_key=?1 AND generation=?2", + params![ + section_key, + generation_i64, + next_start.min(i64::MAX as u64) as i64, + page.total_size.min(i64::MAX as u64) as i64, + observed_rows.min(i64::MAX as u64) as i64, + now, + ], + ) + .map_err(|e| format!("Failed to checkpoint Plex page: {}", e))?; + tx.commit() + .map_err(|e| format!("Failed to commit Plex page: {}", e))?; + Ok(PlexSectionSyncState { + section_key: section_key.to_string(), + generation, + next_start, + total_size: Some(page.total_size), + observed_rows, + resumed: stored.state.resumed, + }) +} + +pub fn plex_cache_apply_section_page( + section_key: String, + generation: u64, + page: PlexTrackPage, +) -> Result { + let mut conn = open_plex_cache_db()?; + apply_section_page_in(&mut conn, §ion_key, generation, &page) +} + +fn finish_section_sync_in( + conn: &mut Connection, + section_key: &str, + generation: u64, +) -> Result { + let tx = conn + .transaction() + .map_err(|e| format!("Failed to start Plex section completion: {}", e))?; + let stored = load_section_sync_in(&tx, section_key)? + .ok_or_else(|| "Plex section sync state is missing".to_string())?; + let total = stored + .state + .total_size + .ok_or_else(|| "Plex section totalSize is unknown".to_string())?; + if stored.status != "running" + || stored.state.generation != generation + || stored.state.next_start != total + || stored.state.observed_rows != total + { + return Err("Plex section is incomplete and cannot authorize prune".to_string()); + } + let generation_i64 = generation.min(i64::MAX as u64) as i64; + let actual: i64 = tx + .query_row( + "SELECT COUNT(*) FROM plex_cache_tracks + WHERE section_key=?1 AND sync_generation=?2", + params![section_key, generation_i64], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to verify Plex section completion: {}", e))?; + if nonnegative_u64(actual) != total { + return Err("Plex section identity count does not match totalSize".to_string()); + } + let pruned = tx + .execute( + "DELETE FROM plex_cache_tracks + WHERE section_key=?1 AND sync_generation<>?2", + params![section_key, generation_i64], + ) + .map_err(|e| format!("Failed to prune completed Plex section: {}", e))?; + tx.execute( + "UPDATE plex_cache_section_sync + SET status='complete',updated_at=?3 WHERE section_key=?1 AND generation=?2", + params![section_key, generation_i64, now_epoch_secs()], + ) + .map_err(|e| format!("Failed to complete Plex section sync: {}", e))?; + tx.commit() + .map_err(|e| format!("Failed to commit Plex section completion: {}", e))?; + Ok(pruned) +} + +pub fn plex_cache_finish_section_sync( + section_key: String, + generation: u64, +) -> Result { + let mut conn = open_plex_cache_db()?; + finish_section_sync_in(&mut conn, §ion_key, generation) +} + +fn mark_section_sync_in( + conn: &Connection, + section_key: &str, + generation: u64, + status: &str, +) -> Result<(), String> { + conn.execute( + "UPDATE plex_cache_section_sync SET status=?3,updated_at=?4 + WHERE section_key=?1 AND generation=?2 AND status='running'", + params![ + section_key, + generation.min(i64::MAX as u64) as i64, + status, + now_epoch_secs(), + ], + ) + .map(|_| ()) + .map_err(|e| format!("Failed to mark Plex section sync: {}", e)) +} + +pub fn plex_cache_interrupt_section_sync( + section_key: String, + generation: u64, +) -> Result<(), String> { + let conn = open_plex_cache_db()?; + mark_section_sync_in(&conn, §ion_key, generation, "interrupted") +} + +pub fn plex_cache_restart_section_sync(section_key: String, generation: u64) -> Result<(), String> { + let conn = open_plex_cache_db()?; + mark_section_sync_in(&conn, §ion_key, generation, "restart") +} + pub fn plex_cache_save_tracks( server_id: Option, section_key: String, @@ -1734,9 +2841,11 @@ pub fn plex_cache_save_tracks( tx.execute( "INSERT INTO plex_cache_tracks (rating_key, section_key, server_id, title, artist, album, duration_ms, artwork_path, - part_key, container, codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, - track_number, disc_number, album_key, year, genre, parent_rating_key, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)", + collection_artwork_path, part_key, container, codec, channels, bitrate_kbps, + sampling_rate_hz, bit_depth, + track_number, disc_number, album_key, year, genre, genres_json, parent_rating_key, updated_at, + recording_mbid) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)", params![ track.rating_key, section_key, @@ -1746,6 +2855,7 @@ pub fn plex_cache_save_tracks( track.album, track.duration_ms.map(|v| v as i64), track.artwork_path, + track.collection_artwork_path, track.part_key, container, track.codec, @@ -1758,8 +2868,10 @@ pub fn plex_cache_save_tracks( track_album_key, track.year.map(|v| v as i64), track.genre.clone(), + plex_genres_json(track), track.parent_rating_key.clone(), now, + track.recording_mbid.clone(), ], ) .map_err(|e| format!("Failed to insert Plex cache track: {}", e))?; @@ -1839,11 +2951,20 @@ pub fn plex_cache_get_tracks_needing_hydration(limit: Option) -> Result Result<(), String> { - let conn = open_plex_cache_db()?; - conn.execute("DELETE FROM plex_cache_tracks", []) + let mut conn = open_plex_cache_db()?; + let tx = conn + .transaction() + .map_err(|e| format!("Failed to start Plex cache clear: {}", e))?; + tx.execute("DELETE FROM plex_cache_tracks", []) .map_err(|e| format!("Failed to clear Plex cache tracks: {}", e))?; - conn.execute("DELETE FROM plex_cache_sections", []) + tx.execute("DELETE FROM plex_cache_sections", []) .map_err(|e| format!("Failed to clear Plex cache sections: {}", e))?; + tx.execute("DELETE FROM plex_cache_section_sync", []) + .map_err(|e| format!("Failed to clear Plex sync state: {}", e))?; + tx.execute("DELETE FROM plex_cache_album_artwork", []) + .map_err(|e| format!("Failed to clear Plex album artwork cache: {}", e))?; + tx.commit() + .map_err(|e| format!("Failed to commit Plex cache clear: {}", e))?; Ok(()) } @@ -1856,18 +2977,48 @@ pub fn plex_cache_clear() -> Result<(), String> { /// (and `save_tracks` then re-applies its per-section carry-over). An empty /// `keep` removes all tracks (nothing selected). Returns rows deleted. pub fn plex_cache_prune_sections(keep: &[String]) -> Result { - let conn = open_plex_cache_db()?; + let mut conn = open_plex_cache_db()?; + let tx = conn + .transaction() + .map_err(|e| format!("Failed to start Plex section selection prune: {}", e))?; if keep.is_empty() { - return conn + let removed = tx .execute("DELETE FROM plex_cache_tracks", []) - .map_err(|e| format!("Failed to prune Plex cache tracks: {}", e)); + .map_err(|e| format!("Failed to prune Plex cache tracks: {}", e))?; + tx.execute("DELETE FROM plex_cache_section_sync", []) + .map_err(|e| format!("Failed to prune Plex sync state: {}", e))?; + tx.execute("DELETE FROM plex_cache_album_artwork", []) + .map_err(|e| format!("Failed to prune Plex album artwork cache: {}", e))?; + tx.commit() + .map_err(|e| format!("Failed to commit Plex section selection prune: {}", e))?; + return Ok(removed); } let placeholders = keep.iter().map(|_| "?").collect::>().join(","); - let sql = format!("DELETE FROM plex_cache_tracks WHERE section_key NOT IN ({placeholders})"); + let track_sql = + format!("DELETE FROM plex_cache_tracks WHERE section_key NOT IN ({placeholders})"); + let state_sql = + format!("DELETE FROM plex_cache_section_sync WHERE section_key NOT IN ({placeholders})"); let params: Vec<&dyn rusqlite::ToSql> = keep.iter().map(|k| k as &dyn rusqlite::ToSql).collect(); - conn.execute(&sql, params.as_slice()) - .map_err(|e| format!("Failed to prune Plex cache tracks: {}", e)) + let removed = tx + .execute(&track_sql, params.as_slice()) + .map_err(|e| format!("Failed to prune Plex cache tracks: {}", e))?; + tx.execute(&state_sql, params.as_slice()) + .map_err(|e| format!("Failed to prune Plex sync state: {}", e))?; + tx.execute( + "DELETE FROM plex_cache_album_artwork + WHERE NOT EXISTS ( + SELECT 1 FROM plex_cache_tracks tracks + WHERE COALESCE(NULLIF(tracks.server_id,''),'default') = + plex_cache_album_artwork.server_id + AND tracks.parent_rating_key = plex_cache_album_artwork.parent_rating_key + )", + [], + ) + .map_err(|e| format!("Failed to prune orphan Plex album artwork: {}", e))?; + tx.commit() + .map_err(|e| format!("Failed to commit Plex section selection prune: {}", e))?; + Ok(removed) } pub async fn plex_resolve_track_media( @@ -1883,12 +3034,12 @@ pub async fn plex_resolve_track_media( .get(metadata_url) .send() .await - .map_err(|e| format!("Plex metadata request failed: {}", e))? + .map_err(|e| safe_http_error("Plex metadata request", &e))? .error_for_status() - .map_err(|e| format!("Plex metadata status error: {}", e))? + .map_err(|e| safe_http_error("Plex metadata request", &e))? .text() .await - .map_err(|e| format!("Failed to read Plex metadata response: {}", e))?; + .map_err(|e| safe_http_error("Failed to read Plex metadata response", &e))?; let mut tracks = parse_tracks(&metadata_xml, Some(1)); let track = tracks @@ -1900,14 +3051,14 @@ pub async fn plex_resolve_track_media( .clone() .ok_or_else(|| format!("Track {rating_key} does not include a playable Part key"))?; - let part_url = with_token(&format!("{base}{part_key}"), &token); + let part_url = with_token(&as_download(&format!("{base}{part_key}")), &token); let part_response = client .get(&part_url) .send() .await - .map_err(|e| format!("Plex part request failed: {}", e))? + .map_err(|e| safe_http_error("Plex part request", &e))? .error_for_status() - .map_err(|e| format!("Plex part status error: {}", e))?; + .map_err(|e| safe_http_error("Plex part request", &e))?; let content_type = part_response .headers() @@ -1918,7 +3069,7 @@ pub async fn plex_resolve_track_media( let bytes = part_response .bytes() .await - .map_err(|e| format!("Failed to read Plex media bytes: {}", e))?; + .map_err(|e| safe_http_error("Failed to read Plex media bytes", &e))?; let playback_id = playback_track_id(&rating_key); Ok(PlexResolvedMedia { @@ -1950,6 +3101,9 @@ pub struct PlexPartLocation { pub content_type: Option, pub sampling_rate_hz: Option, pub bit_depth: Option, + /// The non-secret request identity required when another crate fetches + /// `part_url` instead of using qbz-plex's preconfigured client. + pub request_headers: Vec<(String, String)>, } /// Resolve the direct-play part URL for a Plex track WITHOUT downloading the @@ -1972,12 +3126,12 @@ pub async fn plex_resolve_part_url( .get(metadata_url) .send() .await - .map_err(|e| format!("Plex metadata request failed: {}", e))? + .map_err(|e| safe_http_error("Plex metadata request", &e))? .error_for_status() - .map_err(|e| format!("Plex metadata status error: {}", e))? + .map_err(|e| safe_http_error("Plex metadata request", &e))? .text() .await - .map_err(|e| format!("Failed to read Plex metadata response: {}", e))?; + .map_err(|e| safe_http_error("Failed to read Plex metadata response", &e))?; let mut tracks = parse_tracks(&metadata_xml, Some(1)); let track = tracks @@ -1989,7 +3143,7 @@ pub async fn plex_resolve_part_url( .clone() .ok_or_else(|| format!("Track {rating_key} does not include a playable Part key"))?; - let part_url = with_token(&format!("{base}{part_key}"), &token); + let part_url = with_token(&as_download(&format!("{base}{part_key}")), &token); let playback_id = playback_track_id(&rating_key); Ok(PlexPartLocation { @@ -2001,6 +3155,7 @@ pub async fn plex_resolve_part_url( content_type: track.container.clone(), sampling_rate_hz: track.sampling_rate_hz, bit_depth: track.bit_depth, + request_headers: plex_media_request_headers(), }) } @@ -2008,6 +3163,178 @@ pub async fn plex_resolve_part_url( mod tests { use super::*; + #[test] + fn plex_part_download_flag_preserves_existing_query_parameters() { + assert_eq!( + as_download("http://plex/library/parts/7/file.flac"), + "http://plex/library/parts/7/file.flac?download=1" + ); + assert_eq!( + as_download("http://plex/library/parts/7/file.flac?foo=bar"), + "http://plex/library/parts/7/file.flac?foo=bar&download=1" + ); + assert_eq!( + as_download("http://plex/library/parts/7/file.flac?download=1&foo=bar"), + "http://plex/library/parts/7/file.flac?download=1&foo=bar" + ); + assert_eq!( + with_token( + &as_download("http://plex/library/parts/7/file.flac?foo=bar"), + "secret" + ), + "http://plex/library/parts/7/file.flac?foo=bar&download=1&X-Plex-Token=secret" + ); + } + + fn search_db(track_count: u32) -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE plex_cache_tracks ( + rating_key TEXT PRIMARY KEY, title TEXT NOT NULL, artist TEXT, + album TEXT, duration_ms INTEGER, container TEXT, bit_depth INTEGER, + sampling_rate_hz INTEGER, artwork_path TEXT, collection_artwork_path TEXT, + server_id TEXT, parent_rating_key TEXT, + track_number INTEGER, + disc_number INTEGER, year INTEGER + ); + CREATE TABLE plex_cache_album_artwork ( + server_id TEXT NOT NULL, parent_rating_key TEXT NOT NULL, + artwork_path TEXT, checked_at INTEGER NOT NULL, + PRIMARY KEY(server_id,parent_rating_key) + );", + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + { + let mut insert = tx + .prepare( + "INSERT INTO plex_cache_tracks + (rating_key,title,artist,album,duration_ms,container,bit_depth, + sampling_rate_hz,track_number,disc_number,year) + VALUES (?1,?2,'Plex Artist',?3,1000,'flac',24,96000,?4,1,2026)", + ) + .unwrap(); + for i in 0..track_count { + insert + .execute(params![ + (i + 1).to_string(), + format!("Track {i:05}"), + format!("Album {:04}", i / 10), + (i % 10 + 1) as i64, + ]) + .unwrap(); + } + } + tx.commit().unwrap(); + conn + } + + fn sync_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE plex_cache_tracks ( + rating_key TEXT PRIMARY KEY, + section_key TEXT NOT NULL, + server_id TEXT, + title TEXT NOT NULL, + artist TEXT, + album TEXT, + duration_ms INTEGER, + artwork_path TEXT, + collection_artwork_path TEXT, + part_key TEXT, + container TEXT, + codec TEXT, + channels INTEGER, + bitrate_kbps INTEGER, + sampling_rate_hz INTEGER, + bit_depth INTEGER, + track_number INTEGER, + disc_number INTEGER, + album_key TEXT, + year INTEGER, + genre TEXT, + genres_json TEXT NOT NULL DEFAULT '[]', + parent_rating_key TEXT, + updated_at INTEGER NOT NULL, + sync_generation INTEGER NOT NULL DEFAULT 0, + recording_mbid TEXT + ); + CREATE INDEX idx_sync_tracks_section ON plex_cache_tracks(section_key); + CREATE TABLE plex_cache_album_artwork ( + server_id TEXT NOT NULL, + parent_rating_key TEXT NOT NULL, + artwork_path TEXT, + checked_at INTEGER NOT NULL, + PRIMARY KEY(server_id,parent_rating_key) + ); + CREATE TABLE plex_cache_section_sync ( + section_key TEXT PRIMARY KEY, + server_id TEXT, + generation INTEGER NOT NULL DEFAULT 0, + next_start INTEGER NOT NULL DEFAULT 0, + total_size INTEGER, + observed_rows INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'idle', + updated_at INTEGER NOT NULL DEFAULT 0 + );", + ) + .unwrap(); + conn + } + + fn sync_track(index: u64, with_quality: bool) -> PlexTrack { + PlexTrack { + rating_key: (index + 1).to_string(), + title: format!("Track {index:05}"), + artist: Some("Plex Artist".to_string()), + album: Some(format!("Album {:04}", index / 10)), + duration_ms: Some(180_000), + artwork_path: Some(format!("/library/metadata/{}/thumb", index + 1)), + collection_artwork_path: Some(format!("/library/metadata/album-{}/thumb", index / 10)), + part_key: Some(format!("/library/parts/{}/file.flac", index + 1)), + container: with_quality.then(|| "flac".to_string()), + codec: with_quality.then(|| "flac".to_string()), + channels: with_quality.then_some(2), + bitrate_kbps: with_quality.then_some(2_800), + sampling_rate_hz: with_quality.then_some(192_000), + bit_depth: with_quality.then_some(24), + track_number: Some((index % 10 + 1) as u32), + disc_number: Some(1), + year: Some(2026), + genres: vec!["Fixture".to_string()], + genre: Some("Fixture".to_string()), + parent_rating_key: Some(format!("album-{}", index / 10)), + recording_mbid: None, + } + } + + fn sync_page(start: u64, end: u64, total: u64, with_quality: bool) -> PlexTrackPage { + PlexTrackPage { + tracks: (start..end) + .map(|index| sync_track(index, with_quality)) + .collect(), + offset: start, + response_size: end - start, + total_size: total, + } + } + + fn page_xml(start: u64, end: u64, total: u64) -> String { + let mut xml = format!( + "", + end - start + ); + for index in start..end { + xml.push_str(&format!( + "", + index + 1 + )); + } + xml.push_str(""); + xml + } + #[test] fn parses_music_sections() { let xml = r#" @@ -2019,14 +3346,95 @@ mod tests { assert_eq!(sections[0].key, "1"); } + #[test] + fn album_artwork_uses_the_metadata_token_instead_of_inventing_one() { + let xml = r#" + + + + "#; + assert_eq!( + parse_album_artwork_path(xml).as_deref(), + Some("/library/metadata/60320/thumb/1665204207") + ); + assert_ne!( + parse_album_artwork_path(xml).as_deref(), + Some("/library/metadata/60335/thumb") + ); + } + + #[test] + fn album_artwork_cache_is_server_scoped_negative_cached_and_resolved() { + let mut conn = sync_db(); + conn.execute( + "INSERT INTO plex_cache_tracks( + rating_key,section_key,server_id,title,artist,album,parent_rating_key, + updated_at,sync_generation + ) VALUES ('track-1','music','server-a','Song','Artist','Album','album-1',1,1)", + [], + ) + .unwrap(); + assert_eq!( + album_artwork_candidates_in(&conn, "server-a", 100).unwrap(), + ["album-1"] + ); + assert!(album_artwork_candidates_in(&conn, "server-b", 100) + .unwrap() + .is_empty()); + + save_album_artwork_in( + &mut conn, + "server-a", + &[( + "album-1".into(), + Some("/library/metadata/cover/thumb/7".into()), + )], + 200, + ) + .unwrap(); + assert!(album_artwork_candidates_in(&conn, "server-a", 199) + .unwrap() + .is_empty()); + let effective: Option = conn + .query_row( + &format!( + "SELECT {EFFECTIVE_COLLECTION_ARTWORK_SQL} + FROM plex_cache_tracks WHERE rating_key='track-1'" + ), + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + effective.as_deref(), + Some("/library/metadata/cover/thumb/7") + ); + + save_album_artwork_in(&mut conn, "server-a", &[("album-1".into(), None)], 300).unwrap(); + let effective: Option = conn + .query_row( + &format!( + "SELECT {EFFECTIVE_COLLECTION_ARTWORK_SQL} + FROM plex_cache_tracks WHERE rating_key='track-1'" + ), + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(effective, None); + } + #[test] fn parses_tracks_with_stream_audio_metadata() { let xml = r#" - + + + "#; let tracks = parse_tracks(xml, Some(10)); @@ -2040,8 +3448,257 @@ mod tests { tracks[0].artwork_path.as_deref(), Some("/library/metadata/42/thumb/1") ); + assert_eq!( + tracks[0].collection_artwork_path.as_deref(), + Some("/library/metadata/album-9/thumb/1") + ); assert_eq!(tracks[0].sampling_rate_hz, Some(96000)); assert_eq!(tracks[0].bit_depth, Some(24)); + assert_eq!(tracks[0].genres, ["Progressive Rock", "Art Rock"]); + assert_eq!(tracks[0].genre.as_deref(), Some("Progressive Rock")); + } + + #[test] + fn plex_filtered_query_applies_format_and_quality_before_pagination() { + let conn = search_db(17); + conn.execute( + "UPDATE plex_cache_tracks SET container='mp3',bit_depth=NULL,sampling_rate_hz=44100 + WHERE CAST(rating_key AS INTEGER) % 2 = 1", + [], + ) + .unwrap(); + let formats = vec!["mp3".to_string()]; + let qualities = vec!["lossy".to_string()]; + let first = plex_cache_search_tracks_page_filtered_in( + &conn, + "", + 0, + 4, + "title-asc", + &formats, + false, + &qualities, + ) + .unwrap(); + let second = plex_cache_search_tracks_page_filtered_in( + &conn, + "", + 4, + 8, + "title-asc", + &formats, + false, + &qualities, + ) + .unwrap(); + let ids = first + .iter() + .chain(&second) + .map(|row| row.rating_key.clone()) + .collect::>(); + assert_eq!(ids.len(), 9); + assert!(first.iter().chain(&second).all(|row| row.format == "mp3")); + } + + #[test] + fn parses_bounded_container_page_and_rejects_incomplete_xml() { + let xml = r#" + + + "#; + let page = parse_track_page(xml, 500, 500).unwrap(); + assert_eq!(page.offset, 500); + assert_eq!(page.response_size, 2); + assert_eq!(page.total_size, 503); + assert_eq!(page.next_start(), 502); + assert!(page.has_more()); + + let truncated = r#" + + "#; + assert!(parse_track_page(truncated, 500, 500).is_err()); + } + + #[test] + fn paged_section_over_five_thousand_resumes_and_prunes_only_at_completion() { + let mut conn = sync_db(); + let first = begin_section_sync_in(&mut conn, Some("server-a".into()), "music").unwrap(); + assert_eq!(first.generation, 1); + for start in (0..5_137_u64).step_by(DEFAULT_PLEX_TRACK_PAGE_SIZE as usize) { + let end = (start + DEFAULT_PLEX_TRACK_PAGE_SIZE).min(5_137); + apply_section_page_in( + &mut conn, + "music", + first.generation, + &sync_page(start, end, 5_137, true), + ) + .unwrap(); + } + assert_eq!( + finish_section_sync_in(&mut conn, "music", first.generation).unwrap(), + 0 + ); + + let second = begin_section_sync_in(&mut conn, Some("server-a".into()), "music").unwrap(); + assert_eq!(second.generation, 2); + let mut state = second.clone(); + for start in [0_u64, DEFAULT_PLEX_TRACK_PAGE_SIZE] { + state = apply_section_page_in( + &mut conn, + "music", + second.generation, + &sync_page(start, start + DEFAULT_PLEX_TRACK_PAGE_SIZE, 5_005, false), + ) + .unwrap(); + } + mark_section_sync_in(&conn, "music", second.generation, "interrupted").unwrap(); + let before_resume: i64 = conn + .query_row("SELECT COUNT(*) FROM plex_cache_tracks", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!( + before_resume, 5_137, + "an incomplete generation never prunes" + ); + + let resumed = begin_section_sync_in(&mut conn, Some("server-a".into()), "music").unwrap(); + assert!(resumed.resumed); + assert_eq!(resumed.generation, second.generation); + assert_eq!(resumed.next_start, state.next_start); + for start in (resumed.next_start..5_005).step_by(DEFAULT_PLEX_TRACK_PAGE_SIZE as usize) { + let end = (start + DEFAULT_PLEX_TRACK_PAGE_SIZE).min(5_005); + state = apply_section_page_in( + &mut conn, + "music", + resumed.generation, + &sync_page(start, end, 5_005, false), + ) + .unwrap(); + } + assert_eq!(state.observed_rows, 5_005); + assert_eq!( + finish_section_sync_in(&mut conn, "music", resumed.generation).unwrap(), + 132 + ); + let counts: (i64, i64) = conn + .query_row( + "SELECT COUNT(*),COUNT(DISTINCT rating_key) FROM plex_cache_tracks", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(counts, (5_005, 5_005)); + let quality: (Option, Option) = conn + .query_row( + "SELECT sampling_rate_hz,bit_depth FROM plex_cache_tracks WHERE rating_key='1'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(quality, (Some(192_000), Some(24))); + } + + #[test] + fn repeated_rating_key_across_pages_does_not_advance_checkpoint() { + let mut conn = sync_db(); + let state = begin_section_sync_in(&mut conn, None, "music").unwrap(); + let first = sync_page(0, 1, 2, false); + let state = apply_section_page_in(&mut conn, "music", state.generation, &first).unwrap(); + let duplicate = PlexTrackPage { + tracks: vec![sync_track(0, false)], + offset: 1, + response_size: 1, + total_size: 2, + }; + assert!(apply_section_page_in(&mut conn, "music", state.generation, &duplicate).is_err()); + let stored = load_section_sync_in(&conn, "music").unwrap().unwrap(); + assert_eq!(stored.state.next_start, 1); + assert_eq!(stored.state.observed_rows, 1); + assert!(finish_section_sync_in(&mut conn, "music", state.generation).is_err()); + } + + #[test] + fn changed_total_size_requires_a_fresh_generation() { + let mut conn = sync_db(); + let first = begin_section_sync_in(&mut conn, Some("server-a".into()), "music").unwrap(); + let checkpoint = apply_section_page_in( + &mut conn, + "music", + first.generation, + &sync_page(0, 1, 2, false), + ) + .unwrap(); + assert_eq!(checkpoint.next_start, 1); + + let changed_total = sync_page(1, 2, 3, false); + assert!( + apply_section_page_in(&mut conn, "music", first.generation, &changed_total,).is_err() + ); + let unchanged = load_section_sync_in(&conn, "music").unwrap().unwrap(); + assert_eq!(unchanged.state.next_start, 1); + assert_eq!(unchanged.state.total_size, Some(2)); + + mark_section_sync_in(&conn, "music", first.generation, "restart").unwrap(); + let restarted = begin_section_sync_in(&mut conn, Some("server-a".into()), "music").unwrap(); + assert_eq!(restarted.generation, first.generation + 1); + assert_eq!(restarted.next_start, 0); + assert_eq!(restarted.total_size, None); + assert!(!restarted.resumed); + } + + #[test] + fn page_transaction_failure_rolls_back_rows_and_checkpoint() { + let mut conn = sync_db(); + conn.execute_batch( + "CREATE TRIGGER fail_second_track + BEFORE INSERT ON plex_cache_tracks + WHEN NEW.rating_key='2' + BEGIN SELECT RAISE(ABORT, 'fixture write failure'); END;", + ) + .unwrap(); + let state = begin_section_sync_in(&mut conn, None, "music").unwrap(); + assert!(apply_section_page_in( + &mut conn, + "music", + state.generation, + &sync_page(0, 2, 2, false), + ) + .is_err()); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM plex_cache_tracks", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 0); + let stored = load_section_sync_in(&conn, "music").unwrap().unwrap(); + assert_eq!(stored.state.next_start, 0); + assert_eq!(stored.state.observed_rows, 0); + } + + #[test] + fn paged_xml_metric_keeps_peak_document_bounded() { + const TRACKS: u64 = 5_137; + let full_xml_bytes = page_xml(0, TRACKS, TRACKS).len(); + let started = std::time::Instant::now(); + let mut max_page_bytes = 0_usize; + let mut parsed = 0_u64; + let mut pages = 0_u64; + for start in (0..TRACKS).step_by(DEFAULT_PLEX_TRACK_PAGE_SIZE as usize) { + let end = (start + DEFAULT_PLEX_TRACK_PAGE_SIZE).min(TRACKS); + let xml = page_xml(start, end, TRACKS); + max_page_bytes = max_page_bytes.max(xml.len()); + let page = parse_track_page(&xml, start, DEFAULT_PLEX_TRACK_PAGE_SIZE).unwrap(); + parsed += page.tracks.len() as u64; + pages += 1; + } + let elapsed = started.elapsed(); + assert_eq!(parsed, TRACKS); + assert!(max_page_bytes * 8 < full_xml_bytes); + println!( + "H_PLEX_METRIC tracks={TRACKS} pages={pages} full_xml_bytes={full_xml_bytes} max_page_bytes={max_page_bytes} parse_ms={}", + elapsed.as_millis(), + ); } #[test] @@ -2054,4 +3711,116 @@ mod tests { fn playback_track_id_prefers_numeric_rating_key() { assert_eq!(playback_track_id("48012"), 48012); } + + #[test] + fn plex_search_pages_past_five_thousand_without_omissions() { + let conn = search_db(5_137); + let mut offset = 0; + let mut ids = std::collections::HashSet::new(); + loop { + let page = + plex_cache_search_tracks_page_in(&conn, "Track", offset, 500, "title-asc").unwrap(); + if page.is_empty() { + break; + } + for track in &page { + assert!(ids.insert(track.id), "duplicate Plex id {}", track.id); + } + offset += page.len() as u64; + } + assert_eq!(offset, 5_137); + assert_eq!(ids.len(), 5_137); + } + + #[test] + fn plex_page_order_uses_the_published_album_and_year_values() { + let conn = search_db(2); + conn.execute( + "UPDATE plex_cache_tracks + SET title = 'Zulu', album = 'Plex Artist - Alpha', year = 2020 + WHERE rating_key = '1'", + [], + ) + .unwrap(); + conn.execute( + "UPDATE plex_cache_tracks + SET title = 'Alpha', album = 'Beta', year = NULL + WHERE rating_key = '2'", + [], + ) + .unwrap(); + + let default = plex_cache_search_tracks_page_in(&conn, "", 0, 10, "default").unwrap(); + assert_eq!(default[0].rating_key, "1"); + assert_eq!(default[0].album, "Alpha"); + + let title = plex_cache_search_tracks_page_in(&conn, "", 0, 10, "title-asc").unwrap(); + assert_eq!(title[0].rating_key, "2"); + + let year = plex_cache_search_tracks_page_in(&conn, "", 0, 10, "year-desc").unwrap(); + assert_eq!(year[0].rating_key, "1"); + assert_eq!(year[0].year, Some(2020)); + assert_eq!(year[1].year, None); + } + + #[test] + fn plex_artist_group_orders_by_the_performing_artist() { + let conn = search_db(2); + conn.execute( + "UPDATE plex_cache_tracks SET artist = 'Zed Performer', album = 'A Album', title = 'First' WHERE rating_key = '1'", + [], + ) + .unwrap(); + conn.execute( + "UPDATE plex_cache_tracks SET artist = 'Alpha Performer', album = 'Z Album', title = 'Second' WHERE rating_key = '2'", + [], + ) + .unwrap(); + + let grouped = plex_cache_search_tracks_page_in(&conn, "", 0, 10, "group-artist").unwrap(); + assert_eq!(grouped[0].artist, "Alpha Performer"); + assert_eq!(grouped[1].artist, "Zed Performer"); + } + + #[test] + fn album_detail_resolves_content_hash_and_native_parent_key() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE plex_cache_tracks ( + rating_key TEXT PRIMARY KEY, title TEXT NOT NULL, artist TEXT, + album TEXT, duration_ms INTEGER, container TEXT, bit_depth INTEGER, + sampling_rate_hz INTEGER, artwork_path TEXT, collection_artwork_path TEXT, + server_id TEXT, + track_number INTEGER, + disc_number INTEGER, album_key TEXT, parent_rating_key TEXT + ); + CREATE TABLE plex_cache_album_artwork ( + server_id TEXT NOT NULL, parent_rating_key TEXT NOT NULL, + artwork_path TEXT, checked_at INTEGER NOT NULL, + PRIMARY KEY(server_id,parent_rating_key) + ); + INSERT INTO plex_cache_tracks + (rating_key,title,artist,album,duration_ms,container,bit_depth, + sampling_rate_hz,track_number,disc_number,album_key,parent_rating_key) + VALUES + ('1','First','Artist','Album',180000,'flac',24,96000,1,1,'plex:hash','parent-a'), + ('2','Second','Artist','Album',180000,'flac',24,96000,2,1,'plex:hash','parent-a'), + ('3','Other edition','Artist','Album',180000,'flac',16,44100,1,1,'plex:hash','parent-b');", + ) + .unwrap(); + + let legacy = plex_cache_get_album_tracks_in(&conn, "plex:hash").unwrap(); + assert_eq!(legacy.len(), 3); + let native = plex_cache_get_album_tracks_in(&conn, "plex:album:parent-a").unwrap(); + assert_eq!( + native + .iter() + .map(|track| track.rating_key.as_str()) + .collect::>(), + vec!["1", "2"] + ); + assert!(native + .iter() + .all(|track| track.album_key == "plex:album:parent-a")); + } } diff --git a/crates/qbz-qobuz/Cargo.toml b/crates/qbz-qobuz/Cargo.toml index 6e32ec261..c3aa7dba1 100644 --- a/crates/qbz-qobuz/Cargo.toml +++ b/crates/qbz-qobuz/Cargo.toml @@ -23,12 +23,14 @@ serde_json = { workspace = true } # Crypto (for Qobuz auth) md-5 = { workspace = true } base64 = { workspace = true } +zeroize = "1" # Time chrono = { workspace = true } -# Filesystem (bundle-token cache location) +# Filesystem (bundle-token cache location, atomic replacement of it) dirs = "6" +tempfile = "3" # Regex regex = { workspace = true } @@ -44,3 +46,7 @@ anyhow = { workspace = true } # Used only by examples/lyrics_probe.rs (read-only live probe of the lyrics # endpoint): loads the saved OAuth token / credentials for login. qbz-credentials = { path = "../qbz-credentials" } +# The offline-gate tests build a reqwest client, which panics with +# "No provider set" until a process-level rustls CryptoProvider exists. The app +# installs one at startup; a test binary has to do it itself. +rustls = { version = "0.23", features = ["aws_lc_rs"] } diff --git a/crates/qbz-qobuz/examples/member_login_probe.rs b/crates/qbz-qobuz/examples/member_login_probe.rs new file mode 100644 index 000000000..bcd869816 --- /dev/null +++ b/crates/qbz-qobuz/examples/member_login_probe.rs @@ -0,0 +1,364 @@ +//! Read-only live probe: what does Qobuz return for an account WITHOUT an +//! active subscription ("Qobuz member")? +//! +//! Answers Q2 of `qbz-nix-docs/offline-mode/tauri-review-2026-06-09/ +//! 10-subscription-trial-offline-gating.md` §5.6 against the LIVE API and +//! prints REDACTED JSON so the shapes can be captured into +//! `qbz-nix-docs/qobuz-api/` before the member-mode parser is written: +//! +//! 1. `POST /user/login` raw payload after the OAuth exchange (does it +//! 200? what is in `credential.parameters` / `user.subscription`?) +//! 2. `favorite/getUserFavorites` totals + `getUserPlaylists` (reads?) +//! 3. `/track/getFileUrl` for a catalog track at CD + Hi-Res quality +//! (`sample`? `restrictions`? which format comes back?) +//! 4. optionally the same for a PURCHASED track (`--purchased=`) +//! +//! Sign-in is the same system-browser OAuth the app uses (Qobuz retired the +//! email+password login in 2026): the probe binds a loopback listener, +//! prints the sign-in URL (and tries to open it), captures the redirect, +//! exchanges the code. Alternatively paste an existing `user_auth_token` +//! (e.g. the web player's `X-User-Auth-Token` header) to skip the browser: +//! +//! cargo run -p qbz-qobuz --example member_login_probe [--purchased=ID] [track_id...] +//! QBZ_PROBE_TOKEN=... cargo run -p qbz-qobuz --example member_login_probe +//! +//! Nothing is written to disk and nothing is written to Qobuz: login + GETs. + +use qbz_models::{Quality, UserSession}; +use qbz_qobuz::QobuzClient; +use serde_json::Value; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +struct StderrLogger; + +impl log::Log for StderrLogger { + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.level() <= log::Level::Info + } + fn log(&self, record: &log::Record) { + if self.enabled(record.metadata()) { + eprintln!("[{}] {}", record.level(), record.args()); + } + } + fn flush(&self) {} +} + +static LOGGER: StderrLogger = StderrLogger; + +const OAUTH_TIMEOUT: Duration = Duration::from_secs(180); + +/// Devin Townsend - Vampira (Retinal Circus): a plain catalog track that the +/// lyrics probe already used, so the two captures line up. +const DEFAULT_TRACK_IDS: &[u64] = &[29006863]; + +/// Keys whose string values identify the account or are secrets. Redacted +/// in place before printing; structure and every other field stay verbatim. +const REDACT_KEYS: &[&str] = &[ + "user_auth_token", + "email", + "login", + "firstname", + "lastname", + "display_name", + "avatar", + "publicId", + "public_id", + "session_id", +]; + +fn redact(value: &mut Value) { + match value { + Value::Object(map) => { + for (key, child) in map.iter_mut() { + if REDACT_KEYS.contains(&key.as_str()) { + if let Value::String(s) = child { + if !s.is_empty() { + *s = format!("", s.len()); + } + } + } else if key == "url" { + // Signed CDN URL: keep scheme + host + path, drop the + // query (it carries the per-request token). + if let Value::String(s) = child { + if let Some((base, _)) = s.split_once('?') { + *s = format!("{base}?"); + } + } + } else { + redact(child); + } + } + } + Value::Array(items) => items.iter_mut().for_each(redact), + _ => {} + } +} + +fn print_json(label: &str, status: u16, body: &str) { + println!("\n=== {label} (HTTP {status}) ==="); + match serde_json::from_str::(body) { + Ok(mut json) => { + redact(&mut json); + println!( + "{}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + } + Err(_) => println!("", body.len()), + } +} + +/// 48 hex chars from a splitmix64 stream seeded by wall-clock nanos + pid. +/// A probe run by hand does not need a CSPRNG; it needs a path the callback +/// must echo back so a stray request on the port is ignored. +fn gen_nonce() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x9E37_79B9_7F4A_7C15); + let mut state = nanos ^ ((std::process::id() as u64) << 32); + let mut out = String::with_capacity(48); + for _ in 0..3 { + state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^= z >> 31; + out.push_str(&format!("{z:016x}")); + } + out +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(v); + i += 3; + continue; + } + } + out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] }); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn query_param(query: &str, key: &str) -> Option { + query + .split('&') + .filter_map(|pair| pair.split_once('=')) + .find(|(k, _)| *k == key) + .map(|(_, v)| percent_decode(v)) +} + +/// The authorization code ONLY when the request path carries the nonce +/// (`GET /?...`); `code_autorisation` wins over `code`, as in the app. +fn parse_callback(request_line: &str, expected_nonce: &str) -> Option { + let target = request_line.split_whitespace().nth(1)?; + let (path, query) = target.split_once('?')?; + if path.trim_matches('/') != expected_nonce { + return None; + } + query_param(query, "code_autorisation").or_else(|| query_param(query, "code")) +} + +async fn capture_oauth_code(listener: TcpListener, expected_nonce: &str) -> Option { + loop { + let (mut stream, _) = listener.accept().await.ok()?; + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).await.ok()?; + let request = String::from_utf8_lossy(&buf[..n]); + let code = parse_callback(request.lines().next().unwrap_or(""), expected_nonce); + let body = if code.is_some() { + "\ +

Probe signed in

You can close this tab.

" + } else { + "\ +

Waiting for Qobuz...

" + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; + if code.is_some() { + return code; + } + } +} + +/// System-browser OAuth, the app's flow: loopback listener + nonce path, +/// sign-in URL, redirect capture, code → token exchange. +async fn oauth_token(client: &QobuzClient) -> Result> { + let app_id = client.app_id().await?; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + let nonce = gen_nonce(); + let redirect = format!("http%3A%2F%2Flocalhost%3A{port}%2F{nonce}"); + let url = + format!("https://www.qobuz.com/signin/oauth?ext_app_id={app_id}&redirect_url={redirect}"); + eprintln!("[probe] sign in with the account to probe:\n{url}\n"); + // Best-effort: open it; if that fails the URL above is the fallback. + #[cfg(target_os = "macos")] + let opener = "open"; + #[cfg(not(target_os = "macos"))] + let opener = "xdg-open"; + let _ = std::process::Command::new(opener) + .arg(&url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + let code = tokio::time::timeout(OAUTH_TIMEOUT, capture_oauth_code(listener, &nonce)) + .await + .map_err(|_| "OAuth login timed out (180 s)")? + .ok_or("OAuth login cancelled or no code received")?; + eprintln!("[probe] code captured, exchanging for a token"); + Ok(client.exchange_oauth_code(&code).await?) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Info)); + + let mut purchased: Option = None; + let mut ids: Vec = Vec::new(); + for arg in std::env::args().skip(1) { + if let Some(id) = arg.strip_prefix("--purchased=") { + purchased = Some(id.parse()?); + } else if let Ok(id) = arg.parse() { + ids.push(id); + } + } + let track_ids: Vec = if ids.is_empty() { + DEFAULT_TRACK_IDS.to_vec() + } else { + ids + }; + + let client = QobuzClient::new()?; + let warm = client.init().await?; + eprintln!("[probe] init done (warm bundle cache: {warm})"); + + // 1. Token: pasted, or the app's OAuth flow. Then the raw session + // fetch, captured BEFORE the parser gets a chance to reject it. + let token = match std::env::var("QBZ_PROBE_TOKEN") { + Ok(t) if !t.trim().is_empty() => { + eprintln!("[probe] using QBZ_PROBE_TOKEN"); + t.trim().to_string() + } + _ => oauth_token(&client).await?, + }; + let (status, body) = client.login_with_token_raw(&token).await?; + print_json("POST /user/login (X-User-Auth-Token)", status, &body); + if status != 200 { + eprintln!("[probe] login did not return 200; nothing else to probe"); + return Ok(()); + } + let json: Value = serde_json::from_str(&body)?; + let user = json.get("user").ok_or("no `user` in login payload")?; + let user_id = user + .get("id") + .and_then(Value::as_u64) + .ok_or("no `user.id` in login payload")?; + let session_token = json + .get("user_auth_token") + .and_then(Value::as_str) + .unwrap_or(&token) + .to_string(); + let parameters = user.get("credential").and_then(|c| c.get("parameters")); + let has_parameters = parameters + .and_then(Value::as_object) + .map(|o| !o.is_empty()) + .unwrap_or(false); + println!( + "\n[verdict] user_id={user_id} credential.parameters present+non-empty: {has_parameters} \ + -> today's parser would say: {}", + if has_parameters { + "eligible" + } else { + "IneligibleUser" + } + ); + println!( + "[verdict] user.subscription = {}", + user.get("subscription") + .map(|s| s.to_string()) + .unwrap_or_else(|| "".into()) + ); + + // Hand-built session so the authenticated GETs below can run even when + // the parser would have refused. In-memory only. + client + .set_session(UserSession { + user_auth_token: session_token, + user_id, + ..UserSession::default() + }) + .await; + + // 2. Reads: favorites totals per type + playlists. + for fav_type in ["albums", "tracks", "artists", "labels", "awards"] { + match client.get_favorites(fav_type, 1, 0).await { + Ok(v) => println!( + "[favorites] {fav_type}: total={}", + v.get(fav_type) + .and_then(|t| t.get("total")) + .map(|t| t.to_string()) + .unwrap_or_else(|| "?".into()) + ), + Err(e) => println!("[favorites] {fav_type}: ERROR {e}"), + } + } + match client.get_user_playlists().await { + Ok(p) => println!("[playlists] getUserPlaylists: {} entries", p.len()), + Err(e) => println!("[playlists] getUserPlaylists: ERROR {e}"), + } + + // 3. Streaming: catalog track(s) at CD and Hi-Res. + for track_id in &track_ids { + for quality in [Quality::Lossless, Quality::UltraHiRes] { + match client.get_stream_url_raw(*track_id, quality).await { + Ok((status, body)) => print_json( + &format!("getFileUrl track={track_id} format_id={}", quality.id()), + status, + &body, + ), + Err(e) => println!( + "\n=== getFileUrl track={track_id} format_id={} === ERROR {e}", + quality.id() + ), + } + } + } + + // 4. Streaming a purchased track, if the caller named one. + if let Some(track_id) = purchased { + for quality in [Quality::Lossless, Quality::UltraHiRes] { + match client.get_stream_url_raw(track_id, quality).await { + Ok((status, body)) => print_json( + &format!( + "getFileUrl PURCHASED track={track_id} format_id={}", + quality.id() + ), + status, + &body, + ), + Err(e) => println!( + "\n=== getFileUrl PURCHASED track={track_id} format_id={} === ERROR {e}", + quality.id() + ), + } + } + } + + eprintln!("[probe] done; nothing was written to Qobuz or to disk"); + Ok(()) +} diff --git a/crates/qbz-qobuz/src/auth.rs b/crates/qbz-qobuz/src/auth.rs index 73f42bf13..d8c8a898c 100644 --- a/crates/qbz-qobuz/src/auth.rs +++ b/crates/qbz-qobuz/src/auth.rs @@ -5,7 +5,7 @@ use md5::{Digest, Md5}; use std::time::{SystemTime, UNIX_EPOCH}; use super::error::{ApiError, Result}; -use qbz_models::UserSession; +use qbz_models::{Entitlements, UserSession}; /// Generate MD5 signature for protected API endpoints /// @@ -17,12 +17,39 @@ pub fn generate_signature(method: &str, params: &str, timestamp: u64, secret: &s format!("{:x}", hasher.finalize()) } -/// Generate signature for track/getFileUrl endpoint -pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String { - let params = format!("format_id{}intentstreamtrack_id{}", format_id, track_id); +/// Generate the signature for `track/getFileUrl`. The `intent` is PART OF THE +/// PREIMAGE, not just a query parameter: measured live 2026-09-01 on a +/// purchased DSD128 track (format 56), the request signed with `stream` +/// answered HTTP 400 and the one signed with `download` answered 200 with the +/// DSF. Parameters are alphabetical and unseparated, as on every RPC call. +pub fn sign_get_file_url_with_intent( + track_id: u64, + format_id: u32, + intent: &str, + timestamp: u64, + secret: &str, +) -> String { + let params = format!("format_id{}intent{}track_id{}", format_id, intent, track_id); generate_signature("trackgetFileUrl", ¶ms, timestamp, secret) } +/// Playback: `intent=stream`. +pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String { + sign_get_file_url_with_intent(track_id, format_id, "stream", timestamp, secret) +} + +/// A purchased file: `intent=download`. The Qobuz desktop client signs this +/// exact form when it writes a purchase to disk, and it is the only signature +/// the CDN accepts for a DSD entitlement. +pub fn sign_get_file_url_download( + track_id: u64, + format_id: u32, + timestamp: u64, + secret: &str, +) -> String { + sign_get_file_url_with_intent(track_id, format_id, "download", timestamp, secret) +} + /// Generate signature for favorite/getUserFavorites endpoint pub fn sign_get_favorites(timestamp: u64, secret: &str) -> String { generate_signature("favoritegetUserFavorites", "", timestamp, secret) @@ -52,7 +79,12 @@ pub fn sign_search( /// `method` is the endpoint path with slashes removed, e.g. "/album/get" → "albumget". /// `kv_pairs` are (key, value) pairs sorted alphabetically by key. /// The params string is built as key1value1key2value2... (same as mobile app interceptor). -pub fn sign_request(method: &str, kv_pairs: &[(&str, &str)], timestamp: u64, secret: &str) -> String { +pub fn sign_request( + method: &str, + kv_pairs: &[(&str, &str)], + timestamp: u64, + secret: &str, +) -> String { let mut sorted = kv_pairs.to_vec(); sorted.sort_by_key(|(k, _)| *k); let params: String = sorted.iter().map(|(k, v)| format!("{}{}", k, v)).collect(); @@ -97,14 +129,48 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result .unwrap_or("") .to_string(); - // Check subscription + // Subscription / entitlements. `credential.parameters` is the server's + // verdict: a populated object for a subscriber, `null`/absent/empty + // for a Qobuz member without a subscription. The member is a valid + // session (member mode, 2026-09-02): favorites, playlists, purchases + // and previews all work; the flags below say what does not. let credential = user.get("credential"); - let subscription_label = credential + let parameters = credential .and_then(|c| c.get("parameters")) - .and_then(|p| p.get("short_label")) + .and_then(|p| p.as_object()) + .filter(|o| !o.is_empty()); + let entitlements = parameters + .map(|p| Entitlements { + lossy_streaming: flag(p, "lossy_streaming"), + lossless_streaming: flag(p, "lossless_streaming"), + hires_streaming: flag(p, "hires_streaming"), + hires_purchases_streaming: flag(p, "hires_purchases_streaming"), + offline_streaming: flag(p, "offline_streaming"), + mobile_streaming: flag(p, "mobile_streaming"), + }) + .unwrap_or_default(); + let subscription_label = match parameters { + Some(p) => p + .get("short_label") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(), + // msgid; the UI translates it (the web player says "Qobuz member"). + None => "Member".to_string(), + }; + let subscription = user.get("subscription"); + let subscription_end_date = subscription + .and_then(|s| s.get("end_date")) .and_then(|v| v.as_str()) - .unwrap_or("Unknown") - .to_string(); + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let subscription_offer = subscription + .and_then(|s| s.get("offer")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); fn parse_subscription_valid_until(parameters: &serde_json::Value) -> Option { // Try common string fields first. @@ -159,16 +225,6 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result .and_then(|v| v.as_str()) .map(str::to_string); - // Check if user has valid subscription - let has_subscription = credential - .and_then(|c| c.get("parameters")) - .map(|p| !p.is_null() && p.as_object().map(|o| !o.is_empty()).unwrap_or(false)) - .unwrap_or(false); - - if !has_subscription { - return Err(ApiError::IneligibleUser); - } - Ok(UserSession { user_auth_token, user_id, @@ -176,11 +232,22 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result display_name, subscription_label, subscription_valid_until, + entitlements, + subscription_end_date, + subscription_offer, country_code, language_code, }) } +/// A boolean entitlement flag; anything but literal `true` is `false`. +fn flag(parameters: &serde_json::Map, key: &str) -> bool { + parameters + .get(key) + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + /// App seed for CMAF request signing and key derivation. /// Public value extracted from the webplayer JS bundle. pub const CMAF_SEED: &str = "abb21364945c0583309667d13ca3d93a"; @@ -211,6 +278,23 @@ mod tests { assert_eq!(sig.len(), 32); // MD5 hex is 32 chars } + #[test] + fn download_intent_signs_a_different_preimage_than_stream() { + let (tid, fid, ts, secret) = (123_456_u64, 56_u32, 1_700_000_000_u64, "s3cr3t"); + let expected = generate_signature( + "trackgetFileUrl", + &format!("format_id{fid}intentdownloadtrack_id{tid}"), + ts, + secret, + ); + assert_eq!(sign_get_file_url_download(tid, fid, ts, secret), expected); + assert_ne!( + sign_get_file_url_download(tid, fid, ts, secret), + sign_get_file_url(tid, fid, ts, secret), + "intent is inside the preimage; the two operations cannot share a signature" + ); + } + #[test] fn test_sign_get_file_url() { let sig = sign_get_file_url(123456, 27, 1234567890, "testsecret"); @@ -253,4 +337,118 @@ mod tests { assert_eq!(session.country_code, None); assert_eq!(session.language_code, None); } + + /// Qobuz's own embedded `/user/login` fixture (webpack module 58981 of + /// the web player bundle): an internal Studio account with a PAST + /// `end_date` and `is_canceled: true`, yet every entitlement `true`. + /// The flags are the verdict; the date is not. + fn studio_fixture() -> serde_json::Value { + serde_json::json!({ + "user_auth_token": "CmJsOF3qsokN12dd", + "user": { + "id": 1705826, + "email": "account-test+FRfavorites@qobuz.com", + "login": "test-FR-Studio", + "display_name": "test FR Studio", + "country_code": "FR", + "language_code": "fr", + "subscription": { + "offer": "studio", + "periodicity": "annual", + "end_date": "2022-06-19", + "is_canceled": true + }, + "credential": { + "id": 1489142, + "label": "streaming-studio", + "description": "Abonné Qobuz Studio", + "parameters": { + "lossy_streaming": true, + "lossless_streaming": true, + "hires_streaming": true, + "hires_purchases_streaming": true, + "mobile_streaming": true, + "offline_streaming": true, + "hfp_purchase": false, + "included_format_group_ids": [1, 2, 3, 4], + "label": "Qobuz Studio", + "short_label": "Studio", + "source": "internal" + } + } + } + }) + } + + #[test] + fn subscriber_carries_entitlements_offer_and_end_date() { + let session = parse_login_response(&studio_fixture()).expect("subscriber parses"); + assert_eq!(session.subscription_label, "Studio"); + assert!(session.entitlements.offline_streaming); + assert!(session.entitlements.hires_streaming); + assert!(session.entitlements.hires_purchases_streaming); + assert!(!session.entitlements.is_member_only()); + assert_eq!(session.subscription_offer.as_deref(), Some("studio")); + assert_eq!(session.subscription_end_date.as_deref(), Some("2022-06-19")); + } + + /// A Qobuz member without a subscription: Qobuz answers 200 and the + /// session is real; QBZ used to throw it away (`IneligibleUser`). The + /// web player shows the same account as "Qobuz member" with its + /// favorites and playlists (verified 2026-09-02 with a lapsed account). + #[test] + fn member_without_parameters_is_a_valid_session() { + for credential in [ + serde_json::json!({"parameters": null}), + serde_json::json!({"parameters": {}}), + serde_json::json!({}), + ] { + let response = login_response(serde_json::json!({ "credential": credential })); + let session = parse_login_response(&response) + .unwrap_or_else(|e| panic!("member must parse, got {e} for {credential}")); + assert_eq!(session.user_id, 1705826); + assert_eq!(session.subscription_label, "Member"); + assert_eq!(session.entitlements, Entitlements::default()); + assert!(session.entitlements.is_member_only()); + assert_eq!(session.subscription_end_date, None); + } + // No `credential` object at all: same verdict. + let mut response = login_response(serde_json::json!({})); + response["user"] + .as_object_mut() + .unwrap() + .remove("credential"); + let session = parse_login_response(&response).expect("member without credential"); + assert_eq!(session.subscription_label, "Member"); + assert!(session.entitlements.is_member_only()); + } + + #[test] + fn flags_default_false_when_absent_or_not_boolean() { + let response = login_response(serde_json::json!({ + "credential": {"parameters": { + "short_label": "Solo", + "lossless_streaming": true, + "hires_streaming": "true", + "offline_streaming": 1 + }} + })); + let session = parse_login_response(&response).expect("parses"); + assert_eq!(session.subscription_label, "Solo"); + assert!(session.entitlements.lossless_streaming); + assert!(!session.entitlements.hires_streaming); + assert!(!session.entitlements.offline_streaming); + assert!(!session.entitlements.is_member_only()); + } + + #[test] + fn persisted_session_without_new_fields_still_loads() { + // A session snapshot written before member mode: no entitlements, + // no subscription fields. Must deserialize with everything false. + let json = r#"{"user_auth_token":"t","user_id":1,"email":"","display_name":"", + "subscription_label":"Studio"}"#; + let session: UserSession = serde_json::from_str(json).expect("old snapshot loads"); + assert_eq!(session.entitlements, Entitlements::default()); + assert_eq!(session.subscription_offer, None); + } } diff --git a/crates/qbz-qobuz/src/bundle.rs b/crates/qbz-qobuz/src/bundle.rs index 910e507a8..725a084b2 100644 --- a/crates/qbz-qobuz/src/bundle.rs +++ b/crates/qbz-qobuz/src/bundle.rs @@ -6,8 +6,10 @@ use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::time::Duration; +use tempfile::Builder; use super::error::{ApiError, Result}; @@ -55,15 +57,60 @@ impl From for BundleTokens { } } -fn cache_path() -> Option { - Some(dirs::cache_dir()?.join("qbz").join("bundle_tokens.json")) +/// Where the token cache lives. +/// +/// `cache_dir` is what an embedder supplied; without one this falls back to the +/// platform cache directory, which is what the desktop has always used. +fn cache_path(cache_dir: Option<&Path>) -> Option { + let root = cache_dir.map(Path::to_path_buf).or_else(dirs::cache_dir)?; + Some(root.join("qbz").join("bundle_tokens.json")) +} + +/// Replace `path` with whatever `write` produces, or leave it exactly as it was. +/// +/// The bytes land in a sibling temp file that is flushed and then renamed over +/// the target, so a reader either sees the previous cache or the new one and +/// never the gap between them. Writing in place cannot offer that: it truncates +/// the file before the first byte of the replacement is written. +/// +/// This is process-crash atomicity, not a power-loss durability promise: the +/// file contents are synced before the rename, but the parent directory is not. +/// Concurrent writers are not serialized; the last successful rename wins, +/// while every visible version remains a complete file. +/// +/// `write` is a closure rather than a `&[u8]` so a test can fail partway through +/// and assert what survives. +fn atomic_write_with( + path: &Path, + write: impl FnOnce(&mut std::fs::File) -> io::Result<()>, +) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "cache path has no parent"))?; + std::fs::create_dir_all(parent)?; + + let mut temp = Builder::new() + .prefix(".bundle_tokens.") + .tempfile_in(parent)?; + write(temp.as_file_mut())?; + temp.as_file().sync_all()?; + temp.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + atomic_write_with(path, |file| file.write_all(bytes)) } /// Load cached tokens if a valid cache file exists. Returns `None` on any error /// (missing file, malformed JSON, empty fields) so the caller falls back to a /// live fetch. pub fn load_cached_bundle() -> Option { - let path = cache_path()?; + load_cached_bundle_from(None) +} + +pub(crate) fn load_cached_bundle_from(cache_dir: Option<&Path>) -> Option { + let path = cache_path(cache_dir)?; let data = std::fs::read(&path).ok()?; match serde_json::from_slice::(&data) { Ok(c) if !c.app_id.is_empty() && !c.secrets.is_empty() => Some(c), @@ -78,16 +125,13 @@ pub fn load_cached_bundle() -> Option { } } -fn save_cached_bundle(c: &CachedBundle) { - let Some(path) = cache_path() else { +fn save_cached_bundle(c: &CachedBundle, cache_dir: Option<&Path>) { + let Some(path) = cache_path(cache_dir) else { log::warn!("[Bundle] No cache dir available, skipping token cache write"); return; }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } match serde_json::to_vec_pretty(c) { - Ok(bytes) => match std::fs::write(&path, bytes) { + Ok(bytes) => match atomic_write(&path, &bytes) { Ok(_) => log::info!("[Bundle] Cached tokens (version {})", c.bundle_version), Err(e) => log::warn!("[Bundle] Failed to write token cache: {}", e), }, @@ -177,18 +221,28 @@ async fn extract_bundle_tokens_once(client: &Client) -> Result<(BundleTokens, St /// [`refresh_bundle_if_changed`] on warm starts so the UI never blocks on the /// 7 MB download. pub async fn extract_and_cache_bundle_tokens(client: &Client) -> Result { + extract_and_cache_bundle_tokens_in(client, None).await +} + +pub(crate) async fn extract_and_cache_bundle_tokens_in( + client: &Client, + cache_dir: Option<&Path>, +) -> Result { let mut last_err: Option = None; let attempts = BUNDLE_EXTRACTION_RETRIES + 1; for attempt in 1..=attempts { match extract_bundle_tokens_once(client).await { Ok((tokens, version)) => { - save_cached_bundle(&CachedBundle { - bundle_version: version, - app_id: tokens.app_id.clone(), - secrets: tokens.secrets.clone(), - private_key: tokens.private_key.clone(), - fetched_at: now_unix(), - }); + save_cached_bundle( + &CachedBundle { + bundle_version: version, + app_id: tokens.app_id.clone(), + secrets: tokens.secrets.clone(), + private_key: tokens.private_key.clone(), + fetched_at: now_unix(), + }, + cache_dir, + ); return Ok(tokens); } Err(e) => { @@ -221,12 +275,20 @@ pub async fn extract_and_cache_bundle_tokens(client: &Client) -> Result Option { + refresh_bundle_if_changed_in(client, cached_version, None).await +} + +pub(crate) async fn refresh_bundle_if_changed_in( + client: &Client, + cached_version: &str, + cache_dir: Option<&Path>, ) -> Option { let (_, version) = fetch_bundle_url(client).await.ok()?; if version == cached_version { - if let Some(mut c) = load_cached_bundle() { + if let Some(mut c) = load_cached_bundle_from(cache_dir) { c.fetched_at = now_unix(); - save_cached_bundle(&c); + save_cached_bundle(&c, cache_dir); } log::debug!("[Bundle] Background check: version {} unchanged", version); return None; @@ -236,7 +298,9 @@ pub async fn refresh_bundle_if_changed( cached_version, version ); - extract_and_cache_bundle_tokens(client).await.ok() + extract_and_cache_bundle_tokens_in(client, cache_dir) + .await + .ok() } /// Backwards-compatible one-shot extraction (no caching). Retained for callers @@ -403,6 +467,50 @@ mod tests { assert_eq!(result.unwrap(), "/resources/7.0.1-b001/bundle.js"); } + /// An embedder's root is used verbatim, and the `qbz/` namespace inside it + /// is kept, so the cache has the same name and shape wherever it lands. + #[test] + fn an_explicit_cache_root_keeps_the_qbz_namespace() { + let root = Path::new("/app/cache"); + assert_eq!( + cache_path(Some(root)), + Some(root.join("qbz").join("bundle_tokens.json")) + ); + } + + /// Without one, nothing about the desktop's path changes. + #[test] + fn no_explicit_root_falls_back_to_the_platform_cache_dir() { + assert_eq!( + cache_path(None), + dirs::cache_dir().map(|d| d.join("qbz").join("bundle_tokens.json")) + ); + } + + /// The configurable root from #708 must cover the complete write/read path, + /// not only path construction. The atomic writer also creates its namespace + /// on first use. + #[test] + fn explicit_cache_root_round_trips_through_the_atomic_writer() { + let root = tempfile::tempdir().expect("cache root"); + let expected = CachedBundle { + bundle_version: "8.1.0-b019".to_string(), + app_id: "123456789".to_string(), + secrets: vec!["secret".to_string()], + private_key: Some("private-key".to_string()), + fetched_at: 42, + }; + + save_cached_bundle(&expected, Some(root.path())); + + let actual = load_cached_bundle_from(Some(root.path())).expect("cached bundle"); + assert_eq!(actual.bundle_version, expected.bundle_version); + assert_eq!(actual.app_id, expected.app_id); + assert_eq!(actual.secrets, expected.secrets); + assert_eq!(actual.private_key, expected.private_key); + assert_eq!(actual.fetched_at, expected.fetched_at); + } + #[test] fn test_extract_app_id() { let bundle = r#"production:{api:{appId:"123456789",appSecret:"abc"}"#; @@ -410,4 +518,88 @@ mod tests { assert!(result.is_ok()); assert_eq!(result.unwrap(), "123456789"); } + + /// The invariant the temp-file-and-rename buys: a write that dies partway + /// leaves the tokens that were already there. + /// + /// Swap `atomic_write` back to `std::fs::write` and this fails with an empty + /// file, which is the state a kill during the launch-time `fetched_at` + /// refresh used to leave behind. + #[test] + fn interrupted_cache_write_preserves_the_previous_file() { + let root = tempfile::tempdir().expect("cache root"); + let path = root.path().join("qbz").join("bundle_tokens.json"); + atomic_write(&path, b"previous cache").expect("initial cache write"); + + let result = atomic_write_with(&path, |file| { + file.write_all(b"partial replacement")?; + Err(io::Error::other("simulated interruption")) + }); + + assert!(result.is_err()); + assert_eq!( + std::fs::read(&path).expect("previous cache remains readable"), + b"previous cache" + ); + } + + /// What losing the file costs, and why the invariant above is worth a temp + /// file: there is no partial recovery. Any truncated cache is simply gone, + /// and the next launch pays a full live bundle extraction. + #[test] + fn a_truncated_cache_cannot_be_salvaged() { + let root = tempfile::tempdir().expect("cache root"); + let path = root.path().join("bundle_tokens.json"); + let whole = serde_json::to_vec_pretty(&CachedBundle { + bundle_version: "8.1.0-b019".to_string(), + app_id: "123456789".to_string(), + secrets: vec!["secret".to_string()], + private_key: None, + fetched_at: 1, + }) + .expect("serialize"); + + for kept in [0, whole.len() / 2] { + std::fs::write(&path, &whole[..kept]).expect("write truncated cache"); + let data = std::fs::read(&path).expect("read truncated cache"); + assert!( + serde_json::from_slice::(&data).is_err(), + "{kept} of {} bytes should not parse", + whole.len() + ); + } + } + + /// The replacement file is owner-only, where writing in place left it at + /// whatever the umask allowed (0644 on a default Linux setup). These are + /// app-level values scraped from a public bundle rather than user + /// credentials, so this is tidiness on a shared machine, not a fix for a + /// vulnerability. + #[cfg(unix)] + #[test] + fn cache_file_is_owner_readable_and_writable_only() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().expect("cache root"); + let path = root.path().join("qbz").join("bundle_tokens.json"); + atomic_write(&path, b"cache").expect("cache write"); + + let mode = std::fs::metadata(path) + .expect("cache metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + /// The cache dir is created on demand, so a first run on a clean machine + /// caches its tokens rather than warning and moving on. + #[test] + fn a_missing_cache_directory_is_created() { + let root = tempfile::tempdir().expect("cache root"); + let path = root.path().join("qbz").join("nested").join("tokens.json"); + + atomic_write(&path, b"cache").expect("cache write into a missing directory"); + + assert_eq!(std::fs::read(&path).expect("cache readable"), b"cache"); + } } diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 165f3b7ec..8112c287f 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -2,6 +2,7 @@ use reqwest::{Client, StatusCode}; use serde_json::Value; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; @@ -10,6 +11,7 @@ use super::auth::{ sign_request, sign_search, sign_session_start, }; use super::bundle::{self, BundleTokens}; +use super::delegated::{DelegatedApiConfigError, DelegatedAppCredentials}; use super::endpoints::{self, paths}; use super::error::{ApiError, Result}; use super::forbidden_breaker::ForbiddenBreaker; @@ -17,28 +19,36 @@ use super::lyrics::{ merge_translation_into, QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsUrls, }; use qbz_models::*; +use zeroize::Zeroizing; + +/// Only the token-restoration POST; shared streaming/download requests remain unbounded. +const TOKEN_RESTORE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"; -/// Read a short, log-safe preview of a response body — for diagnosing an -/// unexpected non-2xx (e.g. distinguishing an edge/WAF HTML 403 from the API's -/// JSON error envelope, issue #637). Bounded so a large/HTML body can't bloat -/// the log; prefixed with " : " so it reads well appended to an error message. +/// Return a bounded, content-free diagnostic for an unexpected response body. +/// +/// Remote bodies are untrusted and can contain credentials or echo signed +/// request parameters. They must never enter logs or a displayable `ApiError`. +/// We retain only whether a body was present. async fn body_preview(response: reqwest::Response) -> String { match response.text().await { Ok(body) => { - let trimmed = body.trim(); - if trimmed.is_empty() { - " : ".to_string() - } else { - let preview: String = trimmed.chars().take(200).collect(); - format!(" : {preview}") - } + let body = Zeroizing::new(body); + redacted_body_diagnostic(body.as_str()).to_string() } Err(_) => String::new(), } } +fn redacted_body_diagnostic(body: &str) -> &'static str { + if body.trim().is_empty() { + " : " + } else { + " : " + } +} + /// CMAF session state (session/start + infos for key derivation) struct CmafSession { session_id: String, @@ -46,14 +56,50 @@ struct CmafSession { expires_at: u64, } +/// The app secret which has actually passed the API probe, together with the +/// app ID of the bundle generation it belongs to. +/// +/// Keeping the provenance beside the secret prevents a background bundle +/// rotation from silently pairing a previously validated secret with a new +/// app ID. The secret is app-level material, never a user session token, and +/// is wiped when replaced or when the last client owner is dropped. +struct ValidatedBundleSecret { + app_id: String, + signing_secret: Zeroizing, +} + +impl ValidatedBundleSecret { + fn new(app_id: String, signing_secret: String) -> Self { + Self { + app_id, + signing_secret: Zeroizing::new(signing_secret), + } + } + + fn is_current_for(&self, tokens: &BundleTokens) -> bool { + self.app_id == tokens.app_id + && tokens + .secrets + .iter() + .any(|candidate| candidate == self.signing_secret.as_str()) + } + + fn copy_material(&self) -> (String, String) { + (self.app_id.clone(), self.signing_secret.to_string()) + } +} + /// Qobuz API client pub struct QobuzClient { http: Client, tokens: Arc>>, session: Arc>>, - validated_secret: Arc>>, + validated_secret: Arc>>, locale: Arc>, cmaf_session: Arc>>, + /// Where the regenerable bundle-token cache goes, when the embedder rather + /// than the platform decides. `None` keeps `dirs::cache_dir()`. + bundle_cache_dir: Option, /// Backs off the hot streaming/favorites paths after repeated 403s so a /// post-outage account hiccup can't be escalated into a per-IP edge block /// by the no-backoff prefetch scheduler (issue #637). @@ -69,14 +115,27 @@ impl Clone for QobuzClient { validated_secret: Arc::clone(&self.validated_secret), locale: Arc::clone(&self.locale), cmaf_session: Arc::clone(&self.cmaf_session), + bundle_cache_dir: self.bundle_cache_dir.clone(), forbidden_breaker: Arc::clone(&self.forbidden_breaker), } } } impl QobuzClient { - /// Create a new client + /// Create a new client, caching bundle tokens under the platform cache + /// directory. pub fn new() -> Result { + Self::build(None) + } + + /// Create a client whose regenerable bundle-token cache lives beneath + /// `cache_dir`, for hosts that are handed their cache location rather than + /// deriving it from the environment. + pub fn with_cache_dir(cache_dir: PathBuf) -> Result { + Self::build(Some(cache_dir)) + } + + fn build(bundle_cache_dir: Option) -> Result { let http = Client::builder() .user_agent(USER_AGENT) .cookie_store(true) @@ -93,6 +152,7 @@ impl QobuzClient { validated_secret: Arc::new(RwLock::new(None)), locale: Arc::new(RwLock::new("en".to_string())), cmaf_session: Arc::new(RwLock::new(None)), + bundle_cache_dir, forbidden_breaker: Arc::new(ForbiddenBreaker::new()), }) } @@ -147,10 +207,11 @@ impl QobuzClient { /// a live extraction (cold) — callers can use this to drive a "connecting" /// UI only when it actually matters. pub async fn init(&self) -> Result { - if let Some(cached) = bundle::load_cached_bundle() { + if let Some(cached) = bundle::load_cached_bundle_from(self.bundle_cache_dir.as_deref()) { let version = cached.bundle_version.clone(); log::info!("[Bundle] Using cached tokens (version {})", version); *self.tokens.write().await = Some(cached.into()); + *self.validated_secret.write().await = None; // Cache reads are never gated, but the background refresh is a // network request — gate it once before cloning the client into @@ -160,11 +221,18 @@ impl QobuzClient { Ok(client) => { let client = client.clone(); let tokens_arc = Arc::clone(&self.tokens); + let validated_secret = Arc::clone(&self.validated_secret); + let cache_dir = self.bundle_cache_dir.clone(); tokio::spawn(async move { - if let Some(fresh) = - bundle::refresh_bundle_if_changed(&client, &version).await + if let Some(fresh) = bundle::refresh_bundle_if_changed_in( + &client, + &version, + cache_dir.as_deref(), + ) + .await { *tokens_arc.write().await = Some(fresh); + *validated_secret.write().await = None; log::info!("[Bundle] Background refresh applied rotated tokens"); } }); @@ -180,8 +248,13 @@ impl QobuzClient { // Cold start: a live bundle fetch is a network request — gated on // purpose so an offline cold start fails fast instead of waiting out // the network timeouts. - let tokens = bundle::extract_and_cache_bundle_tokens(self.http()?).await?; + let tokens = bundle::extract_and_cache_bundle_tokens_in( + self.http()?, + self.bundle_cache_dir.as_deref(), + ) + .await?; *self.tokens.write().await = Some(tokens); + *self.validated_secret.write().await = None; Ok(false) } @@ -232,29 +305,74 @@ impl QobuzClient { Ok(&self.http) } - /// Get validated secret (validates on first use) - pub(crate) async fn secret(&self) -> Result { - // Check if we already have a validated secret - if let Some(secret) = self.validated_secret.read().await.clone() { - return Ok(secret); - } + /// Return app ID and signing secret from one validated bundle generation. + async fn validated_app_material(&self) -> Result<(String, String)> { + let (app_id, candidates) = { + let tokens = self.tokens.read().await; + let tokens = tokens.as_ref().ok_or_else(|| { + ApiError::BundleExtractionError("Client not initialized".to_string()) + })?; - // Need to validate secrets - let tokens = self.tokens.read().await; - let tokens = tokens - .as_ref() - .ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?; + if let Some(validated) = self.validated_secret.read().await.as_ref() { + if validated.is_current_for(tokens) { + return Ok(validated.copy_material()); + } + } - for secret in &tokens.secrets { + ( + tokens.app_id.clone(), + Zeroizing::new(tokens.secrets.clone()), + ) + }; + + for secret in candidates.iter() { if self.test_secret(secret).await? { - *self.validated_secret.write().await = Some(secret.clone()); - return Ok(secret.clone()); + // A background refresh is allowed while the network probe is + // running. Accept its result only if the exact app material is + // still present in the current bundle generation. + let tokens = self.tokens.read().await; + let current = tokens.as_ref().ok_or_else(|| { + ApiError::BundleExtractionError("Client not initialized".to_string()) + })?; + if current.app_id != app_id + || !current.secrets.iter().any(|candidate| candidate == secret) + { + return Err(ApiError::BundleExtractionError( + "Bundle credentials changed during validation".to_string(), + )); + } + + let validated = ValidatedBundleSecret::new(app_id.clone(), secret.clone()); + let material = validated.copy_material(); + *self.validated_secret.write().await = Some(validated); + return Ok(material); } } Err(ApiError::InvalidAppSecret) } + /// Get the validated signing secret (validates bundle candidates on first use). + pub(crate) async fn secret(&self) -> Result { + self.validated_app_material() + .await + .map(|(_, signing_secret)| signing_secret) + } + + /// Copy only validated app-level material into an isolated QConnect guest. + /// + /// The returned value does not contain, share, or copy this client's + /// [`UserSession`], user auth token, OAuth private key, cookies, or HTTP + /// client. In particular, it uses the secret selected by the existing API + /// validation probe; it never guesses by taking the first bundle secret. + pub async fn delegated_app_credentials(&self) -> Result { + let (app_id, signing_secret) = self.validated_app_material().await?; + DelegatedAppCredentials::new(app_id, signing_secret).map_err(|error| match error { + DelegatedApiConfigError::InvalidAppId => ApiError::InvalidAppId, + _ => ApiError::InvalidAppSecret, + }) + } + /// Test if a secret is valid using a known track async fn test_secret(&self, secret: &str) -> Result { let test_track_id = 5966783u64; // Known test track @@ -310,6 +428,39 @@ impl QobuzClient { } } + /// Raw `POST /user/login` with an OAuth `user_auth_token`: HTTP status + /// and body, verbatim. Same request as [`Self::login_with_token`], but it + /// never parses the payload and never stores a session, so an account + /// the parser rejects today (`IneligibleUser`) can still be captured for + /// the redacted fixtures in `qbz-nix-docs/qobuz-api/`. Probe support + /// only (`examples/member_login_probe.rs`). + pub async fn login_with_token_raw(&self, token: &str) -> Result<(u16, String)> { + use reqwest::header::{HeaderMap, HeaderValue}; + + let app_id = self.app_id().await?; + let mut headers = HeaderMap::new(); + headers.insert( + "X-App-Id", + HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?, + ); + headers.insert( + "X-User-Auth-Token", + HeaderValue::from_str(token) + .map_err(|_| ApiError::AuthenticationError("Invalid token format".into()))?, + ); + let response = self + .http + .post(endpoints::build_url(endpoints::paths::USER_LOGIN)) + .headers(headers) + .header("Content-Type", "text/plain;charset=UTF-8") + .body("extra=partner") + .send() + .await?; + let status = response.status().as_u16(); + let body = response.text().await?; + Ok((status, body)) + } + /// Check if logged in pub async fn is_logged_in(&self) -> bool { self.session.read().await.is_some() @@ -337,25 +488,35 @@ impl QobuzClient { .and_then(|s| s.language_code.clone()) } + /// A copy of the active session (entitlements included), `None` before + /// login. The shared session lifecycle reads the verdict from here. + pub async fn session(&self) -> Option { + self.session.read().await.clone() + } + /// Exchange an OAuth code for a full user session. /// /// This implements the new Qobuz OAuth flow: - /// 1. GET /oauth/callback?code=CODE&private_key=KEY → { token } - /// 2. POST /user/login with X-User-Auth-Token: token, body=extra=partner → UserSession - pub async fn login_with_oauth_code(&self, code: &str) -> Result { + /// OAuth step 1 on its own: `GET /oauth/callback?code&private_key&app_id` + /// → the `user_auth_token`. Nothing is stored; `login_with_oauth_code` + /// is this plus the `/user/login` session fetch, and the member-mode + /// probe uses it alone so it can capture that second response raw. + pub async fn exchange_oauth_code(&self, code: &str) -> Result { use reqwest::header::{HeaderMap, HeaderValue}; - let tokens = self.tokens.read().await; - let tokens = tokens - .as_ref() - .ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?; - let app_id = tokens.app_id.clone(); - let private_key = tokens.private_key.clone().ok_or_else(|| { - ApiError::BundleExtractionError("OAuth private key not available in bundle".to_string()) - })?; - let _ = tokens; // drop read lock + let (app_id, private_key) = { + let guard = self.tokens.read().await; + let tokens = guard.as_ref().ok_or_else(|| { + ApiError::BundleExtractionError("Client not initialized".to_string()) + })?; + let private_key = tokens.private_key.clone().ok_or_else(|| { + ApiError::BundleExtractionError( + "OAuth private key not available in bundle".to_string(), + ) + })?; + (tokens.app_id.clone(), private_key) + }; - // Step 1: Exchange code for token let callback_url = endpoints::build_url(endpoints::paths::OAUTH_CALLBACK); let mut headers = HeaderMap::new(); headers.insert( @@ -386,12 +547,23 @@ impl QobuzClient { } let callback_json: serde_json::Value = callback_response.json().await?; - let token = callback_json["token"] + callback_json["token"] .as_str() + .map(str::to_string) .ok_or_else(|| { ApiError::ApiResponse("OAuth callback: no token in response".to_string()) - })? - .to_string(); + }) + } + + /// 1. GET /oauth/callback?code=CODE&private_key=KEY → { token } + /// 2. POST /user/login with X-User-Auth-Token: token, body=extra=partner → UserSession + pub async fn login_with_oauth_code(&self, code: &str) -> Result { + use reqwest::header::{HeaderMap, HeaderValue}; + + let app_id = self.app_id().await?; + + // Step 1: Exchange code for token + let token = self.exchange_oauth_code(code).await?; log::info!("[OAuth] Got token, fetching user session via /user/login"); @@ -442,8 +614,6 @@ impl QobuzClient { /// token was persisted. Calls POST /user/login with X-User-Auth-Token header. /// Returns an error if the token has expired. pub async fn login_with_token(&self, token: &str) -> Result { - use reqwest::header::{HeaderMap, HeaderValue}; - let tokens = self.tokens.read().await; let app_id = tokens .as_ref() @@ -452,7 +622,23 @@ impl QobuzClient { .clone(); drop(tokens); - let user_login_url = endpoints::build_url(endpoints::paths::USER_LOGIN); + let request = self.token_restore_request( + &endpoints::build_url(endpoints::paths::USER_LOGIN), + &app_id, + token, + )?; + self.complete_token_restore(request).await + } + + /// Private request seam: tests substitute a loopback URL and override the + /// request's duration. Production always uses USER_LOGIN and 15 seconds. + fn token_restore_request( + &self, + url: &str, + app_id: &str, + token: &str, + ) -> Result { + use reqwest::header::{HeaderMap, HeaderValue}; let mut headers = HeaderMap::new(); headers.insert( "X-App-Id", @@ -464,17 +650,25 @@ impl QobuzClient { .map_err(|_| ApiError::AuthenticationError("Invalid token format".into()))?, ); - log::info!("[OAuth] Restoring session from saved token"); - // Auth exemption: raw client, bypasses the offline gate (sign-in is - // explicit user intent to reach Qobuz; the gate governs services). - let resp = self + // Reqwest carries this SAME deadline through headers AND body reads, + // including a body that keeps trickling bytes. No timeout surrounds + // session publication or the caller's subsequent store activation. + Ok(self .http - .post(&user_login_url) + .post(url) .headers(headers) .header("Content-Type", "text/plain;charset=UTF-8") .body("extra=partner") - .send() - .await?; + .timeout(TOKEN_RESTORE_TIMEOUT)) + } + + async fn complete_token_restore( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + log::info!("[OAuth] Restoring session from saved token"); + // Auth exemption: raw client bypasses the offline service gate. + let resp = request.send().await?; match resp.status() { StatusCode::OK => { @@ -629,7 +823,15 @@ impl QobuzClient { let url = endpoints::build_url(paths::ALBUM_SEARCH); let timestamp = get_timestamp(); let secret = self.secret().await?; - let signature = sign_search("albumsearch", query, limit, offset, search_type, timestamp, &secret); + let signature = sign_search( + "albumsearch", + query, + limit, + offset, + search_type, + timestamp, + &secret, + ); let limit_str = limit.to_string(); let offset_str = offset.to_string(); let ts_str = timestamp.to_string(); @@ -675,7 +877,15 @@ impl QobuzClient { let url = endpoints::build_url(paths::TRACK_SEARCH); let timestamp = get_timestamp(); let secret = self.secret().await?; - let signature = sign_search("tracksearch", query, limit, offset, search_type, timestamp, &secret); + let signature = sign_search( + "tracksearch", + query, + limit, + offset, + search_type, + timestamp, + &secret, + ); let limit_str = limit.to_string(); let offset_str = offset.to_string(); let ts_str = timestamp.to_string(); @@ -721,7 +931,15 @@ impl QobuzClient { let url = endpoints::build_url(paths::ARTIST_SEARCH); let timestamp = get_timestamp(); let secret = self.secret().await?; - let signature = sign_search("artistsearch", query, limit, offset, search_type, timestamp, &secret); + let signature = sign_search( + "artistsearch", + query, + limit, + offset, + search_type, + timestamp, + &secret, + ); let limit_str = limit.to_string(); let offset_str = offset.to_string(); let ts_str = timestamp.to_string(); @@ -761,7 +979,15 @@ impl QobuzClient { let url = endpoints::build_url(paths::CATALOG_SEARCH); let timestamp = get_timestamp(); let secret = self.secret().await?; - let signature = sign_search("catalogsearch", query, limit, offset, None, timestamp, &secret); + let signature = sign_search( + "catalogsearch", + query, + limit, + offset, + None, + timestamp, + &secret, + ); let limit_str = limit.to_string(); let offset_str = offset.to_string(); let ts_str = timestamp.to_string(); @@ -793,11 +1019,15 @@ impl QobuzClient { ) -> Result> { let url = endpoints::build_url(paths::ARTIST_GET_SIMILAR); let http_response = self - .signed_get(&url, "artistgetSimilarArtists", &[ - ("artist_id", artist_id.to_string()), - ("limit", limit.to_string()), - ("offset", offset.to_string()), - ]) + .signed_get( + &url, + "artistgetSimilarArtists", + &[ + ("artist_id", artist_id.to_string()), + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ], + ) .await?; log::debug!( "[API] get_similar_artists({}) status={}", @@ -824,13 +1054,17 @@ impl QobuzClient { let locale = self.locale().await; let http_response = self - .signed_get(&url, "artistget", &[ - ("artist_id", artist_id.to_string()), - ("extra", "tracks".to_string()), - ("lang", locale), - ("limit", limit.to_string()), - ("offset", offset.to_string()), - ]) + .signed_get( + &url, + "artistget", + &[ + ("artist_id", artist_id.to_string()), + ("extra", "tracks".to_string()), + ("lang", locale), + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ], + ) .await?; log::debug!( "[API] get_artist_tracks({}) status={}", @@ -898,10 +1132,9 @@ impl QobuzClient { query.push(("genre_id".to_string(), gid.to_string())); } - let params: Vec<(&str, String)> = query.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); - let http_response = self - .signed_get(&url, "albumgetFeatured", ¶ms) - .await?; + let params: Vec<(&str, String)> = + query.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let http_response = self.signed_get(&url, "albumgetFeatured", ¶ms).await?; log::debug!( "[API] get_featured_albums({}) status={}", featured_type, @@ -985,10 +1218,7 @@ impl QobuzClient { if let Some(items_arr) = items_value.as_array_mut() { for item in items_arr { if let Some(obj) = item.as_object_mut() { - let needs_backfill = obj - .get("artist") - .map(|v| v.is_null()) - .unwrap_or(true); + let needs_backfill = obj.get("artist").map(|v| v.is_null()).unwrap_or(true); if needs_backfill { if let Some(first_artist) = obj .get("artists") @@ -1106,17 +1336,20 @@ impl QobuzClient { /// Qobuz radio for an artist (`/radio/artist`) — a generated track /// list. Ported from the legacy api client. pub async fn get_radio_artist(&self, artist_id: &str) -> Result { - self.get_radio(paths::RADIO_ARTIST, "artist_id", artist_id).await + self.get_radio(paths::RADIO_ARTIST, "artist_id", artist_id) + .await } /// Qobuz radio for a track (`/radio/track`). pub async fn get_radio_track(&self, track_id: &str) -> Result { - self.get_radio(paths::RADIO_TRACK, "track_id", track_id).await + self.get_radio(paths::RADIO_TRACK, "track_id", track_id) + .await } /// Qobuz radio for an album (`/radio/album`). pub async fn get_radio_album(&self, album_id: &str) -> Result { - self.get_radio(paths::RADIO_ALBUM, "album_id", album_id).await + self.get_radio(paths::RADIO_ALBUM, "album_id", album_id) + .await } async fn get_radio(&self, path: &str, key: &str, id: &str) -> Result { @@ -1149,7 +1382,14 @@ impl QobuzClient { } let http_response = self - .signed_get(&url, "genrelist", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get( + &url, + "genrelist", + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await?; log::debug!( "[API] get_genres(parent={:?}) status={}", @@ -1187,7 +1427,14 @@ impl QobuzClient { } let http_response = self - .signed_get_auth(&url, "discoverindex", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get_auth( + &url, + "discoverindex", + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await?; log::info!( "[API] get_discover_index genre_ids={:?} status={}", @@ -1241,7 +1488,14 @@ impl QobuzClient { // Derive method name from endpoint path: "/discover/newReleases" -> "discovernewReleases" let method_name = endpoint.replace('/', "").replace('.', ""); let http_response = self - .signed_get_auth(&url, &method_name, &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get_auth( + &url, + &method_name, + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await?; log::info!( "[API] get_discover_albums({}) query={:?} status={}", @@ -1299,7 +1553,14 @@ impl QobuzClient { // First get raw JSON to debug structure let raw_response: serde_json::Value = self - .signed_get_auth(&url, "discoverplaylists", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get_auth( + &url, + "discoverplaylists", + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await? .json() .await?; @@ -1334,9 +1595,7 @@ impl QobuzClient { pub async fn get_playlist_tags(&self) -> Result> { let url = endpoints::build_url(paths::PLAYLIST_GET_TAGS); - let http_response = self - .signed_get_auth(&url, "playlistgetTags", &[]) - .await?; + let http_response = self.signed_get_auth(&url, "playlistgetTags", &[]).await?; log::info!("[API] get_playlist_tags status={}", http_response.status()); let raw: PlaylistTagsResponse = http_response.json().await?; @@ -1574,9 +1833,7 @@ impl QobuzClient { match serde_json::from_str::(&body) { Ok(mut doc) if doc.original.is_some() || doc.translation.is_some() => { if language.is_some() && doc.original.is_some() && doc.translation.is_none() { - doc.translation = self - .fetch_translation_fallback(track_id, &urls) - .await; + doc.translation = self.fetch_translation_fallback(track_id, &urls).await; } Ok(Some(doc)) } @@ -1648,7 +1905,14 @@ impl QobuzClient { ]; let http_response = self - .signed_get(&url, "artistget", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get( + &url, + "artistget", + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await?; log::debug!( "[API] get_artist_basic({}) status={}", @@ -1688,7 +1952,14 @@ impl QobuzClient { } let http_response = self - .signed_get(&url, "artistget", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get( + &url, + "artistget", + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await?; log::debug!( "[API] get_artist_detail({}) status={}", @@ -1739,7 +2010,14 @@ impl QobuzClient { } let http_response = self - .signed_get(&url, "artistget", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get( + &url, + "artistget", + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await?; log::debug!( "[API] get_artist({}, albums={}) status={}", @@ -1758,10 +2036,14 @@ impl QobuzClient { pub async fn get_playlist_track_ids(&self, playlist_id: u64) -> Result { let url = endpoints::build_url(paths::PLAYLIST_GET); let http_response = self - .signed_get(&url, "playlistget", &[ - ("playlist_id", playlist_id.to_string()), - ("extra", "track_ids".to_string()), - ]) + .signed_get( + &url, + "playlistget", + &[ + ("playlist_id", playlist_id.to_string()), + ("extra", "track_ids".to_string()), + ], + ) .await?; log::debug!( "[API] get_playlist_track_ids({}) status={}", @@ -1813,8 +2095,17 @@ impl QobuzClient { let headers = self.api_headers().await?; let timestamp = get_timestamp(); let secret = self.secret().await?; - let ids_str: String = track_ids.iter().map(|id| id.to_string()).collect::>().join(","); - let sig = sign_request("trackgetList", &[("tracks_id", &ids_str)], timestamp, &secret); + let ids_str: String = track_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let sig = sign_request( + "trackgetList", + &[("tracks_id", &ids_str)], + timestamp, + &secret, + ); let body = serde_json::json!({ "tracks_id": track_ids }); log::debug!("[API] get_tracks_batch POST ({} IDs)", track_ids.len()); @@ -1866,12 +2157,16 @@ impl QobuzClient { // First page — gives us metadata + total track count let http_response = self - .signed_get(&url, "playlistget", &[ - ("playlist_id", playlist_id.to_string()), - ("limit", PAGE_SIZE.to_string()), - ("offset", "0".to_string()), - ("extra", "tracks".to_string()), - ]) + .signed_get( + &url, + "playlistget", + &[ + ("playlist_id", playlist_id.to_string()), + ("limit", PAGE_SIZE.to_string()), + ("offset", "0".to_string()), + ("extra", "tracks".to_string()), + ], + ) .await?; log::debug!( "[API] get_playlist({}) status={}", @@ -1919,7 +2214,12 @@ impl QobuzClient { let ts = get_timestamp(); let sig = sign_request( "playlistget", - &[("extra", "tracks"), ("limit", &limit), ("offset", &offset_str), ("playlist_id", &pid)], + &[ + ("extra", "tracks"), + ("limit", &limit), + ("offset", &offset_str), + ("playlist_id", &pid), + ], ts, &secret, ); @@ -2002,21 +2302,18 @@ impl QobuzClient { /// Enumerate the award catalog (/award/explore). Paginated. /// Returns raw JSON items so callers can extract just (id, name). - pub async fn get_award_explore( - &self, - limit: u32, - offset: u32, - ) -> Result { + pub async fn get_award_explore(&self, limit: u32, offset: u32) -> Result { let url = endpoints::build_url(paths::AWARD_EXPLORE); - log::debug!("[API] get_award_explore(limit={}, offset={})", limit, offset); + log::debug!( + "[API] get_award_explore(limit={}, offset={})", + limit, + offset + ); let response: serde_json::Value = self .signed_get_auth( &url, "awardexplore", - &[ - ("limit", limit.to_string()), - ("offset", offset.to_string()), - ], + &[("limit", limit.to_string()), ("offset", offset.to_string())], ) .await? .json() @@ -2087,14 +2384,14 @@ impl QobuzClient { } // Current shape: V2AlbumGenericListDto {has_more, items}. - let mut items_value = response.get("items").cloned().unwrap_or(serde_json::Value::Null); + let mut items_value = response + .get("items") + .cloned() + .unwrap_or(serde_json::Value::Null); if let Some(items_arr) = items_value.as_array_mut() { for item in items_arr { if let Some(obj) = item.as_object_mut() { - let needs_backfill = obj - .get("artist") - .map(|v| v.is_null()) - .unwrap_or(true); + let needs_backfill = obj.get("artist").map(|v| v.is_null()).unwrap_or(true); if needs_backfill { if let Some(first_artist) = obj .get("artists") @@ -2150,7 +2447,11 @@ impl QobuzClient { offset ); let response: serde_json::Value = self - .signed_get(&url, "labelexplore", &[("limit", limit.to_string()), ("offset", offset.to_string())]) + .signed_get( + &url, + "labelexplore", + &[("limit", limit.to_string()), ("offset", offset.to_string())], + ) .await? .json() .await?; @@ -2179,13 +2480,28 @@ impl QobuzClient { ("limit", limit.to_string()), ("offset", offset.to_string()), ]; - if let Some(v) = sort { params.push(("sort", v.to_string())); } - if let Some(v) = order { params.push(("order", v.to_string())); } - if let Some(v) = genre_ids { params.push(("genre_ids", v.to_string())); } - if let Some(v) = from_date { params.push(("from_date", v.to_string())); } - if let Some(v) = to_date { params.push(("to_date", v.to_string())); } + if let Some(v) = sort { + params.push(("sort", v.to_string())); + } + if let Some(v) = order { + params.push(("order", v.to_string())); + } + if let Some(v) = genre_ids { + params.push(("genre_ids", v.to_string())); + } + if let Some(v) = from_date { + params.push(("from_date", v.to_string())); + } + if let Some(v) = to_date { + params.push(("to_date", v.to_string())); + } - log::debug!("[API] get_label_albums({}, limit={}, offset={})", label_id, limit, offset); + log::debug!( + "[API] get_label_albums({}, limit={}, offset={})", + label_id, + limit, + offset + ); let response: Value = self .signed_get(&url, "labelgetalbums", ¶ms) .await? @@ -2208,7 +2524,9 @@ impl QobuzClient { ("limit", limit.to_string()), ("offset", offset.to_string()), ]; - if let Some(v) = genre_ids { params.push(("genre_ids", v.to_string())); } + if let Some(v) = genre_ids { + params.push(("genre_ids", v.to_string())); + } log::debug!("[API] get_label_next_releases({})", label_id); let response: Value = self @@ -2235,9 +2553,15 @@ impl QobuzClient { ("limit", limit.to_string()), ("offset", offset.to_string()), ]; - if let Some(v) = sort { params.push(("sort", v.to_string())); } - if let Some(v) = order { params.push(("order", v.to_string())); } - if let Some(v) = genre_ids { params.push(("genre_ids", v.to_string())); } + if let Some(v) = sort { + params.push(("sort", v.to_string())); + } + if let Some(v) = order { + params.push(("order", v.to_string())); + } + if let Some(v) = genre_ids { + params.push(("genre_ids", v.to_string())); + } log::debug!("[API] get_label_awarded_releases({})", label_id); let response: Value = self @@ -2258,11 +2582,15 @@ impl QobuzClient { let url = endpoints::build_url(paths::LABEL_GET_PLAYLISTS); log::debug!("[API] get_label_playlists({})", label_id); let response: Value = self - .signed_get(&url, "labelgetplaylists", &[ - ("label_id", label_id.to_string()), - ("limit", limit.to_string()), - ("offset", offset.to_string()), - ]) + .signed_get( + &url, + "labelgetplaylists", + &[ + ("label_id", label_id.to_string()), + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ], + ) .await? .json() .await?; @@ -2279,11 +2607,15 @@ impl QobuzClient { let url = endpoints::build_url(paths::LABEL_GET_TOP_ARTISTS); log::debug!("[API] get_label_top_artists({})", label_id); let response: Value = self - .signed_get(&url, "labelgettopartists", &[ - ("label_id", label_id.to_string()), - ("limit", limit.to_string()), - ("offset", offset.to_string()), - ]) + .signed_get( + &url, + "labelgettopartists", + &[ + ("label_id", label_id.to_string()), + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ], + ) .await? .json() .await?; @@ -2300,11 +2632,15 @@ impl QobuzClient { let url = endpoints::build_url(paths::LABEL_STORY); log::debug!("[API] get_label_story({})", label_id); let response: Value = self - .signed_get(&url, "labelstory", &[ - ("label_id", label_id.to_string()), - ("limit", limit.to_string()), - ("offset", offset.to_string()), - ]) + .signed_get( + &url, + "labelstory", + &[ + ("label_id", label_id.to_string()), + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ], + ) .await? .json() .await?; @@ -2326,7 +2662,12 @@ impl QobuzClient { .map(|id| id.to_string()) .collect::>() .join(","); - let sig = sign_request("labelgetList", &[("label_ids", &ids_str)], timestamp, &secret); + let sig = sign_request( + "labelgetList", + &[("label_ids", &ids_str)], + timestamp, + &secret, + ); let body = serde_json::json!({ "label_ids": label_ids }); log::debug!("[API] get_label_list POST ({} ids)", label_ids.len()); @@ -2347,6 +2688,38 @@ impl QobuzClient { // === Authenticated endpoints === /// Get stream URL for a track (requires auth + signature) + /// Raw `/track/getFileUrl` (`intent=stream`): HTTP status and body, + /// verbatim, no parsing. Probe support only — the typed reader is + /// [`Self::get_stream_url`]. Same signing, same headers, same breaker. + pub async fn get_stream_url_raw( + &self, + track_id: u64, + quality: Quality, + ) -> Result<(u16, String)> { + self.forbidden_guard()?; + let url = endpoints::build_url(paths::TRACK_GET_FILE_URL); + let timestamp = get_timestamp(); + let secret = self.secret().await?; + let signature = sign_get_file_url(track_id, quality.id(), timestamp, &secret); + let response = self + .http()? + .get(&url) + .headers(self.authenticated_headers().await?) + .query(&[ + ("track_id", track_id.to_string()), + ("format_id", quality.id().to_string()), + ("intent", "stream".to_string()), + ("request_ts", timestamp.to_string()), + ("request_sig", signature), + ]) + .send() + .await?; + let status = response.status(); + self.note_forbidden_status(status); + let body = response.text().await?; + Ok((status.as_u16(), body)) + } + pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result { // Back off before the network if the 403 breaker is open (issue #637). self.forbidden_guard()?; @@ -2417,6 +2790,7 @@ impl QobuzClient { bit_depth: json["bit_depth"].as_u64().map(|v| v as u32), track_id, restrictions, + sample: json["sample"].as_bool().unwrap_or(false), }) } StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret), @@ -2473,9 +2847,11 @@ impl QobuzClient { // restriction — every quality would 403 the same way. Abort the // whole fallback loop immediately instead of firing 5 more // requests per track and feeding the storm (issue #637). - Err(e @ (ApiError::Forbidden(_) - | ApiError::ForbiddenCircuitOpen(_) - | ApiError::AuthenticationError(_))) => { + Err( + e @ (ApiError::Forbidden(_) + | ApiError::ForbiddenCircuitOpen(_) + | ApiError::AuthenticationError(_)), + ) => { log::warn!("Stream URL aborting quality fallback: {}", e); return Err(e); } @@ -2573,7 +2949,15 @@ impl QobuzClient { let url = endpoints::build_url(paths::PLAYLIST_SEARCH); let timestamp = get_timestamp(); let secret = self.secret().await?; - let signature = sign_search("playlistsearch", query, limit, offset, None, timestamp, &secret); + let signature = sign_search( + "playlistsearch", + query, + limit, + offset, + None, + timestamp, + &secret, + ); let limit_str = limit.to_string(); let offset_str = offset.to_string(); let ts_str = timestamp.to_string(); @@ -2632,8 +3016,12 @@ impl QobuzClient { pub async fn delete_playlist(&self, playlist_id: u64) -> Result<()> { let url = endpoints::build_url(paths::PLAYLIST_DELETE); - self.signed_get_auth(&url, "playlistdelete", &[("playlist_id", playlist_id.to_string())]) - .await?; + self.signed_get_auth( + &url, + "playlistdelete", + &[("playlist_id", playlist_id.to_string())], + ) + .await?; Ok(()) } @@ -2647,10 +3035,14 @@ impl QobuzClient { .collect::>() .join(","); - self.signed_get_auth(&url, "playlistaddTracks", &[ - ("playlist_id", playlist_id.to_string()), - ("track_ids", track_ids_str), - ]) + self.signed_get_auth( + &url, + "playlistaddTracks", + &[ + ("playlist_id", playlist_id.to_string()), + ("track_ids", track_ids_str), + ], + ) .await?; Ok(()) @@ -2669,10 +3061,73 @@ impl QobuzClient { .collect::>() .join(","); - self.signed_get_auth(&url, "playlistdeleteTracks", &[ - ("playlist_id", playlist_id.to_string()), - ("playlist_track_ids", track_ids_str), - ]) + self.signed_get_auth( + &url, + "playlistdeleteTracks", + &[ + ("playlist_id", playlist_id.to_string()), + ("playlist_track_ids", track_ids_str), + ], + ) + .await?; + + Ok(()) + } + + /// Move tracks WITHIN a playlist — `/playlist/updateTracksPosition`. + /// + /// `playlist_track_ids` are MEMBERSHIP ids, the same id space + /// [`Self::remove_tracks_from_playlist`] takes — not catalog track ids. + /// `insert_before` is the 0-based slot the moved rows must land in front + /// of, counted against the playlist AS IT IS at call time. + /// + /// # Provenance, because this one is not in the current spec + /// + /// The argument shape is the DESKTOP web-player capture's + /// (`qbz-nix-docs/qobuz-api-delta-desktop-8.2.0-b015.md:95` — + /// `{playlist_id, playlist_track_ids, insert_before}`). The decompiled + /// Android route table lists the same path, and that entry is corroboration + /// and nothing more: CLAUDE.md forbids inferring endpoints from it. + /// + /// GET + [`Self::signed_get_auth`], exactly like the two neighbours this + /// call is sequenced with. The capture records a POST, but every playlist + /// write in this client is a signed GET against api.json/0.2 — including + /// `addTracks` and `deleteTracks`, whose captures were POSTs too — and the + /// signature method name follows the same `` rule the + /// neighbours use, hence `playlistupdateTracksPosition`. + /// + /// The exact reading of `insert_before` is the ONE part of this verb no + /// capture in the repo pins down, so the request is logged in full at debug + /// level and callers must treat a successful-but-wrong move as cosmetic — + /// never as a reason to abort a repair that already landed. + pub async fn update_playlist_tracks_position( + &self, + playlist_id: u64, + playlist_track_ids: &[u64], + insert_before: u32, + ) -> Result<()> { + let url = endpoints::build_url(paths::PLAYLIST_UPDATE_TRACKS_POSITION); + let track_ids_str = playlist_track_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + log::debug!( + "[API] updateTracksPosition playlist_id={} playlist_track_ids={} insert_before={}", + playlist_id, + track_ids_str, + insert_before + ); + + self.signed_get_auth( + &url, + "playlistupdateTracksPosition", + &[ + ("playlist_id", playlist_id.to_string()), + ("playlist_track_ids", track_ids_str), + ("insert_before", insert_before.to_string()), + ], + ) .await?; Ok(()) @@ -2714,7 +3169,11 @@ impl QobuzClient { let url = endpoints::build_url(paths::PLAYLIST_SUBSCRIBE); let response = self - .signed_get_auth(&url, "playlistsubscribe", &[("playlist_id", playlist_id.to_string())]) + .signed_get_auth( + &url, + "playlistsubscribe", + &[("playlist_id", playlist_id.to_string())], + ) .await?; let status = response.status(); @@ -2734,7 +3193,11 @@ impl QobuzClient { let url = endpoints::build_url(paths::PLAYLIST_UNSUBSCRIBE); let response = self - .signed_get_auth(&url, "playlistunsubscribe", &[("playlist_id", playlist_id.to_string())]) + .signed_get_auth( + &url, + "playlistunsubscribe", + &[("playlist_id", playlist_id.to_string())], + ) .await?; let status = response.status(); @@ -2755,7 +3218,11 @@ impl QobuzClient { let type_key = format!("{}_ids", fav_type); // album_ids, track_ids, artist_ids let response = self - .signed_get_auth(&url, "favoritecreate", &[(type_key.as_str(), item_id.to_string())]) + .signed_get_auth( + &url, + "favoritecreate", + &[(type_key.as_str(), item_id.to_string())], + ) .await?; if response.status().is_success() { @@ -2774,7 +3241,11 @@ impl QobuzClient { let type_key = format!("{}_ids", fav_type); let response = self - .signed_get_auth(&url, "favoritedelete", &[(type_key.as_str(), item_id.to_string())]) + .signed_get_auth( + &url, + "favoritedelete", + &[(type_key.as_str(), item_id.to_string())], + ) .await?; if response.status().is_success() { @@ -2803,7 +3274,14 @@ impl QobuzClient { log::debug!("[API] get_artist_page({}) sort={:?}", artist_id, sort); let response: serde_json::Value = self - .signed_get(&url, "artistpage", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get( + &url, + "artistpage", + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await? .json() .await?; @@ -2839,7 +3317,14 @@ impl QobuzClient { offset ); let response: serde_json::Value = self - .signed_get(&url, "artistgetReleasesGrid", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get( + &url, + "artistgetReleasesGrid", + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await? .json() .await?; @@ -2869,7 +3354,14 @@ impl QobuzClient { limit ); let response: serde_json::Value = self - .signed_get(&url, "artiststory", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .signed_get( + &url, + "artiststory", + &query + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ) .await? .json() .await?; @@ -2979,11 +3471,7 @@ impl QobuzClient { /// /// This is the new streaming endpoint that returns encrypted CMAF segments /// instead of a direct file URL. - pub async fn get_file_url( - &self, - track_id: u64, - quality: Quality, - ) -> Result { + pub async fn get_file_url(&self, track_id: u64, quality: Quality) -> Result { let format_id = quality.id(); let url = endpoints::build_url(paths::FILE_URL); @@ -3044,11 +3532,7 @@ impl QobuzClient { let code = status.as_u16(); if status == reqwest::StatusCode::FORBIDDEN { let preview = body_preview(response).await; - log::warn!( - "[CMAF] file/url 403 for track {}{}", - track_id, - preview - ); + log::warn!("[CMAF] file/url 403 for track {}{}", track_id, preview); return Err(ApiError::Forbidden(preview)); } return Err(if code == 404 { @@ -3065,10 +3549,7 @@ impl QobuzClient { } else if status.is_server_error() { ApiError::ServerError(code) } else { - ApiError::ApiResponse(format!( - "file/url failed with status {}", - status - )) + ApiError::ApiResponse(format!("file/url failed with status {}", status)) }); } @@ -3098,6 +3579,108 @@ impl Default for QobuzClient { mod tests { use super::*; + #[test] + fn remote_body_diagnostic_never_echoes_payload() { + let marker = "jwt=secret&request_sig=signed-value"; + let diagnostic = redacted_body_diagnostic(marker); + assert_eq!(diagnostic, " : "); + assert!(!diagnostic.contains(marker)); + assert!(!diagnostic.contains("secret")); + assert_eq!(redacted_body_diagnostic(" \n"), " : "); + } + + /// Install the process-level rustls `CryptoProvider`, once. + /// + /// UNRELATED DRIVE-BY FIX, 2026-08-16: the two gate tests below were failing + /// on every run — `reqwest` is built with `rustls-tls-webpki-roots-no-provider`, + /// so BUILDING a client panics with "No provider set" until a provider is + /// installed, and a test binary has no application startup to do it. The + /// application installs it via `qbz_app::ensure_crypto_provider`, but this + /// crate sits below `qbz-app` and cannot call that, so it installs its own. + /// `install_default` is idempotent and returns `Err` if someone else won the + /// race, which is why the result is discarded. + fn ensure_crypto_provider() { + use std::sync::Once; + static INIT: Once = Once::new(); + INIT.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); + } + + #[tokio::test] + async fn delegated_credentials_use_the_validated_secret_not_the_first_candidate() { + ensure_crypto_provider(); + let client = QobuzClient::new().expect("client construction is local-only"); + let app_id = "123456789"; + let first_candidate = "first-unvalidated-secret"; + let selected_secret = "second-validated-secret"; + + *client.tokens.write().await = Some(BundleTokens { + app_id: app_id.to_string(), + secrets: vec![first_candidate.to_string(), selected_secret.to_string()], + private_key: Some("owner-oauth-private-key".to_string()), + }); + *client.validated_secret.write().await = Some(ValidatedBundleSecret::new( + app_id.to_string(), + selected_secret.to_string(), + )); + *client.session.write().await = Some(UserSession { + user_auth_token: "owner-user-auth-token".to_string(), + user_id: 7, + email: "owner@example.test".to_string(), + display_name: "Owner".to_string(), + subscription_label: "Studio".to_string(), + country_code: Some("MX".to_string()), + language_code: Some("es".to_string()), + ..UserSession::default() + }); + + let credentials = client.delegated_app_credentials().await.unwrap(); + let (delegated_app_id, delegated_secret) = credentials.values_for_test(); + assert_eq!(delegated_app_id, app_id); + assert_eq!(delegated_secret, selected_secret); + assert_ne!(delegated_secret, first_candidate); + assert_ne!(delegated_secret, "owner-oauth-private-key"); + assert_ne!(delegated_secret, "owner-user-auth-token"); + } + + #[test] + fn validated_secret_is_bound_to_its_bundle_generation() { + let validated = + ValidatedBundleSecret::new("123456789".to_string(), "selected-secret".to_string()); + let matching = BundleTokens { + app_id: "123456789".to_string(), + secrets: vec!["other-secret".to_string(), "selected-secret".to_string()], + private_key: None, + }; + let rotated_secret = BundleTokens { + app_id: "123456789".to_string(), + secrets: vec!["replacement-secret".to_string()], + private_key: None, + }; + let rotated_app = BundleTokens { + app_id: "987654321".to_string(), + secrets: vec!["selected-secret".to_string()], + private_key: None, + }; + + assert!(validated.is_current_for(&matching)); + assert!(!validated.is_current_for(&rotated_secret)); + assert!(!validated.is_current_for(&rotated_app)); + } + + #[tokio::test] + async fn delegated_credentials_require_an_initialized_owner_client() { + ensure_crypto_provider(); + let client = QobuzClient::new().expect("client construction is local-only"); + + assert!(matches!( + client.delegated_app_credentials().await, + Err(ApiError::BundleExtractionError(message)) + if message == "Client not initialized" + )); + } + /// With the offline gate closed, any public API method must fail fast /// with the typed `ApiError::OfflineMode` — no network access, no /// connect timeout. The gate is process-global and tests run in @@ -3105,6 +3688,7 @@ mod tests { /// drop guard reopens the gate even if the test panics. #[tokio::test] async fn offline_gate_fails_fast_with_typed_error() { + ensure_crypto_provider(); let _lock = crate::offline_gate::test_lock() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -3138,6 +3722,7 @@ mod tests { /// and without touching the network. #[tokio::test] async fn offline_gate_exempts_login_methods() { + ensure_crypto_provider(); let _lock = crate::offline_gate::test_lock() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -3178,3 +3763,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "saved_session_tests.rs"] +mod saved_session_tests; diff --git a/crates/qbz-qobuz/src/cmaf.rs b/crates/qbz-qobuz/src/cmaf.rs index c112b3083..2512c90ac 100644 --- a/crates/qbz-qobuz/src/cmaf.rs +++ b/crates/qbz-qobuz/src/cmaf.rs @@ -34,6 +34,7 @@ //! playback time. This is the security-sensitive path. use std::sync::Arc; +use std::time::Duration; use qbz_models::{Quality, StreamQualityInfo}; @@ -45,6 +46,15 @@ use crate::error::Result; /// past ~5 parallel requests per client IP. pub const CMAF_PREFETCH_CONCURRENCY: usize = 3; +/// Total deadline for one CMAF CDN fetch attempt, from connect through the +/// complete response body. Qobuz audio segments are independently retryable; +/// leaving a single body read unbounded can strand both the live feeder and +/// the gapless successor fetch long after the audible buffer has run dry. +/// +/// The retry layer makes up to three attempts, so this is deliberately an +/// attempt deadline rather than a whole-track deadline. +pub const CMAF_FETCH_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(15); + /// Progress callback shape for the download helpers. Each call reports /// "k of n segments complete" with the bytes received for that segment, /// so the caller can emit UI progress events without knowing the CMAF @@ -144,7 +154,7 @@ pub async fn setup_streaming( let init_start = std::time::Instant::now(); log::info!("[CMAF] Fetching init segment for track {}", track_id); - let init_data = fetch_bytes_with_retry(&http, &init_url, "CMAF init") + let init_data = fetch_cdn_bytes_with_retry(&http, &init_url, "CMAF init") .await .map_err(|e| format!("Failed to fetch init segment: {}", e))?; @@ -380,34 +390,70 @@ fn build_cdn_client() -> std::result::Result { /// 5xx, 429) with exponential backoff. A terminal status (404/403) fails /// immediately. Without this a single transient segment failure aborted the /// whole track download and the frontend skipped it — issue #467. -async fn fetch_bytes_with_retry( +pub async fn fetch_cdn_bytes_with_retry( http: &reqwest::Client, url: &str, log_tag: &str, ) -> std::result::Result, String> { - use crate::retry::{ - classify_reqwest, classify_status, retry_transient, FetchError, DEFAULT_MAX_ATTEMPTS, - }; + fetch_cdn_bytes_with_retry_config( + http, + url, + log_tag, + CMAF_FETCH_ATTEMPT_TIMEOUT, + crate::retry::DEFAULT_MAX_ATTEMPTS, + ) + .await +} + +async fn fetch_cdn_bytes_with_retry_config( + http: &reqwest::Client, + url: &str, + log_tag: &str, + attempt_timeout: Duration, + max_attempts: u32, +) -> std::result::Result, String> { + use crate::retry::{classify_reqwest, classify_status, retry_transient, FetchError}; retry_transient( - DEFAULT_MAX_ATTEMPTS, + max_attempts, log_tag, FetchError::is_transient, |_attempt| async move { + // TIMING SPLIT (2026-08-10): the owner measured this same fetch at + // 423ms under the Slint binary and 5459ms under Qt — same shared + // crate, same machine, same network, and (verified with + // `cargo tree -e features`) the same unified reqwest feature set. + // `send()` covers DNS + connect + TLS + time-to-headers; `bytes()` + // is the body. Splitting them says which half is losing the five + // seconds instead of a sixth guess. + let t_send = std::time::Instant::now(); let response = http .get(url) .header("User-Agent", "Mozilla/5.0") + // reqwest applies this from the start of connect until the + // response body completes. The deadline is recreated on each + // retry, so one stalled segment cannot monopolize a feeder. + .timeout(attempt_timeout) .send() .await .map_err(|e| classify_reqwest(&e, "fetch"))?; + let send_ms = t_send.elapsed().as_millis(); let status = response.status(); if !status.is_success() { return Err(classify_status(status, "fetch")); } - response + let t_body = std::time::Instant::now(); + let out = response .bytes() .await .map(|b| b.to_vec()) - .map_err(|e| classify_reqwest(&e, "read")) + .map_err(|e| classify_reqwest(&e, "read")); + log::info!( + "[{}] timing: send(dns+connect+tls+ttfb)={}ms body={}ms", + log_tag, + send_ms, + t_body.elapsed().as_millis() + ); + out }, ) .await @@ -429,7 +475,11 @@ async fn fetch_all_segments( ) -> std::result::Result>, String> { let semaphore = Arc::new(tokio::sync::Semaphore::new(CMAF_PREFETCH_CONCURRENCY)); let seg_indices: Vec = (1..=n_segments).collect(); - let mut handles = Vec::with_capacity(seg_indices.len()); + // JoinSet owns every segment task: dropping this fetch (for example when + // engine-empty recovery advances the queue) aborts the children too. + // Plain JoinHandles detach on drop and would keep the abandoned gapless + // download competing with the replacement track. + let mut fetches = tokio::task::JoinSet::new(); let completed_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); @@ -441,12 +491,15 @@ async fn fetch_all_segments( let progress = on_progress.clone(); let counter = completed_count.clone(); - handles.push(tokio::spawn(async move { + fetches.spawn(async move { let permit = sem.acquire_owned().await.map_err(|e| format!("semaphore: {}", e))?; - let seg_data = - fetch_bytes_with_retry(&http, &seg_url, &format!("{} seg {}", log_tag, seg_idx)) - .await - .map_err(|e| format!("[{}] seg {} fetch: {}", log_tag, seg_idx, e))?; + let seg_data = fetch_cdn_bytes_with_retry( + &http, + &seg_url, + &format!("{} seg {}", log_tag, seg_idx), + ) + .await + .map_err(|e| format!("[{}] seg {} fetch: {}", log_tag, seg_idx, e))?; let bytes_this_segment = seg_data.len() as u64; if let Some(cb) = progress { let done = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; @@ -461,14 +514,13 @@ async fn fetch_all_segments( tokio::time::sleep(std::time::Duration::from_millis(500)).await; drop(permit); Ok::<(u8, Vec), String>((seg_idx, seg_data)) - })); + }); } // Collect results in arrival order, then re-sort by segment index - let mut segments: Vec<(u8, Vec)> = Vec::with_capacity(handles.len()); - for handle in handles { - let (idx, data) = handle - .await + let mut segments: Vec<(u8, Vec)> = Vec::with_capacity(n_segments as usize); + while let Some(result) = fetches.join_next().await { + let (idx, data) = result .map_err(|e| format!("[{}] task panic: {}", log_tag, e))? .map_err(|e| format!("[{}] download failed: {}", log_tag, e))?; segments.push((idx, data)); @@ -531,3 +583,120 @@ pub fn decrypt_segments_into( fn _type_assertions() { let _: fn() -> Result<()> = || Ok(()); } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::{Arc, Once}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn install_tls_provider() { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); + } + + #[tokio::test] + async fn stalled_segment_body_times_out_and_retries() { + install_tls_provider(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .unwrap(); + let address = listener.local_addr().unwrap(); + let accepts = Arc::new(AtomicU32::new(0)); + let server_accepts = accepts.clone(); + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + server_accepts.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let mut request = [0u8; 1024]; + let _ = socket.read(&mut request).await; + let _ = socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\n", + ) + .await; + // Headers arrive immediately; the body never does within + // the attempt deadline. This reproduces the 52 s body stall + // from the 2026-08-28 session without a live CDN. + tokio::time::sleep(Duration::from_millis(500)).await; + }); + } + }); + + let client = reqwest::Client::builder().build().unwrap(); + let url = format!("http://{address}/segment"); + let result = tokio::time::timeout( + Duration::from_secs(2), + fetch_cdn_bytes_with_retry_config( + &client, + &url, + "CMAF timeout test", + Duration::from_millis(50), + 2, + ), + ) + .await + .expect("bounded segment attempts must not hang"); + + let error = result.expect_err("both stalled attempts must time out"); + assert!( + error.contains("read:"), + "stalled response must fail while reading its body: {error}" + ); + assert_eq!(accepts.load(Ordering::SeqCst), 2); + server.abort(); + } + + #[tokio::test] + async fn aborting_full_fetch_cancels_its_segment_tasks() { + install_tls_provider(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .unwrap(); + let address = listener.local_addr().unwrap(); + let (headers_tx, headers_rx) = tokio::sync::oneshot::channel(); + let (closed_tx, closed_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 1024]; + let _ = socket.read(&mut request).await; + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + let _ = headers_tx.send(()); + + let mut trailing_request = [0u8; 1024]; + while socket.read(&mut trailing_request).await.unwrap_or(0) != 0 { + // Drain any request bytes left after the server's first read. + } + let _ = closed_tx.send(()); + }); + + let client = reqwest::Client::builder().build().unwrap(); + let url_template = format!("http://{address}/segment-$SEGMENT$"); + let fetch = tokio::spawn(async move { + fetch_all_segments(&client, &url_template, 1, "CMAF abort test", None).await + }); + tokio::time::timeout(Duration::from_secs(1), headers_rx) + .await + .expect("segment request must reach the body stall") + .unwrap(); + + fetch.abort(); + let _ = fetch.await; + tokio::time::timeout(Duration::from_secs(1), closed_rx) + .await + .expect("aborting the owner must close its in-flight segment") + .unwrap(); + server.await.unwrap(); + } +} diff --git a/crates/qbz-qobuz/src/delegated.rs b/crates/qbz-qobuz/src/delegated.rs new file mode 100644 index 000000000..112ec0fe5 --- /dev/null +++ b/crates/qbz-qobuz/src/delegated.rs @@ -0,0 +1,871 @@ +//! Isolated Qobuz API access for a delegated QConnect guest session. +//! +//! This client owns its HTTP stack and credentials. It never reads or changes +//! [`crate::QobuzClient`] owner-session state, OAuth credentials, keyrings, or +//! global headers. The delegated JWT is sent only as `Authorization: Bearer`, +//! alongside `X-App-Id`; `X-User-Auth-Token` is deliberately unavailable. +//! +//! The surface is intentionally read-only and transient: access validation, +//! track metadata, and stream URL resolution. There is no persistence, token +//! refresh, library/account operation, or implicit owner fallback here. + +use crate::auth::get_timestamp; +use crate::endpoints::paths; +use md5::{Digest, Md5}; +use qbz_models::{Quality, StreamRestriction, StreamUrl, Track}; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use reqwest::{Client, RequestBuilder, Response, StatusCode, Url}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::fmt; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use zeroize::Zeroizing; + +const MAX_ENDPOINT_BYTES: usize = 2_048; +const MAX_JWT_BYTES: usize = 16 * 1_024; +const MAX_APP_ID_BYTES: usize = 256; +const MAX_SIGNING_SECRET_BYTES: usize = 16 * 1_024; +const MIN_INITIAL_TTL: Duration = Duration::from_secs(60); +const TRACKS_PER_BATCH: usize = 50; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +/// A configuration rejection which never embeds the rejected value. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum DelegatedApiConfigError { + #[error("delegated API endpoint is invalid")] + InvalidEndpoint, + #[error("delegated API endpoint exceeds the size limit")] + EndpointTooLong, + #[error("delegated API endpoint must use HTTPS on port 443")] + InsecureEndpoint, + #[error("delegated API endpoint host is not allowed")] + EndpointHostNotAllowed, + #[error("delegated API app ID is invalid")] + InvalidAppId, + #[error("delegated API signing secret is invalid")] + InvalidSigningSecret, + #[error("delegated API credential is invalid")] + InvalidJwt, + #[error("delegated API credential is expired")] + Expired, + #[error("delegated API credential has insufficient lifetime")] + InsufficientLifetime, +} + +/// Runtime errors for delegated, read-only Qobuz API operations. +/// +/// Network and response errors are intentionally sanitized: reqwest errors +/// may carry the delegated endpoint URL, which must not reach Debug/log output. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum DelegatedApiError { + #[error(transparent)] + Configuration(#[from] DelegatedApiConfigError), + #[error("delegated API credential is expired")] + Expired, + #[error("offline mode is active - delegated Qobuz services are disabled")] + OfflineMode, + #[error("delegated API HTTP client could not be initialized")] + ClientInitialization, + #[error("delegated API network request failed")] + Network, + #[error("delegated API credential was rejected")] + Unauthorized, + #[error("delegated API access was forbidden")] + Forbidden, + #[error("delegated API request signature was rejected")] + InvalidSignature, + #[error("delegated API returned an invalid response")] + InvalidResponse, + #[error("delegated API request was rejected (HTTP {0})")] + RequestRejected(u16), + #[error("delegated API was rate limited")] + RateLimited, + #[error("delegated API server error (HTTP {0})")] + ServerError(u16), + #[error("track {0} is no longer available on Qobuz")] + TrackUnavailable(u64), + #[error("no valid quality is available for this track")] + NoQualityAvailable, +} + +impl DelegatedApiError { + pub fn is_transient(&self) -> bool { + matches!( + self, + Self::Network | Self::RateLimited | Self::ServerError(_) + ) + } +} + +pub type DelegatedApiResult = std::result::Result; + +/// An HTTPS API base whose host has passed the caller's closed allowlist. +/// +/// The allowlist belongs to the QConnect adapter because it is established by +/// official-client captures. Passing it explicitly prevents this lower-level +/// crate from silently broadening the accepted authority later. +pub struct DelegatedApiEndpoint { + base_url: Zeroizing, +} + +impl DelegatedApiEndpoint { + pub fn new( + endpoint: &str, + allowed_hosts: &[&str], + ) -> std::result::Result { + if endpoint.len() > MAX_ENDPOINT_BYTES { + return Err(DelegatedApiConfigError::EndpointTooLong); + } + + let mut base_url = + Url::parse(endpoint).map_err(|_| DelegatedApiConfigError::InvalidEndpoint)?; + if base_url.cannot_be_a_base() + || !base_url.username().is_empty() + || base_url.password().is_some() + || base_url.query().is_some() + || base_url.fragment().is_some() + { + return Err(DelegatedApiConfigError::InvalidEndpoint); + } + if base_url.scheme() != "https" || base_url.port_or_known_default() != Some(443) { + return Err(DelegatedApiConfigError::InsecureEndpoint); + } + + let host = base_url + .host_str() + .ok_or(DelegatedApiConfigError::InvalidEndpoint)?; + if allowed_hosts.is_empty() + || !allowed_hosts + .iter() + .any(|allowed| host.eq_ignore_ascii_case(allowed)) + { + return Err(DelegatedApiConfigError::EndpointHostNotAllowed); + } + + if !base_url.path().ends_with('/') { + let normalized_path = format!("{}/", base_url.path()); + base_url.set_path(&normalized_path); + } + Ok(Self { + base_url: Zeroizing::new(base_url.to_string()), + }) + } + + fn join(&self, path: &str) -> DelegatedApiResult { + Url::parse(self.base_url.as_str()) + .map_err(|_| { + DelegatedApiError::Configuration(DelegatedApiConfigError::InvalidEndpoint) + })? + .join(path.trim_start_matches('/')) + .map_err(|_| DelegatedApiError::Configuration(DelegatedApiConfigError::InvalidEndpoint)) + } +} + +impl fmt::Debug for DelegatedApiEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DelegatedApiEndpoint") + .field("base_url", &"") + .finish() + } +} + +/// App-level material copied into, and owned exclusively by, a guest client. +/// +/// It intentionally does not implement `Clone`; the signing secret is wiped on +/// drop. Callers obtain this opaque value through +/// [`crate::QobuzClient::delegated_app_credentials`], which copies only the +/// app ID and the app secret selected by the owner's validation probe. +pub struct DelegatedAppCredentials { + app_id: String, + signing_secret: Zeroizing, +} + +impl DelegatedAppCredentials { + pub(crate) fn new( + app_id: String, + signing_secret: String, + ) -> std::result::Result { + let signing_secret = Zeroizing::new(signing_secret); + if app_id.is_empty() + || app_id.len() > MAX_APP_ID_BYTES + || app_id.chars().any(char::is_whitespace) + || HeaderValue::from_str(&app_id).is_err() + { + return Err(DelegatedApiConfigError::InvalidAppId); + } + if signing_secret.is_empty() + || signing_secret.len() > MAX_SIGNING_SECRET_BYTES + || signing_secret.chars().any(char::is_whitespace) + { + return Err(DelegatedApiConfigError::InvalidSigningSecret); + } + Ok(Self { + app_id, + signing_secret, + }) + } + + #[cfg(test)] + pub(crate) fn values_for_test(&self) -> (&str, &str) { + (&self.app_id, self.signing_secret.as_str()) + } +} + +impl fmt::Debug for DelegatedAppCredentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DelegatedAppCredentials") + .field("app_id", &"") + .field("signing_secret", &"") + .finish() + } +} + +/// Complete, in-memory configuration consumed when creating a guest client. +pub struct DelegatedApiConfig { + endpoint: DelegatedApiEndpoint, + expires_at: u64, + jwt_api: Zeroizing, + app_credentials: DelegatedAppCredentials, +} + +impl DelegatedApiConfig { + pub fn new( + endpoint: DelegatedApiEndpoint, + expires_at: u64, + jwt_api: String, + app_credentials: DelegatedAppCredentials, + ) -> std::result::Result { + let jwt_api = Zeroizing::new(jwt_api); + let now = unix_timestamp(); + if expires_at <= now { + return Err(DelegatedApiConfigError::Expired); + } + if expires_at.saturating_sub(now) < MIN_INITIAL_TTL.as_secs() { + return Err(DelegatedApiConfigError::InsufficientLifetime); + } + validate_jwt(&jwt_api)?; + Ok(Self { + endpoint, + expires_at, + jwt_api, + app_credentials, + }) + } +} + +impl fmt::Debug for DelegatedApiConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DelegatedApiConfig") + .field("endpoint", &self.endpoint) + .field("expires_at", &self.expires_at) + .field("jwt_api", &"") + .field("app_credentials", &self.app_credentials) + .finish() + } +} + +/// A read-only Qobuz client whose authority is one delegated `jwt_api`. +/// +/// The type is not clonable. Callers that need shared ownership can place the +/// whole isolated context in an `Arc`; this never shares state with the owner +/// [`crate::QobuzClient`]. +pub struct DelegatedQobuzClient { + http: Client, + endpoint: DelegatedApiEndpoint, + expires_at: u64, + jwt_api: Zeroizing, + app_id: String, + signing_secret: Zeroizing, +} + +impl DelegatedQobuzClient { + pub fn new(config: DelegatedApiConfig) -> DelegatedApiResult { + let http = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|_| DelegatedApiError::ClientInitialization)?; + + Ok(Self { + http, + endpoint: config.endpoint, + expires_at: config.expires_at, + jwt_api: config.jwt_api, + app_id: config.app_credentials.app_id, + signing_secret: config.app_credentials.signing_secret, + }) + } + + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + /// Prove the delegated Bearer against the observed non-mutating endpoint. + pub async fn validate_access(&self) -> DelegatedApiResult<()> { + let url = self.endpoint.join(paths::USER_GET)?; + let response = self.execute(self.http.get(url)).await?; + ensure_success(response.status(), None, false)?; + let value: Value = read_json(response).await?; + let valid_user_shape = value.as_object().is_some_and(|object| { + object.contains_key("id") + || object.contains_key("user") + || object.contains_key("credential") + }); + if !valid_user_shape { + return Err(DelegatedApiError::InvalidResponse); + } + Ok(()) + } + + /// Resolve read-only metadata for one track with delegated authority. + pub async fn get_track(&self, track_id: u64) -> DelegatedApiResult { + let response = self + .signed_get( + paths::TRACK_GET, + "trackget", + &[("track_id", track_id.to_string())], + ) + .await?; + ensure_success(response.status(), Some(track_id), true)?; + read_json(response).await + } + + /// Resolve read-only metadata in the API's 50-track request windows. + pub async fn get_tracks_batch(&self, track_ids: &[u64]) -> DelegatedApiResult> { + if track_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut tracks = Vec::with_capacity(track_ids.len()); + for chunk in track_ids.chunks(TRACKS_PER_BATCH) { + tracks.extend(self.get_tracks_batch_chunk(chunk).await?); + } + Ok(tracks) + } + + /// Resolve one signed streaming URL using only the delegated Bearer. + pub async fn get_stream_url( + &self, + track_id: u64, + quality: Quality, + ) -> DelegatedApiResult { + let timestamp = get_timestamp(); + let track_id_param = track_id.to_string(); + let format_id = quality.id().to_string(); + let signing_params = [ + ("format_id", format_id.as_str()), + ("intent", "stream"), + ("track_id", track_id_param.as_str()), + ]; + let signature = Zeroizing::new(delegated_signature( + "trackgetFileUrl", + &signing_params, + timestamp, + self.signing_secret.as_str(), + )); + let timestamp = timestamp.to_string(); + let url = self.endpoint.join(paths::TRACK_GET_FILE_URL)?; + let response = self + .execute(self.http.get(url).query(&[ + ("track_id", track_id_param.as_str()), + ("format_id", format_id.as_str()), + ("intent", "stream"), + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ])) + .await?; + ensure_success(response.status(), Some(track_id), true)?; + + let value: Value = read_json(response).await?; + let restrictions: Vec = value + .get("restrictions") + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(|_| DelegatedApiError::InvalidResponse)? + .unwrap_or_default(); + let stream_url = value + .get("url") + .and_then(Value::as_str) + .filter(|url| !url.is_empty()) + .ok_or(DelegatedApiError::TrackUnavailable(track_id))? + .to_string(); + + Ok(StreamUrl { + url: stream_url, + format_id: value + .get("format_id") + .and_then(Value::as_u64) + .unwrap_or_default() as u32, + mime_type: value + .get("mime_type") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + sampling_rate: value + .get("sampling_rate") + .and_then(Value::as_f64) + .unwrap_or_default(), + bit_depth: value + .get("bit_depth") + .and_then(Value::as_u64) + .map(|depth| depth as u32), + track_id, + restrictions, + sample: value + .get("sample") + .and_then(Value::as_bool) + .unwrap_or(false), + }) + } + + /// Try the requested quality and lower tiers without ever switching to the + /// owner session or refreshing the delegated credential. + pub async fn get_stream_url_with_fallback( + &self, + track_id: u64, + preferred: Quality, + ) -> DelegatedApiResult { + let qualities = Quality::fallback_order(); + let start = qualities + .iter() + .position(|quality| *quality == preferred) + .unwrap_or_default(); + let mut unavailable = false; + + for quality in &qualities[start..] { + match self.get_stream_url(track_id, *quality).await { + Ok(stream) if !stream.has_restrictions() => return Ok(stream), + Ok(_) => continue, + Err(DelegatedApiError::TrackUnavailable(_)) => unavailable = true, + Err(error) => return Err(error), + } + } + + if unavailable { + Err(DelegatedApiError::TrackUnavailable(track_id)) + } else { + Err(DelegatedApiError::NoQualityAvailable) + } + } + + async fn get_tracks_batch_chunk(&self, track_ids: &[u64]) -> DelegatedApiResult> { + let ids = track_ids + .iter() + .map(u64::to_string) + .collect::>() + .join(","); + let timestamp = get_timestamp(); + let signature = Zeroizing::new(delegated_signature( + "trackgetList", + &[("tracks_id", ids.as_str())], + timestamp, + self.signing_secret.as_str(), + )); + let timestamp = timestamp.to_string(); + let url = self.endpoint.join(paths::TRACK_GET_LIST)?; + let response = self + .execute( + self.http + .post(url) + .query(&[ + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ]) + .json(&serde_json::json!({ "tracks_id": track_ids })), + ) + .await?; + ensure_success(response.status(), None, true)?; + + let value: Value = read_json(response).await?; + let items = value + .get("tracks") + .and_then(|tracks| tracks.get("items")) + .cloned() + .ok_or(DelegatedApiError::InvalidResponse)?; + serde_json::from_value(items).map_err(|_| DelegatedApiError::InvalidResponse) + } + + async fn signed_get( + &self, + path: &str, + method_name: &str, + params: &[(&str, String)], + ) -> DelegatedApiResult { + let timestamp = get_timestamp(); + let signing_params = params + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect::>(); + let signature = Zeroizing::new(delegated_signature( + method_name, + &signing_params, + timestamp, + self.signing_secret.as_str(), + )); + let timestamp = timestamp.to_string(); + let mut query = signing_params; + query.push(("request_ts", timestamp.as_str())); + query.push(("request_sig", signature.as_str())); + let url = self.endpoint.join(path)?; + self.execute(self.http.get(url).query(&query)).await + } + + async fn execute(&self, request: RequestBuilder) -> DelegatedApiResult { + self.guard()?; + request + .headers(self.delegated_headers()?) + .send() + .await + .map_err(|_| DelegatedApiError::Network) + } + + fn guard(&self) -> DelegatedApiResult<()> { + if crate::offline_gate::is_offline() { + return Err(DelegatedApiError::OfflineMode); + } + if self.expires_at <= unix_timestamp() { + return Err(DelegatedApiError::Expired); + } + Ok(()) + } + + fn delegated_headers(&self) -> DelegatedApiResult { + let mut headers = HeaderMap::new(); + headers.insert( + "X-App-Id", + HeaderValue::from_str(&self.app_id).map_err(|_| { + DelegatedApiError::Configuration(DelegatedApiConfigError::InvalidAppId) + })?, + ); + let mut authorization = + bearer_header(self.jwt_api.as_str()).map_err(DelegatedApiError::Configuration)?; + authorization.set_sensitive(true); + headers.insert(AUTHORIZATION, authorization); + Ok(headers) + } +} + +impl fmt::Debug for DelegatedQobuzClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DelegatedQobuzClient") + .field("endpoint", &"") + .field("expires_at", &self.expires_at) + .field("jwt_api", &"") + .field("app_id", &"") + .field("signing_secret", &"") + .finish() + } +} + +fn validate_jwt(jwt: &str) -> std::result::Result<(), DelegatedApiConfigError> { + if jwt.is_empty() + || jwt.len() > MAX_JWT_BYTES + || !jwt.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) + { + return Err(DelegatedApiConfigError::InvalidJwt); + } + Ok(()) +} + +fn bearer_header(jwt: &str) -> std::result::Result { + let mut bearer = Zeroizing::new(String::with_capacity("Bearer ".len() + jwt.len())); + bearer.push_str("Bearer "); + bearer.push_str(jwt); + HeaderValue::from_str(bearer.as_str()).map_err(|_| DelegatedApiConfigError::InvalidJwt) +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Sign without ever allocating a concatenated buffer containing the secret. +fn delegated_signature( + method: &str, + params: &[(&str, &str)], + timestamp: u64, + secret: &str, +) -> String { + let mut sorted = params.to_vec(); + sorted.sort_by_key(|(key, _)| *key); + let mut hasher = Md5::new(); + hasher.update(method.as_bytes()); + for (key, value) in sorted { + hasher.update(key.as_bytes()); + hasher.update(value.as_bytes()); + } + hasher.update(timestamp.to_string().as_bytes()); + hasher.update(secret.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn ensure_success( + status: StatusCode, + track_id: Option, + signed: bool, +) -> DelegatedApiResult<()> { + match status { + status if status.is_success() => Ok(()), + StatusCode::BAD_REQUEST if signed => Err(DelegatedApiError::InvalidSignature), + StatusCode::UNAUTHORIZED => Err(DelegatedApiError::Unauthorized), + StatusCode::FORBIDDEN => Err(DelegatedApiError::Forbidden), + StatusCode::NOT_FOUND => track_id.map_or_else( + || Err(DelegatedApiError::RequestRejected(status.as_u16())), + |track_id| Err(DelegatedApiError::TrackUnavailable(track_id)), + ), + StatusCode::TOO_MANY_REQUESTS => Err(DelegatedApiError::RateLimited), + status if status.is_server_error() => Err(DelegatedApiError::ServerError(status.as_u16())), + status => Err(DelegatedApiError::RequestRejected(status.as_u16())), + } +} + +async fn read_json(response: Response) -> DelegatedApiResult { + let body = response + .bytes() + .await + .map_err(|_| DelegatedApiError::Network)?; + serde_json::from_slice(&body).map_err(|_| DelegatedApiError::InvalidResponse) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::Once; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + const APP_ID: &str = "delegated-app-id"; + const SIGNING_SECRET: &str = "delegated-signing-secret"; + const JWT: &str = "delegated.jwt.value"; + + fn install_tls_provider() { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); + } + + fn future_expiry() -> u64 { + unix_timestamp() + 600 + } + + fn test_client(base_url: String) -> DelegatedQobuzClient { + install_tls_provider(); + let endpoint = DelegatedApiEndpoint { + base_url: Zeroizing::new(base_url), + }; + let credentials = + DelegatedAppCredentials::new(APP_ID.to_string(), SIGNING_SECRET.to_string()).unwrap(); + let config = + DelegatedApiConfig::new(endpoint, future_expiry(), JWT.to_string(), credentials) + .unwrap(); + DelegatedQobuzClient::new(config).unwrap() + } + + async fn mock_once(body: &'static str) -> (String, tokio::task::JoinHandle) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let read = socket.read(&mut chunk).await.unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + }); + (format!("http://{address}/api.json/0.2/"), task) + } + + fn request_headers(request: &str) -> BTreeMap { + request + .lines() + .skip(1) + .take_while(|line| !line.is_empty()) + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string())) + .collect() + } + + #[test] + fn endpoint_requires_https_and_a_caller_allowlist() { + let allowed = ["api.example.test"]; + assert!(matches!( + DelegatedApiEndpoint::new("http://api.example.test/v1", &allowed), + Err(DelegatedApiConfigError::InsecureEndpoint) + )); + assert!(matches!( + DelegatedApiEndpoint::new("https://other.example.test/v1", &allowed), + Err(DelegatedApiConfigError::EndpointHostNotAllowed) + )); + assert!(matches!( + DelegatedApiEndpoint::new("https://user@api.example.test/v1", &allowed), + Err(DelegatedApiConfigError::InvalidEndpoint) + )); + assert!(matches!( + DelegatedApiEndpoint::new("https://api.example.test/v1?token=nope", &allowed), + Err(DelegatedApiConfigError::InvalidEndpoint) + )); + + let endpoint = DelegatedApiEndpoint::new("https://api.example.test/v1", &allowed).unwrap(); + assert_eq!( + endpoint.join("track/get").unwrap().as_str(), + "https://api.example.test/v1/track/get" + ); + } + + #[test] + fn config_rejects_expired_short_lived_and_invalid_credentials() { + let make_config = |expires_at, jwt: &str| { + let endpoint = + DelegatedApiEndpoint::new("https://api.example.test/v1", &["api.example.test"]) + .unwrap(); + let credentials = + DelegatedAppCredentials::new(APP_ID.to_string(), SIGNING_SECRET.to_string()) + .unwrap(); + DelegatedApiConfig::new(endpoint, expires_at, jwt.to_string(), credentials) + }; + let now = unix_timestamp(); + assert!(matches!( + make_config(now, JWT), + Err(DelegatedApiConfigError::Expired) + )); + assert!(matches!( + make_config(now + 59, JWT), + Err(DelegatedApiConfigError::InsufficientLifetime) + )); + assert!(matches!( + make_config(now + 600, "delegated jwt with spaces"), + Err(DelegatedApiConfigError::InvalidJwt) + )); + } + + #[test] + fn secrets_and_authorization_never_appear_in_debug() { + install_tls_provider(); + let endpoint_text = "https://api.example.test/private/base"; + let endpoint = DelegatedApiEndpoint::new(endpoint_text, &["api.example.test"]).unwrap(); + let credentials = + DelegatedAppCredentials::new(APP_ID.to_string(), SIGNING_SECRET.to_string()).unwrap(); + let config = + DelegatedApiConfig::new(endpoint, future_expiry(), JWT.to_string(), credentials) + .unwrap(); + let config_debug = format!("{config:?}"); + for forbidden in [ + endpoint_text, + APP_ID, + SIGNING_SECRET, + JWT, + "Authorization", + "Bearer", + ] { + assert!(!config_debug.contains(forbidden)); + } + + let client = DelegatedQobuzClient::new(config).unwrap(); + let client_debug = format!("{client:?}"); + for forbidden in [ + endpoint_text, + APP_ID, + SIGNING_SECRET, + JWT, + "Authorization", + "Bearer", + ] { + assert!(!client_debug.contains(forbidden)); + } + + let headers_debug = format!("{:?}", client.delegated_headers().unwrap()); + assert!(headers_debug.contains("Sensitive")); + assert!(!headers_debug.contains(JWT)); + assert!(!headers_debug.contains("Bearer")); + assert!(!headers_debug.contains("x-user-auth-token")); + } + + #[tokio::test] + async fn access_preflight_uses_only_bearer_and_app_id() { + let _lock = crate::offline_gate::test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _reset = crate::offline_gate::TestGateReset; + crate::offline_gate::set_offline(false); + let (base_url, server) = mock_once(r#"{"id": 7}"#).await; + let client = test_client(base_url); + client.validate_access().await.unwrap(); + let request = server.await.unwrap(); + let request_line = request.lines().next().unwrap(); + let headers = request_headers(&request); + + assert_eq!(request_line, "GET /api.json/0.2/user/get HTTP/1.1"); + assert_eq!(headers.get("x-app-id").map(String::as_str), Some(APP_ID)); + assert_eq!( + headers.get("authorization").map(String::as_str), + Some("Bearer delegated.jwt.value") + ); + assert!(!headers.contains_key("x-user-auth-token")); + } + + #[tokio::test] + async fn metadata_request_keeps_delegated_headers_and_is_signed() { + let _lock = crate::offline_gate::test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _reset = crate::offline_gate::TestGateReset; + crate::offline_gate::set_offline(false); + let (base_url, server) = mock_once(r#"{"id": 42}"#).await; + let client = test_client(base_url); + let track = client.get_track(42).await.unwrap(); + assert_eq!(track.id, 42); + + let request = server.await.unwrap(); + let request_line = request.lines().next().unwrap(); + let headers = request_headers(&request); + assert_eq!(headers.get("x-app-id").map(String::as_str), Some(APP_ID)); + assert_eq!( + headers.get("authorization").map(String::as_str), + Some("Bearer delegated.jwt.value") + ); + assert!(!headers.contains_key("x-user-auth-token")); + + let target = request_line.split_whitespace().nth(1).unwrap(); + let url = Url::parse(&format!("http://localhost{target}")).unwrap(); + let query = url.query_pairs().into_owned().collect::>(); + assert_eq!(url.path(), "/api.json/0.2/track/get"); + assert_eq!(query.get("track_id").map(String::as_str), Some("42")); + let timestamp = query.get("request_ts").unwrap().parse::().unwrap(); + let expected = + crate::auth::sign_request("trackget", &[("track_id", "42")], timestamp, SIGNING_SECRET); + assert_eq!(query.get("request_sig"), Some(&expected)); + } + + #[test] + fn expired_credentials_fail_before_any_request() { + let _lock = crate::offline_gate::test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _reset = crate::offline_gate::TestGateReset; + crate::offline_gate::set_offline(false); + let mut client = test_client("http://127.0.0.1:9/api.json/0.2/".to_string()); + client.expires_at = 0; + assert_eq!(client.guard(), Err(DelegatedApiError::Expired)); + } +} diff --git a/crates/qbz-qobuz/src/endpoints.rs b/crates/qbz-qobuz/src/endpoints.rs index bd84b9ffd..a962ae0ac 100644 --- a/crates/qbz-qobuz/src/endpoints.rs +++ b/crates/qbz-qobuz/src/endpoints.rs @@ -5,6 +5,7 @@ pub const BASE_URL: &str = "https://www.qobuz.com/api.json/0.2"; /// Endpoint paths pub mod paths { // User + pub const USER_GET: &str = "/user/get"; pub const USER_LOGIN: &str = "/user/login"; // OAuth @@ -50,6 +51,14 @@ pub mod paths { pub const PLAYLIST_ADD_TRACKS: &str = "/playlist/addTracks"; pub const PLAYLIST_DELETE_TRACKS: &str = "/playlist/deleteTracks"; pub const PLAYLIST_UPDATE: &str = "/playlist/update"; + // Reorder tracks WITHIN a playlist. Not in + // qobuz-api-inferred-openapi-v10.0.0.0-beta.yaml; the shape comes from the + // DESKTOP web-player capture + // (qbz-nix-docs/qobuz-api-delta-desktop-8.2.0-b015.md:95), not from the + // decompiled Android route table, which CLAUDE.md forbids inferring + // endpoints from. Distinct from /playlist/updatePlaylistsPosition, which + // reorders PLAYLISTS. + pub const PLAYLIST_UPDATE_TRACKS_POSITION: &str = "/playlist/updateTracksPosition"; pub const PLAYLIST_GET_TAGS: &str = "/playlist/getTags"; pub const PLAYLIST_SUBSCRIBE: &str = "/playlist/subscribe"; pub const PLAYLIST_UNSUBSCRIBE: &str = "/playlist/unsubscribe"; @@ -94,6 +103,11 @@ pub mod paths { // Session (CMAF streaming) pub const SESSION_START: &str = "/session/start"; + // Qobuz Connect cloud authentication. Both are form-encoded POSTs; see + // qobuz-api-inferred-openapi-v10.0.0.0-beta.yaml. + pub const QWS_CREATE_TOKEN: &str = "/qws/createToken"; + pub const QWS_DELEGATE_AUTH: &str = "/qws/delegateAuth"; + // File (CMAF streaming) pub const FILE_URL: &str = "/file/url"; diff --git a/crates/qbz-qobuz/src/error.rs b/crates/qbz-qobuz/src/error.rs index 5dd1ecd41..7f3c24175 100644 --- a/crates/qbz-qobuz/src/error.rs +++ b/crates/qbz-qobuz/src/error.rs @@ -34,8 +34,9 @@ pub enum ApiError { /// Qobuz answered 403 Forbidden on an authenticated request. The account is /// authenticated but not currently allowed to perform the action (entitlement /// not restored after an outage, geo/concurrency limit, or an edge/WAF block). - /// The string is a short body preview for diagnostics. Terminal, never a - /// per-quality restriction — abort the fallback loop instead of retrying. + /// The string contains only a redacted body-presence diagnostic. Terminal, + /// never a per-quality restriction — abort the fallback loop instead of + /// retrying. #[error("Access forbidden by Qobuz (HTTP 403){0}")] Forbidden(String), @@ -65,6 +66,18 @@ pub enum ApiError { } impl ApiError { + /// Whether this is the exact terminal 404 sentinel emitted by + /// `QobuzClient::get_album`. Centralising the legacy string-shaped result + /// keeps UI callers from broad `contains("404")` guesses while avoiding a + /// new enum variant that would break every exhaustive error classifier. + pub fn is_album_unavailable(&self, album_id: &str) -> bool { + matches!( + self, + ApiError::ApiResponse(message) + if message == &format!("Album {album_id} not found (404)") + ) + } + /// True for errors worth retrying with backoff (issue #467): transport /// problems (timeout/connect/reset), 5xx server errors, and 429 rate /// limiting. Terminal errors — a real 404 `TrackUnavailable`, auth or @@ -80,3 +93,23 @@ impl ApiError { } pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn album_unavailable_recognises_only_the_exact_get_album_404() { + assert!(ApiError::ApiResponse("Album old-id not found (404)".into()) + .is_album_unavailable("old-id")); + assert!( + !ApiError::ApiResponse("get_album(old-id) status 500".into()) + .is_album_unavailable("old-id") + ); + assert!( + !ApiError::ApiResponse("Album another-id not found (404)".into()) + .is_album_unavailable("old-id") + ); + assert!(!ApiError::OfflineMode.is_album_unavailable("old-id")); + } +} diff --git a/crates/qbz-qobuz/src/lib.rs b/crates/qbz-qobuz/src/lib.rs index 6f1b69dfc..e5a979372 100644 --- a/crates/qbz-qobuz/src/lib.rs +++ b/crates/qbz-qobuz/src/lib.rs @@ -34,6 +34,7 @@ pub mod auth; pub mod bundle; pub mod client; pub mod cmaf; +pub mod delegated; pub mod endpoints; pub mod error; pub mod forbidden_breaker; @@ -42,6 +43,7 @@ pub mod lyrics; pub mod offline_gate; pub mod performers; pub mod purchases; +pub mod qconnect_auth; pub mod retry; // Re-export main types @@ -55,6 +57,10 @@ pub use cmaf::{ setup_streaming as cmaf_setup_streaming, CmafProgressCallback, CmafProgressUpdate, CmafRawBundle, CmafStreamingInfo, CMAF_PREFETCH_CONCURRENCY, }; +pub use delegated::{ + DelegatedApiConfig, DelegatedApiConfigError, DelegatedApiEndpoint, DelegatedApiError, + DelegatedApiResult, DelegatedAppCredentials, DelegatedQobuzClient, +}; pub use error::{ApiError, Result}; pub use link_resolver::{resolve_link, LinkResolverError, ResolvedLink}; pub use lyrics::{ @@ -62,3 +68,4 @@ pub use lyrics::{ QobuzLyricsPlainLine, QobuzLyricsPublisher, QobuzLyricsTranslation, QobuzLyricsUrls, QobuzLyricsWord, }; +pub use qconnect_auth::QwsAuthToken; diff --git a/crates/qbz-qobuz/src/purchases.rs b/crates/qbz-qobuz/src/purchases.rs index 4b9f725f1..163ba1574 100644 --- a/crates/qbz-qobuz/src/purchases.rs +++ b/crates/qbz-qobuz/src/purchases.rs @@ -3,7 +3,7 @@ //! Ported 1:1 from the Tauri reference at `src-tauri/src/api/client.rs` //! (`get_user_purchases_page_typed` 1538, `get_user_purchases_ids_page_typed` //! 1570, `get_user_purchases_all` 1612, `get_user_purchases_all_typed` 1683, -//! `get_track_file_url_by_format` 1768) plus `download_audio` from +//! `get_track_file_url_by_format` 1768) plus `download_audio_to_path` from //! `src-tauri/src/commands_v2/helpers.rs:332`. //! //! Behavioral divergence from the Tauri reference, intentional and correct: @@ -13,30 +13,162 @@ //! Purchases therefore fail fast offline (`ApiError::OfflineMode`), consistent //! with the rest of `qbz-qobuz`. The Tauri build had no shared offline gate. //! - The CDN cross-feature gate (`CDN_STREAMING_ACTIVE`) is `src-tauri`-only; it -//! guarded `download_audio` against concurrent streaming-vs-download CDN rate +//! guarded `download_audio_to_path` against concurrent streaming-vs-download CDN rate //! limiting. There is no equivalent shared counter in the Slint stack, so the -//! ported `download_audio` has NO playback-vs-download collision protection. +//! ported `download_audio_to_path` has NO playback-vs-download collision protection. //! This is a documented limitation, NOT a 1:1 download-function gap — a true //! cross-frontend gate is a separate hardening item. Do NOT add a total //! request timeout "to be safe": large hi-res downloads legitimately exceed //! any fixed budget and the connect-timeout already bounds the dial phase. -//! - `download_audio` omits the reference's `.use_native_tls()` — this crate is +//! - `download_audio_to_path` omits the reference's `.use_native_tls()` — this crate is //! rustls-only (no `native-tls` feature), matching `cmaf.rs::build_cdn_client`. //! `http1_only()` (the RST_STREAM/EOF fix) is retained. use reqwest::StatusCode; use serde_json::Value; -use super::auth::{get_timestamp, sign_get_file_url}; +use super::auth::{get_timestamp, sign_get_file_url, sign_get_file_url_download}; use super::client::QobuzClient; use super::endpoints::{self, paths}; use super::error::{ApiError, Result}; use qbz_models::{ - PurchaseAlbum, PurchaseIdsResponse, PurchaseResponse, PurchaseTrack, SearchResultsPage, - StreamRestriction, StreamUrl, + Album, PurchaseAlbum, PurchaseIdsResponse, PurchaseResponse, PurchaseTrack, SearchResultsPage, + StreamRestriction, StreamUrl, Track, }; +/// What the purchases pagination loop should do after receiving a page. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PurchasePageStep { + /// Stop; every item has been collected (or the page came back empty). + Stop, + /// Request the next page starting at this offset. + Continue { next_offset: u32 }, +} + +/// The purchases pagination loop's terminating condition, extracted so it can be +/// asserted rather than merely inspected (contract §12-2). +/// +/// Verbatim from the reference (`src-tauri/src/api/client.rs:1694-1729`): `total` +/// is captured only on the FIRST page, and the loop stops when the page came back +/// empty or when `offset + got >= total`. +/// +/// The case worth naming is `total == 0` with a non-empty page: `0 + got >= 0` +/// holds immediately, so the loop stops after ONE page. That is not a +/// "truncation at 500" — the caller then reports that page's actual length as the +/// total. The behaviour is replicated because the reference shipped it and the +/// endpoint is untestable from here; the caller logs a warning so the symptom is +/// diagnosable if it ever bites. +pub fn purchase_page_step(offset: u32, got: u32, total: u32) -> PurchasePageStep { + if got == 0 || offset.saturating_add(got) >= total { + PurchasePageStep::Stop + } else { + PurchasePageStep::Continue { + next_offset: offset + got, + } + } +} + +/// The two operations `track/getFileUrl` is signed for. The word is part of the +/// signature preimage, so the two never share a signature. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FileUrlIntent { + Stream, + Download, +} + +impl FileUrlIntent { + fn wire(self) -> &'static str { + match self { + FileUrlIntent::Stream => "stream", + FileUrlIntent::Download => "download", + } + } +} + impl QobuzClient { + /// `/album/get` for the PURCHASE path — header-auth, **UNSIGNED**. + /// + /// The general `get_album` signs this call. Signing evidently works (QBZ + /// browses the catalogue with it every day), so this is not a bug being + /// fixed — but on the purchase path it is a variable nobody can test, and it + /// diverges from both implementations known to work there. Neither the + /// reference (`src-tauri/src/api/client.rs:641`) nor Qobuz's own Electron + /// desktop client sign it: in the vendor bundle the request helper takes a + /// SIGN flag, and `album/get`, `track/get` and `purchase/getUserPurchases` + /// all pass it false while `track/getFileUrl` passes it true (contract + /// §2.1b). So the purchase path matches the vendor exactly. + /// + /// Unsigned is NOT anonymous: measured 2026-08-15, `/album/get` with + /// `X-App-Id` alone returns **401**. The user token is required, which is + /// what `authenticated_headers()` supplies. + /// + /// Status IS hard-checked here, unlike the two purchases list endpoints. That + /// asymmetry is the reference's (`client.rs:653-669`) and it is load-bearing: + /// this response is parsed with the STRICT `Album`/`Track` structs, whose + /// optional fields carry no lenient wrapper, so one wrong-typed value fails + /// the whole album rather than degrading to an empty page. + pub async fn get_album_for_purchase(&self, album_id: &str) -> Result { + let url = endpoints::build_url(paths::ALBUM_GET); + let http_response = self + .http()? + .get(&url) + .headers(self.authenticated_headers().await?) + .query(&[("album_id", album_id)]) + .send() + .await?; + let status = http_response.status(); + log::debug!("[Purchases] get_album_for_purchase({album_id}) status={status}"); + + if status == reqwest::StatusCode::NOT_FOUND { + return Err(ApiError::ApiResponse(format!( + "Album {album_id} not found (404)" + ))); + } + if !status.is_success() { + return Err(ApiError::ApiResponse(format!( + "get_album_for_purchase({album_id}) status {status}" + ))); + } + + let response: Value = http_response.json().await?; + Ok(serde_json::from_value(response)?) + } + + /// `/track/get` for the PURCHASE path — header-auth, **UNSIGNED**. + /// + /// Same rationale as `get_album_for_purchase`. This call runs before EVERY + /// purchase download (`legacy_compat.rs:2659` calls `get_track` immediately + /// before `get_track_file_url_by_format`) and supplies the artist, album + /// title and track number that build the on-disk path — and, since the scope + /// expansion, the metadata written into the file's tags. + /// + /// It carries no entitlement. `/track/getFileUrl` is where the right to the + /// file is proved, by its signature, and that call is untouched. + pub async fn get_track_for_purchase(&self, track_id: u64) -> Result { + let url = endpoints::build_url(paths::TRACK_GET); + let http_response = self + .http()? + .get(&url) + .headers(self.authenticated_headers().await?) + .query(&[("track_id", track_id.to_string())]) + .send() + .await?; + let status = http_response.status(); + log::debug!("[Purchases] get_track_for_purchase({track_id}) status={status}"); + + if status == reqwest::StatusCode::NOT_FOUND { + return Err(ApiError::TrackUnavailable(track_id)); + } + if !status.is_success() { + return Err(ApiError::ApiResponse(format!( + "get_track_for_purchase({track_id}) status {status}" + ))); + } + + let response: Value = http_response.json().await?; + Ok(serde_json::from_value(response)?) + } + /// Get one purchases page from Qobuz, optionally constrained by purchase /// type (`"albums"` / `"tracks"`; omitted if `None`). /// @@ -73,6 +205,46 @@ impl QobuzClient { Ok(serde_json::from_value(response)?) } + /// Fail-closed variant used for entitlement decisions. Unlike the legacy + /// Purchases list loader, an HTTP error is never allowed to deserialize as + /// an empty page and masquerade as a fresh "not owned" answer. + pub async fn get_user_purchases_page_typed_checked( + &self, + purchase_type: Option<&str>, + limit: u32, + offset: u32, + ) -> Result { + let url = endpoints::build_url(paths::PURCHASE_GET_USER_PURCHASES); + let mut query: Vec<(&str, String)> = + vec![("limit", limit.to_string()), ("offset", offset.to_string())]; + if let Some(kind) = purchase_type { + query.push(("type", kind.to_string())); + } + + let http_response = self + .http()? + .get(&url) + .headers(self.authenticated_headers().await?) + .query(&query) + .send() + .await?; + let status = http_response.status(); + log::debug!( + "[Purchases] checked entitlement page(type={:?}, limit={}, offset={}) status={}", + purchase_type, + limit, + offset, + status + ); + if !status.is_success() { + return Err(ApiError::ApiResponse(format!( + "checked purchase entitlement request failed with status {status}" + ))); + } + let response: Value = http_response.json().await?; + Ok(serde_json::from_value(response)?) + } + /// Get one purchases-ids page from Qobuz, optionally constrained by purchase /// type. The items are OPAQUE — the UI reads only `.total` per type. /// @@ -200,6 +372,18 @@ impl QobuzClient { &self, purchase_type: &str, ) -> Result { + // Pre-flight validation (contract §8-D2). The reference validates the + // type BEFORE any I/O (`legacy_compat.rs:2773-2778`); this loop used to + // fetch a page first and only reject inside the match arm, so a bogus + // type issued a live authenticated GET before failing. Same error, no + // request. + if purchase_type != "albums" && purchase_type != "tracks" { + return Err(ApiError::ApiResponse(format!( + "Unsupported purchase type: {}", + purchase_type + ))); + } + let page_limit = 500u32; let mut offset = 0u32; @@ -219,10 +403,10 @@ impl QobuzClient { } let got = page.albums.items.len() as u32; all_albums.extend(page.albums.items); - if got == 0 || offset + got >= total { - break; + match purchase_page_step(offset, got, total) { + PurchasePageStep::Stop => break, + PurchasePageStep::Continue { next_offset } => offset = next_offset, } - offset += got; } "tracks" => { if offset == 0 { @@ -230,29 +414,39 @@ impl QobuzClient { } let got = page.tracks.items.len() as u32; all_tracks.extend(page.tracks.items); - if got == 0 || offset + got >= total { - break; + match purchase_page_step(offset, got, total) { + PurchasePageStep::Stop => break, + PurchasePageStep::Continue { next_offset } => offset = next_offset, } - offset += got; - } - _ => { - return Err(ApiError::ApiResponse(format!( - "Unsupported purchase type: {}", - purchase_type - ))); } + // Unreachable: the type is validated before the first request. + // Kept so the match stays exhaustive without an unreachable!(). + _ => break, } } - let final_total = if total == 0 { - if purchase_type == "albums" { - all_albums.len() as u32 - } else { - all_tracks.len() as u32 - } + // §2.3: a ZERO server total terminates the loop after a single page, + // because `offset + got >= total` holds immediately. The returned total + // then falls back to the accumulated length — which is THAT PAGE's actual + // length, not a 500-item truncation. Replicated verbatim because the + // reference shipped it, but it is worth a log line: a user whose library + // silently stops at one page has no other signal, and without this the + // symptom gets misdiagnosed as "truncated at 500". + let accumulated = if purchase_type == "albums" { + all_albums.len() as u32 } else { - total + all_tracks.len() as u32 }; + if total == 0 && accumulated > 0 { + log::warn!( + "[Purchases] {purchase_type}: server reported total=0 while returning \ + {accumulated} item(s); pagination stopped after the first page. \ + Reporting total={accumulated}. If the real library is larger, this \ + is where the items were lost." + ); + } + + let final_total = if total == 0 { accumulated } else { total }; Ok(PurchaseResponse { albums: SearchResultsPage { @@ -278,25 +472,49 @@ impl QobuzClient { }) } - /// Get a signed file URL for a specific `format_id`. - /// - /// RPC-SIGNED (the ONLY signed purchase call): the signature HARD-CODES - /// `intentstream` (`sign_get_file_url`), and the query `intent` is likewise - /// `"stream"` — purchases reuse the streaming intent even though the OpenAPI - /// lists `"download"`. A raw-`format_id` entry point is required because the - /// in-tree `get_stream_url` takes a `Quality` enum that can't express every - /// purchase format id (27/7/6/5). Empty `url` → `ApiError::TrackUnavailable`, - /// HTTP 400 → `ApiError::InvalidAppSecret`. Ported from - /// `src-tauri/src/api/client.rs:1768`. + /// Get a signed stream URL for one track in one format, `intent=stream`. + /// This is the PLAYBACK grant — see [`Self::get_purchase_file_url`] for a + /// purchased file. Kept as the historical name for its one remaining + /// caller class. pub async fn get_track_file_url_by_format( &self, track_id: u64, format_id: u32, + ) -> Result { + self.fetch_file_url_signed(track_id, format_id, FileUrlIntent::Stream) + .await + } + + /// Get a signed DOWNLOAD URL for one purchased track in one entitled + /// format, `intent=download`. + /// + /// Measured live 2026-09-01 on a DSD128 purchase (format 56): the same + /// call with `intent=stream` answers HTTP 400, with `intent=download` it + /// answers 200, `mime_type=audio/x-dsf`, and a URL on + /// `download-v2.qobuz.com` that honours `Range`. The intent is inside the + /// signature preimage, so this is a distinct operation, not a flag. A 400 + /// here is reported as what it most plausibly is — a format the account is + /// not entitled to for this track — never as a bad app secret. + pub async fn get_purchase_file_url(&self, track_id: u64, format_id: u32) -> Result { + self.fetch_file_url_signed(track_id, format_id, FileUrlIntent::Download) + .await + } + + async fn fetch_file_url_signed( + &self, + track_id: u64, + format_id: u32, + intent: FileUrlIntent, ) -> Result { let url = endpoints::build_url(paths::TRACK_GET_FILE_URL); let timestamp = get_timestamp(); let secret = self.secret().await?; - let signature = sign_get_file_url(track_id, format_id, timestamp, &secret); + let signature = match intent { + FileUrlIntent::Stream => sign_get_file_url(track_id, format_id, timestamp, &secret), + FileUrlIntent::Download => { + sign_get_file_url_download(track_id, format_id, timestamp, &secret) + } + }; let response = self .http()? @@ -305,7 +523,7 @@ impl QobuzClient { .query(&[ ("track_id", track_id.to_string()), ("format_id", format_id.to_string()), - ("intent", "stream".to_string()), + ("intent", intent.wire().to_string()), ("request_ts", timestamp.to_string()), ("request_sig", signature), ]) @@ -349,9 +567,19 @@ impl QobuzClient { .map(|v| v as u32), track_id, restrictions, + sample: json + .get("sample") + .and_then(|v| v.as_bool()) + .unwrap_or(false), }) } - StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret), + StatusCode::BAD_REQUEST => match intent { + FileUrlIntent::Stream => Err(ApiError::InvalidAppSecret), + FileUrlIntent::Download => Err(ApiError::ApiResponse(format!( + "HTTP 400 for format {format_id} of track {track_id}: not in this account's \ + download entitlement (or the request signature was rejected)" + ))), + }, status => Err(ApiError::ApiResponse(format!( "Unexpected status: {}", status @@ -359,33 +587,28 @@ impl QobuzClient { } } - /// Download raw audio bytes from a Qobuz CDN URL. + /// Stream a Qobuz CDN body straight to `path`, returning the byte count. /// - /// Ported from `src-tauri/src/commands_v2/helpers.rs:332`, preserving the - /// load-bearing behavior: HTTP/1.1-only (`http1_only` — Qobuz CDN sends - /// RST_STREAM on large HTTP/2 downloads, causing "1 byte then EOF"), a 10s - /// connect timeout, and crucially NO total request timeout (large hi-res - /// downloads must not be capped). The body is streamed in chunks; on a stream - /// error the full `.source()` cause chain is folded into the returned message. + /// Replaces the `Vec` round trip of the original port for purchases: a + /// DSD128 master is ~650 MB per track, and holding all of it before the + /// first write is a memory spike for nothing. The load-bearing transport + /// choices carry over unchanged: HTTP/1.1-only (`http1_only` — Qobuz CDN + /// sends RST_STREAM on large HTTP/2 downloads, causing "1 byte then EOF"), + /// a 10 s connect timeout, and crucially NO total request timeout. /// - /// TLS divergence from the reference: the Tauri build called - /// `.use_native_tls()` because its Cargo opts into both TLS stacks. This crate - /// stays on rustls (no `native-tls` feature — same decision documented in - /// `cmaf.rs::build_cdn_client`), so we omit `.use_native_tls()`. If Akamai/ - /// Qobuz's CDN ever surfaces a cert issue, adding the `native-tls` feature to - /// `qbz-qobuz` is the escape hatch. This is NOT a download-function regression. + /// A body shorter than the announced `Content-Length` is an ERROR, not a + /// warning: the caller is about to give this file a name that promises a + /// complete master. On any error the file at `path` is left for the caller + /// to remove — it owns the `.part` lifecycle. /// - /// The Tauri build wrapped this in the `CDN_STREAMING_ACTIVE` busy-wait gate - /// to avoid concurrent streaming-vs-download CDN throttling. That counter is - /// `src-tauri`-only — the Slint stack has no shared equivalent — so there is - /// no collision protection here (documented limitation; see the module-level - /// note). The error type is `String` to mirror the reference exactly. - pub async fn download_audio(url: &str) -> std::result::Result, String> { + /// TLS: rustls, no `native-tls` (same decision as `cmaf.rs::build_cdn_client`). + pub async fn download_audio_to_path( + url: &str, + path: &std::path::Path, + ) -> std::result::Result { + use std::io::Write; use std::time::Duration; - // Force HTTP/1.1 — Qobuz CDN sends RST_STREAM on large downloads over - // HTTP/2, causing "1 byte then EOF". curl (HTTP/1.1) downloads the same - // URLs successfully. let client = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) .http1_only() @@ -405,81 +628,227 @@ impl QobuzClient { return Err(format!("HTTP error: {}", response.status())); } - // Log response headers for CDN diagnostics (helps debug "1 byte EOF"). { let headers = response.headers(); - let h_ce = headers - .get("content-encoding") - .map(|v| v.to_str().unwrap_or("?")); - let h_te = headers - .get("transfer-encoding") - .map(|v| v.to_str().unwrap_or("?")); - let h_conn = headers.get("connection").map(|v| v.to_str().unwrap_or("?")); - let h_server = headers.get("server").map(|v| v.to_str().unwrap_or("?")); - let h_ct = headers - .get("content-type") - .map(|v| v.to_str().unwrap_or("?")); - let h_via = headers.get("via").map(|v| v.to_str().unwrap_or("?")); + let h = |k: &str| headers.get(k).map(|v| v.to_str().unwrap_or("?")); log::info!( "[Purchases] CDN response: status={}, content-encoding={:?}, transfer-encoding={:?}, connection={:?}, server={:?}, content-type={:?}, via={:?}, version={:?}", response.status(), - h_ce, - h_te, - h_conn, - h_server, - h_ct, - h_via, + h("content-encoding"), + h("transfer-encoding"), + h("connection"), + h("server"), + h("content-type"), + h("via"), response.version() ); } - let content_length = response.content_length(); - if let Some(len) = content_length { + let expected = response.content_length(); + if let Some(len) = expected { log::info!("[Purchases] Downloading audio: {} bytes expected", len); } - // Stream body in chunks to handle partial reads gracefully. - let expected_len = content_length.unwrap_or(0) as usize; - let mut all_data = Vec::with_capacity(expected_len); + let mut file = std::io::BufWriter::new( + std::fs::File::create(path) + .map_err(|e| format!("Failed to create temporary file: {}", e))?, + ); + let mut written: u64 = 0; let mut stream = response.bytes_stream(); use futures_util::StreamExt; while let Some(chunk_result) = stream.next().await { - match chunk_result { - Ok(chunk) => all_data.extend_from_slice(&chunk), - Err(e) => { - use std::error::Error as _; - let mut msg = format!("Failed to read audio bytes: {}", e); - let mut source = e.source(); - while let Some(cause) = source { - msg.push_str(&format!(" | caused by: {}", cause)); - source = cause.source(); - } - // If we got some data but not all, log what we received. - if !all_data.is_empty() { - log::error!( - "[Purchases] Download error after {}/{} bytes: {}", - all_data.len(), - expected_len, - msg - ); - } else { - log::error!("[Purchases] Download error (0 bytes received): {}", msg); - } - return Err(msg); + let chunk = chunk_result.map_err(|e| { + use std::error::Error as _; + let mut msg = format!("Failed to read audio bytes: {}", e); + let mut source = e.source(); + while let Some(cause) = source { + msg.push_str(&format!(" | caused by: {}", cause)); + source = cause.source(); } + log::error!( + "[Purchases] Download error after {}/{} bytes: {}", + written, + expected.unwrap_or(0), + msg + ); + msg + })?; + file.write_all(&chunk) + .map_err(|e| format!("Failed to write temporary file: {}", e))?; + written += chunk.len() as u64; + } + file.flush() + .map_err(|e| format!("Failed to write temporary file: {}", e))?; + + if let Some(len) = expected { + if written != len { + return Err(format!( + "Download truncated: got {} bytes, expected {}", + written, len + )); } } - if expected_len > 0 && all_data.len() != expected_len { - log::warn!( - "[Purchases] Download size mismatch: got {} bytes, expected {}", - all_data.len(), - expected_len - ); - } - - log::info!("[Purchases] Downloaded {} bytes", all_data.len()); - Ok(all_data) + log::info!("[Purchases] Downloaded {} bytes", written); + Ok(written) + } +} + +#[cfg(test)] +mod purchase_contract_tests { + use super::*; + use crate::auth::{generate_signature, sign_get_file_url, sign_get_file_url_download}; + + // ── §12-1: the requests must be byte-identical to the reference ────────── + // + // These assert the SIGNATURE PREIMAGE and the endpoint paths, not just that + // "a signature is produced". The purchase path is the one feature nobody on + // this team can smoke-test — Qobuz Purchases is not sold in the owner's + // region — so inspection is not evidence and these tests are the only thing + // standing between a wrong request and a shipped, invisible failure. + + /// The preimage has NO separators anywhere. The playback grant signs + /// `intentstream`; the purchase grant signs `intentdownload`. Both facts + /// were measured on the wire — the second one live on 2026-09-01 against a + /// DSD128 entitlement, where `stream` answers 400 and `download` answers the + /// file. + #[test] + fn file_url_signature_preimage_is_exact() { + let track_id = 123_456_u64; + let format_id = 27_u32; + let timestamp = 1_700_000_000_u64; + let secret = "0123456789abcdef0123456789abcdef"; + + // Assembled here by hand, character for character, from the contract: + // trackgetFileUrl + format_id{fid} + intentstream + track_id{tid} + // + {timestamp} + {secret} + let expected_preimage = format!( + "trackgetFileUrlformat_id{format_id}intentstreamtrack_id{track_id}{timestamp}{secret}" + ); + let expected = { + use md5::{Digest, Md5}; + let mut hasher = Md5::new(); + hasher.update(expected_preimage.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + + assert_eq!( + sign_get_file_url(track_id, format_id, timestamp, secret), + expected, + "the getFileUrl stream signature preimage drifted; playback will fail auth" + ); + } + + /// The purchase grant: `intentdownload` in the same unseparated shape. + /// This is the signature that fetched a real DSF on 2026-09-01. + #[test] + fn purchase_file_url_signature_preimage_is_exact() { + let track_id = 123_456_u64; + let format_id = 56_u32; + let timestamp = 1_700_000_000_u64; + let secret = "0123456789abcdef0123456789abcdef"; + let expected_preimage = format!( + "trackgetFileUrlformat_id{format_id}intentdownloadtrack_id{track_id}{timestamp}{secret}" + ); + let expected = { + use md5::{Digest, Md5}; + let mut hasher = Md5::new(); + hasher.update(expected_preimage.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + assert_eq!( + sign_get_file_url_download(track_id, format_id, timestamp, secret), + expected, + "the getFileUrl download signature preimage drifted; every purchase download \ + will answer HTTP 400" + ); + assert_eq!(FileUrlIntent::Download.wire(), "download"); + assert_eq!(FileUrlIntent::Stream.wire(), "stream"); + } + + /// Guard the halves of the preimage a well-meaning edit would "fix": the + /// two intents never collide, and there are no separators. + #[test] + fn file_url_signatures_keep_intents_apart_and_carry_no_separators() { + let (tid, fid, ts, secret) = (99_u64, 6_u32, 1_u64, "s"); + let stream = sign_get_file_url(tid, fid, ts, secret); + let download = sign_get_file_url_download(tid, fid, ts, secret); + assert_ne!(stream, download, "intent is inside the preimage"); + + let with_separators = generate_signature( + "trackgetFileUrl", + &format!("format_id{fid}&intentstream&track_id{tid}"), + ts, + secret, + ); + assert_ne!( + stream, with_separators, + "the preimage carries no separators" + ); + } + + /// The endpoint constants themselves — a typo here fails silently into an + /// empty purchases list, because the list endpoints never check status. + #[test] + fn purchase_endpoint_paths_are_the_reference_paths() { + assert_eq!( + paths::PURCHASE_GET_USER_PURCHASES, + "/purchase/getUserPurchases" + ); + assert_eq!( + paths::PURCHASE_GET_USER_PURCHASES_IDS, + "/purchase/getUserPurchasesIds" + ); + assert_eq!(paths::ALBUM_GET, "/album/get"); + assert_eq!(paths::TRACK_GET, "/track/get"); + } + + // ── §12-2: the pagination loop's terminating condition ─────────────────── + + #[test] + fn pagination_stops_on_an_empty_page() { + assert_eq!(purchase_page_step(0, 0, 1_000), PurchasePageStep::Stop); + // Even mid-walk, and even when the server claims more remain. + assert_eq!(purchase_page_step(500, 0, 1_000), PurchasePageStep::Stop); + } + + #[test] + fn pagination_walks_while_items_remain() { + assert_eq!( + purchase_page_step(0, 500, 1_200), + PurchasePageStep::Continue { next_offset: 500 } + ); + assert_eq!( + purchase_page_step(500, 500, 1_200), + PurchasePageStep::Continue { next_offset: 1_000 } + ); + // Final page: 1000 + 200 >= 1200. + assert_eq!( + purchase_page_step(1_000, 200, 1_200), + PurchasePageStep::Stop + ); + } + + /// THE case from §2.3. A zero total stops the walk after one page even + /// though that page was full. The caller then reports the accumulated + /// length, so the symptom is "my library is exactly one page long", not + /// "truncated at 500" — a distinction that has to survive in a test because + /// it cannot be reproduced against a live account here. + #[test] + fn pagination_zero_total_stops_after_one_page_even_when_full() { + assert_eq!(purchase_page_step(0, 500, 0), PurchasePageStep::Stop); + assert_eq!(purchase_page_step(0, 1, 0), PurchasePageStep::Stop); + } + + /// Exact-boundary and overshoot: `>=`, never `>`. + #[test] + fn pagination_boundary_is_inclusive() { + assert_eq!(purchase_page_step(0, 500, 500), PurchasePageStep::Stop); + assert_eq!(purchase_page_step(0, 501, 500), PurchasePageStep::Stop); + assert_eq!( + purchase_page_step(0, 499, 500), + PurchasePageStep::Continue { next_offset: 499 } + ); } } diff --git a/crates/qbz-qobuz/src/qconnect_auth.rs b/crates/qbz-qobuz/src/qconnect_auth.rs new file mode 100644 index 000000000..aae62e0a6 --- /dev/null +++ b/crates/qbz-qobuz/src/qconnect_auth.rs @@ -0,0 +1,88 @@ +//! Qobuz Connect cloud authentication. +//! +//! The official clients send `POST /qws/createToken` with exactly one form +//! field (`jwt=jwt_qws`). Their `user_auth_token_needed` and +//! `strong_auth_needed` values are interceptor controls and never cross the +//! HTTP boundary. Keeping the request here prevents frontend adapters from +//! drifting from that wire contract. + +use serde::Deserialize; + +use crate::client::QobuzClient; +use crate::endpoints::{self, paths}; +use crate::error::{ApiError, Result}; + +const QCONNECT_TOKEN_FORM: [(&str, &str); 1] = [("jwt", "jwt_qws")]; + +/// A QWS credential returned by Qobuz. +/// +/// Deliberately does not implement `Debug`: `jwt` is an in-memory secret and +/// must not leak through formatted errors or diagnostics. +#[derive(Clone, Deserialize)] +pub struct QwsAuthToken { + endpoint: Option, + exp: i64, + jwt: String, +} + +impl QwsAuthToken { + pub fn endpoint(&self) -> Option<&str> { + self.endpoint.as_deref() + } + + pub const fn expires_at(&self) -> i64 { + self.exp + } + + pub fn jwt(&self) -> &str { + &self.jwt + } + + pub fn into_parts(self) -> (Option, i64, String) { + (self.endpoint, self.exp, self.jwt) + } +} + +#[derive(Deserialize)] +struct QwsAuthResponse { + jwt_qws: Option, +} + +impl QobuzClient { + /// Mint the owner credential used by the native QConnect WebSocket + /// transport. The response body is never included in an error because a + /// successful body contains the JWT itself. + pub async fn create_qconnect_token(&self) -> Result { + let headers = self.authenticated_headers().await?; + let response = self + .http()? + .post(endpoints::build_url(paths::QWS_CREATE_TOKEN)) + .headers(headers) + .form(&QCONNECT_TOKEN_FORM) + .send() + .await?; + let status = response.status(); + let body = response.text().await?; + + if !status.is_success() { + return Err(ApiError::ApiResponse(format!( + "qws/createToken failed with HTTP {status}" + ))); + } + + let payload: QwsAuthResponse = serde_json::from_str(&body)?; + payload.jwt_qws.ok_or_else(|| { + ApiError::ApiResponse("qws/createToken response missing jwt_qws".to_string()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::QCONNECT_TOKEN_FORM; + + #[test] + fn create_token_form_matches_official_wire() { + assert_eq!(QCONNECT_TOKEN_FORM, [("jwt", "jwt_qws")]); + } +} diff --git a/crates/qbz-qobuz/src/saved_session_tests.rs b/crates/qbz-qobuz/src/saved_session_tests.rs new file mode 100644 index 000000000..9ed907675 --- /dev/null +++ b/crates/qbz-qobuz/src/saved_session_tests.rs @@ -0,0 +1,222 @@ +use super::*; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +const SHORT: Duration = Duration::from_millis(100); +fn client() -> Arc { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + Arc::new(QobuzClient::new().unwrap()) +} +fn body(id: u64) -> String { + serde_json::json!({"user_auth_token": "fixture-token", "user": {"id": id, "display_name": "Fixture"}}).to_string() +} + +enum Reply { + HeadersStall, + BodyStall, + Drip, + Response(u16, String), + Late(String), + Disconnect, +} +struct Server { + url: String, + task: Option>, + accepted: Option>, +} +impl Server { + async fn new(reply: Reply) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/user/login", listener.local_addr().unwrap()); + let (send, accepted) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + loop { + let len = socket.read(&mut buffer).await.unwrap(); + assert!(len > 0); + request.extend_from_slice(&buffer[..len]); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]); + let body_len = if headers.starts_with("POST ") { + "extra=partner".len() + } else { + 0 + }; + if request.len() >= end + 4 + body_len { + break; + } + } + } + let _ = send.send(()); + match reply { + Reply::HeadersStall => { + let _ = socket.read(&mut buffer).await; + } + Reply::BodyStall => { + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\nConnection: close\r\n\r\n{\"user\":").await.unwrap(); + let _ = socket.read(&mut buffer).await; + } + Reply::Drip => { + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100000\r\nConnection: close\r\n\r\n").await.unwrap(); + for _ in 0..1000 { + if socket.write_all(b" ").await.is_err() { + break; + } + tokio::time::sleep(Duration::from_millis(15)).await; + } + } + Reply::Response(status, body) => { + socket.write_all(format!("HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + Reply::Late(body) => { + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()).await.unwrap(); + tokio::time::sleep(Duration::from_millis(250)).await; + let _ = socket.write_all(body.as_bytes()).await; + } + Reply::Disconnect => {} + } + }); + Self { + url, + task: Some(task), + accepted: Some(accepted), + } + } + async fn finish(mut self) { + let task = self.task.take().unwrap(); + // The fixture must be reaped, including when the client's body closes. + let mut task = task; + let result = tokio::time::timeout(Duration::from_secs(2), &mut task).await; + if result.is_err() { + task.abort(); + let _ = task.await; + panic!("HTTP fixture did not stop"); + } + result.unwrap().unwrap(); + } +} +impl Drop for Server { + fn drop(&mut self) { + if let Some(task) = &self.task { + task.abort(); + } + } +} + +async fn restore(client: &QobuzClient, server: &Server) -> Result { + client + .complete_token_restore( + client + .token_restore_request(&server.url, "fixture-app", "fixture-token") + .unwrap() + .timeout(SHORT), + ) + .await +} + +async fn assert_timeout(reply: Reply) { + let client = client(); + let server = Server::new(reply).await; + let start = std::time::Instant::now(); + let error = restore(&client, &server).await.unwrap_err(); + assert!( + matches!(error, ApiError::NetworkError(ref e) if e.is_timeout()), + "{error}" + ); + assert!(start.elapsed() < Duration::from_millis(750)); + assert!(!client.is_logged_in().await); + server.finish().await; +} +#[tokio::test] +async fn headers_stall_releases_restore() { + assert_timeout(Reply::HeadersStall).await; +} +#[tokio::test] +async fn partial_body_stall_releases_restore() { + assert_timeout(Reply::BodyStall).await; +} +#[tokio::test] +async fn body_trickle_cannot_extend_total_deadline() { + assert_timeout(Reply::Drip).await; +} + +#[tokio::test] +async fn success_auth_rejection_and_transport_keep_their_classification() { + let client = client(); + let rejected = Server::new(Reply::Response(401, String::new())).await; + assert!(matches!( + restore(&client, &rejected).await, + Err(ApiError::AuthenticationError(_)) + )); + rejected.finish().await; + let disconnected = Server::new(Reply::Disconnect).await; + assert!(matches!( + restore(&client, &disconnected).await, + Err(ApiError::NetworkError(_)) + )); + disconnected.finish().await; + let valid = Server::new(Reply::Response(200, body(12))).await; + assert_eq!(restore(&client, &valid).await.unwrap().user_id, 12); + assert_eq!(client.session.read().await.as_ref().unwrap().user_id, 12); + valid.finish().await; +} + +#[tokio::test] +async fn timed_out_response_cannot_replace_a_retried_account() { + let client = client(); + let late = Server::new(Reply::Late(body(1))).await; + assert!(restore(&client, &late).await.is_err()); + let retry = Server::new(Reply::Response(200, body(2))).await; + assert_eq!(restore(&client, &retry).await.unwrap().user_id, 2); + retry.finish().await; + late.finish().await; + assert_eq!(client.session.read().await.as_ref().unwrap().user_id, 2); +} + +#[tokio::test] +async fn canceled_request_cannot_publish_a_late_session() { + let client = client(); + let mut late = Server::new(Reply::Late(body(1))).await; + let request = client + .token_restore_request(&late.url, "app", "token") + .unwrap(); + let owned = client.clone(); + let task = tokio::spawn(async move { owned.complete_token_restore(request).await }); + late.accepted.take().unwrap().await.unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + let retry = Server::new(Reply::Response(200, body(2))).await; + assert_eq!(restore(&client, &retry).await.unwrap().user_id, 2); + retry.finish().await; + late.finish().await; + assert_eq!(client.session.read().await.as_ref().unwrap().user_id, 2); +} + +#[tokio::test] +async fn streaming_builder_and_body_do_not_inherit_restore_timeout() { + let client = client(); + let server = Server::new(Reply::Late("stream fixture".into())).await; + let restore = client + .token_restore_request(&server.url, "app", "token") + .unwrap() + .build() + .unwrap(); + assert_eq!(restore.timeout(), Some(&TOKEN_RESTORE_TIMEOUT)); + let stream = client.http.get(&server.url).build().unwrap(); + assert_eq!(stream.timeout(), None); + let start = std::time::Instant::now(); + let bytes = client + .http + .execute(stream) + .await + .unwrap() + .bytes() + .await + .unwrap(); + assert!(start.elapsed() > SHORT); + assert_eq!(&bytes[..], b"stream fixture"); + server.finish().await; +} diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml new file mode 100644 index 000000000..85bd0aa0c --- /dev/null +++ b/crates/qbz-qt/Cargo.toml @@ -0,0 +1,272 @@ +[package] +name = "qbz-qt" +description = "Qt/QML frontend for QBZ (cxx-qt bridge over the UI-agnostic backend)" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +# The shipped executable remains `qbz`; the package name stays `qbz-qt` so +# package-scoped Cargo commands remain unambiguous. +name = "qbz" +path = "src/main.rs" + +[dependencies] +cxx = "1" +cxx-qt = "0.7" +cxx-qt-lib = { version = "0.7", features = ["qt_full"] } + +tokio = { workspace = true } +log = { workspace = true } +# Hard-exit watchdog (main.rs arm_hard_exit_watchdog): `_exit` terminates +# without running the atexit chain — a hang inside Qt's atexit destructors is +# one of the wedges the watchdog exists to cut short. Already in the lock as +# a transitive dep of half the tree; this only makes it direct. +libc = "0.2" +qbz-disc = { path = "../qbz-disc" } +qbz-rip = { path = "../qbz-rip" } +rand = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +reqwest = { workspace = true } +urlencoding = "2" +# Deep links are validated by a real parse before they are acted on: a +# launcher can split a URL across argv, and a bare scheme is not a URL. +# Already in the workspace lock via qbz-models. +url = "2" +open = "5" +dirs = "5" + +# Share links (src/share_qt.rs). +# Rust owns the clipboard on this side because the toast does: cxx-qt-lib 0.7 +# ships no QClipboard binding, and QML cannot publish QbzShell.toastJson, so +# splitting the copy and its confirmation across the boundary would buy +# nothing. +# +# wayland-data-control: Wayland-native clipboard via the data-control +# protocols (GNOME/KDE/sway/niri/Hyprland), falling back to X11 when Wayland +# is absent (#514 — without it arboard is X11-only and every copy fails on +# Wayland-only sessions). +arboard = { version = "3", features = ["wayland-data-control"] } + +qbz-core = { path = "../qbz-core" } +qbz-app = { path = "../qbz-app" } +qbz-audio = { path = "../qbz-audio" } +# The HiFi Wizard's frontend-agnostic core; Qt owns the presentation adapter. +qbz-dac-wizard-core = { path = "../qbz-dac-wizard-core" } +qbz-qobuz = { path = "../qbz-qobuz" } +# Cross-provider "Open Music Link" resolver. The crate is frontend-agnostic; +# src/link_resolver_qt.rs supplies the live Qobuz search + Qt navigation seams. +qbz-music-link = { path = "../qbz-music-link" } +qbz-models = { path = "../qbz-models" } +qbz-cache = { path = "../qbz-cache" } +qbz-offline-cache = { path = "../qbz-offline-cache" } +qbz-library = { path = "../qbz-library" } +# Derived, rebuildable Local Library read catalog. The Qt app only schedules +# its background bootstrap here; no Qt type crosses into the catalog crate. +qbz-local-catalog = { path = "../qbz-local-catalog" } +# MyQBZ: Mixtapes & Collections backend (schema + migrations, repo, shuffle, +# enqueue). Headless and frontend-agnostic (ADR-006), with the persistent +# library.db schema owned outside the Qt presentation layer. +qbz-mixtape = { path = "../qbz-mixtape" } +qbz-source = { path = "../qbz-source" } +# The media-server protocol crates. `qbz-qt` needs them directly for the SYNC +# (which lives here, where the tokio runtime and the progress UI are) and for +# `Credentials`, which the settings glue derives. +qbz-jellyfin = { path = "../qbz-jellyfin" } +qbz-subsonic = { path = "../qbz-subsonic" } +qbz-media-cache = { path = "../qbz-media-cache" } +# Cast: Chromecast + DLNA discovery, the two device families and the local +# media server (src/cast_qt.rs drives it — nothing is reimplemented). +qbz-cast = { path = "../qbz-cast" } +qbz-plex = { path = "../qbz-plex" } +qbz-lyrics = { path = "../qbz-lyrics" } +# Playlist Importer: the provider scrapers (Spotify / Apple Music / Tidal / +# Deezer), the Qobuz matcher and playlist creation. The implementation stays +# frontend-agnostic (ADR-006); src/playlist_import_qt.rs drives it. +qbz-playlist-import = { path = "../qbz-playlist-import" } +qbz-account-migration = { path = "../qbz-account-migration" } +qbz-text-utils = { path = "../qbz-text-utils" } +qbz-credentials = { path = "../qbz-credentials" } +qbz-i18n = { path = "../qbz-i18n" } +qbz-log = { path = "../qbz-log" } +qbz-theme = { path = "../qbz-theme" } +qbz-integrations = { path = "../qbz-integrations" } +# Last.fm "similar albums" row on the album page (external_reco_qt.rs). +qbz-external-reco = { path = "../qbz-external-reco" } +# System media controls — MPRIS on Linux (what makes the KDE Plasma / GNOME +# now-playing widget and the hardware media keys see QBZ), SMTC/MediaRemote +# elsewhere. Driven by src/media_controls_qt.rs. +# +# Plain, NOT target-gated, and with no features. The crate carries its own cfg +# arms internally and is already a workspace member. +# +# Its Linux backend rides mpris-server -> zbus 4 with its own current-thread +# tokio runtime, which is a DIFFERENT major from the zbus 5 that ksni / ashpd / +# rfd use with async-io. That is the only reason the two coexist in one +# process; the rule in the ksni comment below applies here too — never enable a +# `tokio` feature on any D-Bus dependency. +qbz-media-controls = { path = "../qbz-media-controls" } +# Qobuz Connect: shared session/renderer orchestration and WS transport, +# consumed without a frontend fork. Driven by src/qconnect_transport_qt.rs, +# src/qconnect_engine_qt.rs and the facade/event sink. +qconnect-app = { path = "../qconnect-app" } +qconnect-lan = { path = "../qconnect-lan" } +qconnect-protocol = { path = "../qconnect-protocol" } +qconnect-transport-ws = { path = "../qconnect-transport-ws" } +# Type-level edge only, sanctioned by contract §1.1: the QConnect renderer +# engine trait names `qbz_player::PlaybackState`, and the engine's feeder +# reaches the protected player seams THROUGH `runtime.core().player()` — the +# shared engine trait. No qbz-player/qbz-audio internals are touched +# (TRACK-RULES §7). +qbz-player = { path = "../qbz-player" } +# QConnect: uuid v4 for controller action ids (the B3 facade / B4 sink); +# futures-util for the engine's HTTP feeder +# (StreamExt over the response byte stream). +uuid = { version = "1", features = ["v4"] } +futures-util = { workspace = true } +tempfile = "3" +async-trait = "0.1" +zeroize = "1" + +rusqlite = { version = "0.40", features = ["bundled"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } + +# Native file picker, used by exactly two call sites — the MyQBZ collection +# cover upload (src/myqbz_cover_qt.rs) and the My-QBZ branding icon picker +# (src/myqbz_prefs_qt.rs). +# +# "async-std" is rfd's name for the async-io/ashpd backend. NEVER enable +# rfd's "tokio" feature: it would switch the shared zbus 5 executor choice. +# `default-features = false` keeps the GTK3 backend out; the xdg-portal path +# is the Wayland-correct one and needs no extra system libs. +rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } + +# Linux system tray via StatusNotifierItem (src/tray_linux.rs). macOS and +# Windows use their native implementations in src/tray_macos.rs and +# src/tray_windows.rs. +# +# default-features = false: ksni's default is "tokio", which flips the shared +# zbus 5 executor to tokio graph-wide. We only use the blocking API on +# worker threads; async-io self-drives the connection from any context. +# Feature unification is per-crate-graph, not per-target: any dependency that +# turns on zbus/tokio changes the whole binary. +[target.'cfg(target_os = "linux")'.dependencies] +async-io = "2" +ksni = { version = "0.3", default-features = false, features = ["blocking", "async-io"] } +# Session-bus single-instance guard. Keep the same async-io executor as ksni; +# enabling zbus/tokio would poison the graph-wide executor choice. +zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } + +[build-dependencies] +cxx-qt-build = "0.7" +# A4 (Line Bed QQuickRhiItem): the crate's first hand-written C++ +# (cxx/linebed_item.cpp). `qt-build-utils` provides moc + the Qt include +# paths — it is cxx-qt-build's OWN engine, already in the lock at the same +# version, so this adds no new package. `cc` compiles the static lib +# (likewise already in the lock). +qt-build-utils = "0.7" +cc = "1" + +[target.'cfg(windows)'.build-dependencies] +# Icon + VERSIONINFO + the longPathAware manifest, embedded as PE resources. +# winresource, not winres: winres has been unmaintained since 2021 and this +# is its maintained fork. +winresource = "0.1" + +# macOS only: the Dock-icon activation policy behind the "Hide Dock icon when +# closed to menu bar" setting (Spotify behaviour — close puts the app in the +# menu bar and drops the Dock icon; minimize still goes to the Dock). +# +# objc2 0.5 / objc2-app-kit 0.2 stay on one internally consistent generation; +# mixing objc2 generations in one binary can register duplicate Objective-C +# classes. Only the items used by the native tray/Dock integration are enabled. +# +# ⚠ NOT COMPILED OR TESTED HERE. This tree builds on Linux; the macOS arm is +# behind cfg, so `cargo build` cannot see it. First real check is a macOS +# build — the Mac mini (192.168.0.69) is where that happens. +[target.'cfg(target_os = "windows")'.dependencies] +# SetCurrentProcessExplicitAppUserModelID: the identity Windows groups the +# taskbar button under and matches a toast's app_id against. 0.61 is already +# in the workspace lock. +windows-sys = { version = "0.61", features = [ + "Win32_UI_Shell", + # Single instance: CreateMutexW arbitrates, a named pipe carries the + # handoff, and both need the error codes to tell "already there" from + # "failed". + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_Pipes", + # ProcessIdToSessionId: the pipe name is qualified by session, because + # pipe names are machine-global while the mutex is per-session. + "Win32_System_RemoteDesktop", + "Win32_Storage_FileSystem", + # RegGetValueW: is this install's AppUserModelID actually registered? + # Without that, Windows silently drops every toast. + "Win32_System_Registry", +] } + +[target.'cfg(target_os = "macos")'.dependencies] +# DIRECT objc2, added 2026-08-05 for the menu-bar tray: `declare_class!`, +# `msg_send_id!`, `sel!`, `ClassType`, `DeclaredClass`, `mutability` and +# `rc::Retained` all live at the objc2 root. Pinned to the generation targeted +# by objc2-app-kit 0.2. +objc2 = "0.5" +# `MainThreadMarker` — in THIS generation it is exported by objc2-foundation, +# not by the objc2 root (objc2-foundation-0.2.2/src/lib.rs:152, ungated). The +# native integration imports it from here. +objc2-foundation = { version = "0.2", default-features = false, features = [ + "std", + # NSThread gates `MainThreadMarker::new()` itself + # (objc2-foundation-0.2.2/src/thread.rs:210-213) — without it the type + # exists but only the UNSAFE `new_unchecked` does, which is not what this + # wants: the whole point is to ASK whether we are on the AppKit main + # thread and no-op if not. Caught by the Mac build; invisible on Linux. + "NSThread", +] } +objc2-app-kit = { version = "0.2", default-features = false, features = [ + "std", + # NSApplication: the class and `setActivationPolicy`. + "NSApplication", + # NSResponder: `sharedApplication` is behind `cfg(feature = "NSResponder")` + # (objc2-app-kit-0.2.2/src/generated/NSApplication.rs:326-329) — it is + # NSApplication's superclass, and the NSApplication feature does NOT pull + # it. + "NSResponder", + # NSRunningApplication: where `NSApplicationActivationPolicy` actually + # lives (…/src/generated/NSRunningApplication.rs:33-35), NOT in + # NSApplication.rs as the name suggests. Verified by reading the crate + # rather than by compiling — this arm cannot be built on Linux, and the + # reference's own proven set (crates/qbz/Cargo.toml) lists the same three. + "NSRunningApplication", + # --- macos_chrome.rs (traffic-light centring) ------------------------- + # Every one of these was read out of objc2-app-kit-0.2.2's own sources + # before being added, because this arm cannot be compiled on Linux: + # NSWindow — `NSApplication::mainWindow` is gated on it + # (generated/NSApplication.rs:355), and it carries `frame` + + # `standardWindowButton`. The style-mask / titlebar setters it also + # carries are NO LONGER USED: expanding the client area is Qt's job + # via the 6.9 window flags (see Main.qml), because QCocoaWindow + # recomputes and reassigns the mask behind us. + # NSButton + NSControl + NSView — `standardWindowButton` is gated on + # ALL THREE at once (generated/NSWindow.rs:1181), not just NSButton; + # NSControl is NSButton's superclass and NSView is NSControl's. + # NSView also provides the geometry the centring measures with + # (bounds / frame / setFrame / superview / isFlipped / + # convertRect_toView). + "NSWindow", + "NSButton", + "NSControl", + "NSView", + # --- tray_macos.rs (the menu-bar NSStatusItem) ------------------------ + # Exactly the set the reference proves on a Mac (qbz/Cargo.toml:172-188); + # NSStatusBarButton is separate from NSStatusItem, and NSEvent is what + # `handle_status_click` reads to tell a right-click from a left-click. + "NSStatusBar", + "NSStatusItem", + "NSStatusBarButton", + "NSMenu", + "NSMenuItem", + "NSImage", + "NSEvent", +] } diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs new file mode 100644 index 000000000..8f75fb6aa --- /dev/null +++ b/crates/qbz-qt/build.rs @@ -0,0 +1,1002 @@ +use std::path::Path; + +use cxx_qt_build::{CxxQtBuilder, QmlModule}; + +mod build_support; + +/// Collect every file under `dir` (recursive), crate-root-relative — the +/// baked icon variants (qml/assets/icons//.svg) are too many to +/// list by hand. +/// +/// The strings become qrc ALIASES verbatim (qt-build-utils writes +/// `` from `Path::display()`), and QML asks with `/` +/// (`QbzIcon.qml:231`, `FontPreload.qml:40-46`). On Windows `read_dir` joins +/// with `\`, so normalise here or every asset fails with +/// "QQuickImage: Cannot open". +fn collect_qrc_files(dir: &Path, out: &mut Vec) { + for entry in + std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display())) + { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + collect_qrc_files(&path, out); + } else { + out.push(qrc_alias(&path)); + } + } +} + +/// A qrc alias is always `/`-separated, whatever the host separator. +/// +/// Rewritten only when the BUILD HOST is Windows. On Unix a backslash is a +/// LEGAL filename character, so rewriting it there would quietly point the +/// alias at a different file (or at none). `cfg!` in a build script reads the +/// host, which is exactly the separator `read_dir` just produced. +fn qrc_alias(path: &Path) -> String { + let raw = path.to_string_lossy(); + if cfg!(windows) { + raw.replace('\\', "/") + } else { + raw.into_owned() + } +} + +/// MSVC reports `__cplusplus` as `199711L` no matter what `/std:` says, unless +/// `/Zc:__cplusplus` is passed. Qt refuses that value outright — +/// `qcompilerdetection.h:1317` raises "Qt requires a C++17 compiler, and a +/// suitable value for __cplusplus" on EVERY translation unit that includes a +/// Qt header, ours and moc's alike, so the first Windows build cannot get past +/// the Qt headers. And without `/permissive-` the Qt 6.9 headers do not parse +/// under MSVC's default non-conforming mode: measured here, `qtmochelpers.h:262` +/// C2065 'result', `qcomparehelpers.h:1348` C2968 recursive alias, and C2737 on +/// every moc `staticMetaObject`. Both flags are what Qt's own CMake passes; +/// neither cc-rs nor cxx-qt-build adds either. +/// +/// The build script runs on the HOST, so ask cargo for the TARGET env rather +/// than `cfg!()` — identical for a native build, not for a cross one. +fn apply_msvc_qt_flags(cc: &mut cc::Build) { + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + cc.flag("/Zc:__cplusplus"); + cc.flag("/permissive-"); + } +} + +/// Every RHI variant a Qt Quick shader should carry. +/// +/// `qsb --qt6` is only `100 es,120,150` + HLSL 50 + MSL 12. This is that plus +/// the modern GLES levels and desktop 440, because the runtime asks for the +/// HIGHEST it can use and falls through: on a GLES 3.2 context Qt tries +/// 320, 310, 300, 100 in that order and gives up if none is present. +const SHADER_GLSL: &str = "100 es,300 es,310 es,320 es,120,150,440"; +const SHADER_HLSL: &str = "50"; +const SHADER_MSL: &str = "12"; + +/// The same list without `100 es`, for shaders that use unsigned integer +/// arithmetic. GLSL ES 1.00 (OpenGL ES 2.0) has NO uint type, so SPIRV-Cross +/// has to re-type the literals as int and refuses when that would make one +/// negative — `qsb` then fails the whole bake, not just that variant. +/// +/// The one shader in this position is `ambient.frag`, and its hash CANNOT drop +/// to floats: the reference's own comment (ambient.wgsl) records that a float +/// hash lets the same lattice corner disagree between adjacent cells and draws +/// a straight seam through the warp field. So the ES 2.0 level is dropped +/// instead, and AmbientField's Canvas arm covers that hardware — which is what +/// the fallback exists for. +const SHADER_GLSL_NO_ES100: &str = "300 es,310 es,320 es,120,150,440"; + +/// Opt out of `100 es` by putting this marker anywhere in the shader source. +const NO_ES100_MARKER: &str = "QSB-SKIP-GLES100"; + +/// Opt out of the `-b` batching rewrite for VERTEX shaders by putting this +/// marker anywhere in the source. The rewrite is only valid for Qt Quick +/// scene-graph-batched items (ShaderEffect stages); a custom +/// `QQuickRhiItem` (linebed.vert, A4) owns its attribute layout and the +/// rewrite would corrupt it. +const NO_BATCH_MARKER: &str = "QSB-SKIP-BATCH"; + +/// Is `out` already a current bake of `src`? True only when it exists and is +/// no older than BOTH the shader source and this build script — the script +/// because the variant lists and the `100 es` opt-out live here, so editing +/// them has to invalidate every `.qsb` even though no `.frag` moved. +/// +/// Anything unreadable answers false: a bake we cannot reason about is one we +/// redo. Equal mtimes count as current (a same-second write is the normal case +/// right after a bake, and treating it as stale would restart the churn). +fn up_to_date(src: &Path, out: &Path) -> bool { + let mtime = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok(); + let (Some(out_t), Some(src_t)) = (mtime(out), mtime(src)) else { + return false; + }; + if out_t < src_t { + return false; + } + match mtime(Path::new("build.rs")) { + Some(script_t) => out_t >= script_t, + None => true, + } +} + +/// Locate Qt's `qsb`. PATH first, then the usual install layouts (Linux +/// distro, Homebrew on the Mac mini). +fn find_qsb() -> Option { + if let Ok(explicit) = std::env::var("QSB") { + let p = std::path::PathBuf::from(explicit); + if p.is_file() { + return Some(p); + } + } + // Windows: `qsb.exe`, and `Path::is_file()` does NOT append `.exe` (Rust's + // std only does that for a full path handed to `Command`), so probe both. + let names: &[&str] = if cfg!(windows) { + &["qsb.exe", "qsb"] + } else { + &["qsb"] + }; + if let Ok(path) = std::env::var("PATH") { + for dir in std::env::split_paths(&path) { + for name in names { + let p = dir.join(name); + if p.is_file() { + return Some(p); + } + } + } + } + // aqt / install-qt-action export QT_ROOT_DIR (e.g. F:\Qt\6.9.3\msvc2022_64). + if let Ok(root) = std::env::var("QT_ROOT_DIR") { + for name in names { + let p = std::path::Path::new(&root).join("bin").join(name); + if p.is_file() { + return Some(p); + } + } + } + for candidate in [ + "/usr/lib64/qt6/bin/qsb", + "/usr/lib/qt6/bin/qsb", + "/usr/lib/x86_64-linux-gnu/qt6/bin/qsb", + "/opt/homebrew/opt/qt/bin/qsb", + "/usr/local/opt/qt/bin/qsb", + ] { + let p = std::path::PathBuf::from(candidate); + if p.is_file() { + return Some(p); + } + } + None +} + +/// Compile every `.frag` / `.vert` under `qml/assets/shaders/` to a `.qsb` +/// beside it, with the full variant set. +/// +/// WHY THIS EXISTS. The `.qsb` files used to be baked BY HAND and committed, +/// with no record of the command. They carried SPIR-V + GLSL 440 + MSL and +/// **no GLES variants at all**, so the moment the app got a GLES context every +/// one of them failed with "No GLSL shader code found (versions tried: 320, +/// 310, 300, 100)" and the visualiser went blank (2026-08-11). A hand-baked +/// artifact cannot be audited and will be copied by the next person who adds a +/// shader — so the bake is part of the build now. +/// +/// Missing `qsb` is a WARNING, not an error: the committed `.qsb` are still +/// in the tree and still load, so a box without Qt's shader tools can build. +/// CI explicitly sets `QBZ_PREBUILT_SHADERS=1`: validate the committed packs +/// without looking for qsb or rewriting them. A separate gate tests baking. +/// +/// THE BAKE MUST BE IDEMPOTENT, and that is not a nicety — it is the whole +/// reason `up_to_date` exists. `qsb` REWRITES its output unconditionally: +/// byte-identical content, brand-new mtime (measured — same md5, mtime moves). +/// The `.qsb` live under `qml/assets`, which `main` watches with +/// `cargo:rerun-if-changed=qml` plus one line per qrc file, so an +/// unconditional bake made every build touch a file cargo was watching. Cargo +/// then re-ran this script on the NEXT invocation, which baked again, which +/// dirtied the watch again: `cargo build` never reached a no-op and every +/// `qt-run.sh` recompiled the crate from the build script down. Introduced in +/// 21e941bdf together with the bake itself, and it is why the tree started +/// rebuilding on every run when nothing had changed. +fn build_shaders() { + println!("cargo:rerun-if-env-changed=QBZ_PREBUILT_SHADERS"); + println!("cargo:rerun-if-env-changed=QSB"); + let prebuilt = + build_support::use_prebuilt_shaders(std::env::var("QBZ_PREBUILT_SHADERS").ok().as_deref()) + .unwrap_or_else(|error| panic!("{error}")); + let dir = Path::new("qml/assets/shaders"); + let qsb = if prebuilt { None } else { find_qsb() }; + if !prebuilt && qsb.is_none() { + println!( + "cargo:warning=qsb not found — shaders keep their committed .qsb. \ + Set QSB=/path/to/qsb to re-bake them." + ); + } + for entry in std::fs::read_dir(dir).expect("read shaders dir") { + let src = entry.expect("read shader entry").path(); + let ext = src.extension().and_then(|e| e.to_str()).unwrap_or(""); + if !matches!(ext, "frag" | "vert") { + continue; + } + println!("cargo:rerun-if-changed={}", src.display()); + let out = src.with_extension(format!("{ext}.qsb")); + let Some(qsb) = qsb.as_ref() else { + build_support::require_prebuilt_shader(&src).unwrap_or_else(|error| panic!("{error}")); + println!("cargo:rerun-if-changed={}", out.display()); + continue; + }; + if up_to_date(&src, &out) { + continue; + } + let text = std::fs::read_to_string(&src).unwrap_or_default(); + let glsl = if text.contains(NO_ES100_MARKER) { + SHADER_GLSL_NO_ES100 + } else { + SHADER_GLSL + }; + let mut cmd = std::process::Command::new(&qsb); + cmd.args(["--glsl", glsl, "--hlsl", SHADER_HLSL, "--msl", SHADER_MSL]); + // Vertex shaders used by Qt Quick need the batching rewrite, or the + // scene graph cannot batch the item and falls back per-node — + // EXCEPT custom QQuickRhiItem stages, which own their attribute + // layout (NO_BATCH_MARKER). + if ext == "vert" && !text.contains(NO_BATCH_MARKER) { + cmd.arg("-b"); + } + cmd.arg("-o").arg(&out).arg(&src); + match cmd.status() { + Ok(s) if s.success() => {} + Ok(s) => panic!("qsb failed ({s}) on {}", src.display()), + Err(e) => panic!("could not run qsb on {}: {e}", src.display()), + } + } +} + +/// Emit the two compile-time env vars the About modal's "Build" row reads +/// (`src/about_qt.rs`): the build DATE (`QBZ_BUILD_DATE`, `YYYY-MM-DD`) and the +/// short git COMMIT (`QBZ_BUILD_COMMIT`). Ported from `crates/qbz/build.rs:16-41`. +/// +/// Both degrade gracefully and neither can fail the build: +/// - the date prefers `SOURCE_DATE_EPOCH` (reproducible builds; Flathub sets +/// it) and falls back to the wall clock; +/// - the commit shells out to `git rev-parse --short HEAD` and is simply EMPTY +/// in an offline source tarball with no `.git` (Flathub/Snap). +fn emit_build_stamp() { + // Re-run when HEAD moves so the embedded commit stays fresh. + // + // Ask git where HEAD actually lives instead of assuming `../../.git/HEAD`: + // in a git WORKTREE `.git` is a 70-byte file, not a directory, so that path + // does not exist — and cargo treats a missing `rerun-if-changed` target as + // always-dirty, which re-ran this whole script (per-shader qsb, the + // recursive asset sweep, cxx-qt/moc codegen) on every single cargo + // invocation in the tree. Emit nothing when git cannot answer. + if let Some(head) = std::process::Command::new("git") + .args(["rev-parse", "--git-path", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && std::path::Path::new(s).exists()) + { + println!("cargo:rerun-if-changed={head}"); + } + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); + + let epoch: i64 = std::env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs() as i64) + }) + .unwrap_or(0); + + println!("cargo:rustc-env=QBZ_BUILD_DATE={}", format_ymd(epoch)); + + let commit = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + println!("cargo:rustc-env=QBZ_BUILD_COMMIT={commit}"); +} + +/// Unix timestamp (seconds, UTC) -> `YYYY-MM-DD`, via Howard Hinnant's +/// days→civil algorithm. Copied as-is from `crates/qbz/build.rs:58-75`: it is +/// chrono-free BY DESIGN, and `qbz-qt` has no chrono dependency to lean on. +/// Empty string for a zero/invalid epoch. +fn format_ymd(epoch_secs: i64) -> String { + if epoch_secs <= 0 { + return String::new(); + } + let days = epoch_secs.div_euclid(86_400); + // days_from_civil inverse (Hinnant, "chrono-Compatible Low-Level Date Algorithms"). + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; // [0, 146096] + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let year = if m <= 2 { y + 1 } else { y }; + format!("{year:04}-{m:02}-{d:02}") +} + +/// The hand-written C++ Qt types: A4's `LineBedItem`, A2's +/// `PlasmaItem`, A3's `RibbonItem` and B1's `TunnelFlowItem` (`cxx/*_item.*`, +/// the scenes a ShaderEffect cannot express — line strips / feedback +/// ping-pong / persistent sub-rect texture writes — spec 01 §2.5/§2.1/§2.4, +/// spec 02 §3). +/// +/// The crate had no non-cxx-qt C++ precedent (`src/macos_chrome.rs` is pure +/// Rust objc2), so this adds the minimal wiring: `moc` (the Q_OBJECT meta +/// object) and the Qt include paths come from `qt-build-utils` — already in +/// the lock as cxx-qt-build's own engine, same version — and the compile is +/// a plain `cc::Build` static lib that cargo links into the binary. The Qt +/// LINK libraries are already emitted by CxxQtBuilder below (Gui/Quick are +/// among its modules); this lib only adds objects. +/// +/// The `` headers live in Qt's PRIVATE include tree +/// (`/Qt//Qt`), so those paths are added +/// explicitly. +/// +/// macOS is a DIFFERENT LAYOUT, and the note that used to sit here said the +/// paths were "the same" and that this was untested — it was wrong on the +/// first count and honest on the second. Homebrew ships Qt as FRAMEWORKS: +/// * `qtbuild.include_paths()` yields `/QtCore.framework/Headers`, and +/// that does NOT satisfy `#include ` — inside it, +/// `QtCore` is the umbrella HEADER FILE, not a directory, so the compiler +/// reports "file not found" for every one of these four items. Reproduced +/// outside cargo: the same `-I` fails, `-F ` compiles clean. +/// * the private tree is NOT under `QT_INSTALL_HEADERS` (which has no +/// `QtGui/` at all); it is +/// `/Qt.framework/Headers//Qt`. +/// So macOS gets `-F` plus framework-derived private paths. Verified on the +/// Mac mini M2 (Qt 6.11.1, Homebrew). +fn build_rhi_items() { + let mut qtbuild = qt_build_utils::QtBuild::new(vec![ + "Core".to_string(), + "Gui".to_string(), + "Qml".to_string(), + "Quick".to_string(), + ]) + .expect("Qt6 not found for the RHI items (cxx-qt-build would fail too)"); + + let mut cc = cc::Build::new(); + cc.cpp(true).std("c++17").pic(true).include("cxx"); // the moc outputs do `#include ".h"` + apply_msvc_qt_flags(&mut cc); + for item in [ + "linebed_item", + "plasma_item", + "ribbon_item", + "tunnelflow_item", + "scope_trace_item", + "seek_waveform_item", + "local_tracks_model", + "local_albums_model", + "local_artists_model", + ] { + let header = format!("cxx/{item}.h"); + let source = format!("cxx/{item}.cpp"); + println!("cargo:rerun-if-changed={header}"); + println!("cargo:rerun-if-changed={source}"); + // moc: the Q_OBJECT meta-object. No QML_ELEMENT (the types are + // registered by hand in the .cpps), so no uri/include-path + // arguments are needed. + let moc = qtbuild.moc(&header, qt_build_utils::MocArguments::default()); + cc.file(source).file(&moc.cpp); + } + // Plain helper, no QObject/moc: registers script-specific bundled font + // fallbacks through QFontDatabase before Main.qml constructs any text. + println!("cargo:rerun-if-changed=cxx/font_fallback.cpp"); + cc.file("cxx/font_fallback.cpp"); + // Plain helper, no QObject/moc: enumerates the exact QVulkanInstance that + // Qt Quick will give QRhi. A raw Vulkan instance can see a different order + // when hybrid-GPU implicit layers are active. + println!("cargo:rerun-if-changed=cxx/qt_vulkan_probe.cpp"); + cc.file("cxx/qt_vulkan_probe.cpp"); + println!("cargo:rerun-if-changed=cxx/win_shell.cpp"); + cc.file("cxx/win_shell.cpp"); + // Shell_NotifyIconW tray. No Q_OBJECT, so no moc; the body is inside + // `#ifdef _WIN32` and compiles to nothing on Linux and macOS. + println!("cargo:rerun-if-changed=cxx/win_tray.cpp"); + println!("cargo:rerun-if-changed=cxx/win_tray.h"); + cc.file("cxx/win_tray.cpp"); + for p in qtbuild.include_paths() { + cc.include(p); + } + let version = qtbuild.version().to_string(); + // The build script runs on the HOST, so ask cargo for the TARGET rather + // than using cfg!() — they are the same for a native build and they are + // not for a cross one. + let macos = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos"); + if macos { + // FRAMEWORK search path. Without it every `#include ` in + // cxx/ fails: the `-I .../QtCore.framework/Headers` that + // `include_paths()` produces cannot resolve a module-qualified include, + // because `QtCore` inside that directory is the umbrella header file. + let libs = qtbuild.qmake_query("QT_INSTALL_LIBS"); + cc.flag(&format!("-F{}", libs.trim())); + for module in ["QtCore", "QtGui", "QtQml", "QtQuick"] { + let version_root = format!("{}/{module}.framework/Headers/{version}", libs.trim()); + if Path::new(&version_root).is_dir() { + // Private Qt headers include siblings as + // ; that needs the version root. + cc.include(&version_root); + } + let private = format!("{version_root}/{module}"); + if Path::new(&private).is_dir() { + cc.include(private); + } + } + } else { + let headers = qtbuild.qmake_query("QT_INSTALL_HEADERS"); + for module in ["QtCore", "QtGui", "QtQml", "QtQuick"] { + let version_root = format!("{headers}/{module}/{version}"); + if Path::new(&version_root).is_dir() { + cc.include(&version_root); + } + let private = format!("{version_root}/{module}"); + if Path::new(&private).is_dir() { + cc.include(private); + } + } + } + cc.compile("qbz_rhi_items"); +} + +fn main() { + // The About modal's build stamp. Emitted before anything else so a failure + // anywhere below cannot leave the env vars unset (env! is a compile error + // when they are). + emit_build_stamp(); + + // Bake the shaders BEFORE the qrc sweep below collects qml/assets, so a + // freshly compiled .qsb is the one that gets embedded. + build_shaders(); + + // The hand-written C++ Qt types (cxx/). Static lib linked into the binary; + // the QML types self-register at QGuiApplication construction. + build_rhi_items(); + + // The WHOLE asset tree, recursively. This used to name the root-level + // files by hand with only icons/ and fonts/ collected, so dropping a new + // asset next to hi-res.svg compiled fine and then failed at runtime with + // "QQuickImage: Cannot open" — invisible to cargo check and to the tests. + let mut qrc_files: Vec = Vec::new(); + collect_qrc_files(Path::new("qml/assets"), &mut qrc_files); + // Non-QML resources land at qrc:/qt/qml/com/blitzfc/qbz/ — QML + // files in qml/ reference them relatively (e.g. + // "assets/icons/primary/plus.svg"). + let qrc_refs: Vec<&str> = qrc_files.iter().map(String::as_str).collect(); + // Cargo's default is to rerun build.rs only when a source file changes, so + // dropping a new icon into qml/assets/ produced a binary whose qrc did not + // contain it — the file was on disk and the app logged "Cannot open". + // Watch the whole tree instead. + println!("cargo:rerun-if-changed=qml"); + for f in &qrc_files { + println!("cargo:rerun-if-changed={f}"); + } + + CxxQtBuilder::new() + // Qt modules the bridge links against (Qt6 CMake in /usr/lib64/cmake). + .qt_module("Qml") + .qt_module("Quick") + .qt_module("QuickControls2") + .qml_module(QmlModule { + uri: "com.blitzfc.qbz", + // EVERY #[cxx_qt::bridge] file. A bridge missing here does not + // fail the build: its QML singleton simply does not exist, and + // every `QbzFoo.bar()` in QML becomes a runtime ReferenceError + // that `cargo check` cannot see. The four MyQBZ/Blacklist + // singletons are the newest arrivals; the domain CONTROLLERS + // (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain + // modules and must NOT be listed — only files that declare a + // #[cxx_qt::bridge] mod belong in this array. + rust_files: &[ + "src/bridge.rs", + "src/session_bridge.rs", + "src/shell_bridge.rs", + "src/player_bridge.rs", + "src/queue_bridge.rs", + "src/home_bridge.rs", + "src/viz_bridge.rs", + "src/immersive_bridge.rs", + "src/shader_scene_bridge.rs", + "src/suggestions_bridge.rs", + "src/hotkeys_bridge.rs", + "src/link_resolver_bridge.rs", + "src/search_bridge.rs", + "src/local_bridge.rs", + "src/library_bridge.rs", + "src/album_bridge.rs", + "src/artist_bridge.rs", + "src/scene_bridge.rs", + "src/musician_bridge.rs", + "src/lyrics_qt.rs", + "src/icon_tint_qt.rs", + "src/cast_bridge.rs", + "src/myqbz_bridge.rs", + "src/myqbz_add_bridge.rs", + "src/disco_bridge.rs", + "src/blacklist_bridge.rs", + "src/playlist_picker_bridge.rs", + "src/playlist_manager_bridge.rs", + "src/playlist_import_bridge.rs", + "src/dac_wizard_bridge.rs", + "src/folder_edit_bridge.rs", + "src/playlist_edit_bridge.rs", + "src/qconnect_bridge.rs", + "src/kiosk_nav_bridge.rs", + "src/mini_bridge.rs", + "src/tray_bridge.rs", + "src/about_bridge.rs", + "src/purchases_bridge.rs", + "src/offline_manager_bridge.rs", + "src/track_replace_bridge.rs", + "src/disc_meta_bridge.rs", + "src/tag_editor_bridge.rs", + ], + qml_files: &[ + "qml/LoginScreen.qml", + "qml/Main.qml", + "qml/FontPreload.qml", + "qml/cards/AlbumCard.qml", + "qml/cards/ArtistCard.qml", + "qml/cards/CollectionMosaic.qml", + "qml/cards/LabelCard.qml", + "qml/cards/MixArtwork.qml", + "qml/cards/PlaylistCard.qml", + "qml/cards/PlaylistCollage.qml", + "qml/cards/RadioCard.qml", + "qml/cards/SlimCard.qml", + "qml/cards/TrackCard.qml", + "qml/controls/AddToMixtapeModal.qml", + "qml/controls/CardMenu.qml", + "qml/controls/CommandBlock.qml", + "qml/controls/CardOverlayButton.qml", + "qml/controls/CardOverlayRow.qml", + "qml/controls/FolderEditPanel.qml", + "qml/controls/FolderModals.qml", + "qml/controls/GroupHeader.qml", + "qml/controls/LinkResolverModal.qml", + "qml/controls/MyQbzModals.qml", + "qml/controls/PlaylistCreateModal.qml", + "qml/controls/PlaylistEditModal.qml", + "qml/controls/PlaylistImportModal.qml", + "qml/controls/PlaylistPickerModal.qml", + "qml/controls/PmFolderIcon.qml", + "qml/controls/QbzCircleAction.qml", + "qml/controls/QbzColorPicker.qml", + "qml/controls/QbzConfirmModal.qml", + "qml/controls/QbzContextMenu.qml", + "qml/controls/QbzEmptyState.qml", + "qml/controls/QbzIconButton.qml", + "qml/controls/QbzLineEdit.qml", + "qml/controls/QbzLoadingDots.qml", + "qml/controls/QbzLoadMore.qml", + "qml/controls/QbzMultiSelectBar.qml", + "qml/controls/QbzNavButton.qml", + "qml/controls/QbzOfflinePlaceholder.qml", + "qml/controls/QbzPrimaryButton.qml", + "qml/controls/QbzRadioOption.qml", + "qml/controls/QbzSectionHeader.qml", + // Per-page scroll memory for the back/forward stack; mounted + // beside each view's scroll container. + "qml/controls/ScrollMemory.qml", + "qml/controls/ScopePanel.qml", + "qml/controls/QbzSegToggle.qml", + "qml/controls/QbzSelect.qml", + "qml/controls/QbzSplitButton.qml", + "qml/controls/QbzSlider.qml", + "qml/controls/QbzTabBar.qml", + "qml/controls/QbzTextArea.qml", + "qml/controls/QbzToast.qml", + "qml/controls/QbzToggle.qml", + "qml/controls/QbzToolButton.qml", + "qml/controls/QbzTooltip.qml", + "qml/controls/QbzProgressRing.qml", + // Applied-filters tooltip: the trigger a filter control mounts + // beside itself (it writes the shell channel QbzTooltip reads). + "qml/controls/QbzFilterTip.qml", + // Home > Qobuz Playlists: the category filter, trigger + popup. + "qml/controls/PlaylistTagFilterButton.qml", + "qml/controls/PlaylistTagFilterPopup.qml", + "qml/controls/QualityBadge.qml", + "qml/controls/QualityMini.qml", + "qml/controls/SettingRow.qml", + "qml/controls/IconTextButton.qml", + "qml/controls/SettingsButton.qml", + "qml/controls/SettingsDivider.qml", + "qml/controls/SettingsSpacer.qml", + // Moved out of views/local/ on 2026-07-31: the album/track + // CARD badges mount it too, and Slint keeps its counterpart in + // primitives/ (SourceGlyph.slint). + "qml/controls/SourceIcon.qml", + // "Find available version" (2026-08-17 unavailable-tracks + // contract §6). Mounted in AppShell beside its modal + // neighbours; missing from this array it is absent from the + // qrc and the mount fails to resolve. + "qml/controls/TrackReplacementModal.qml", + "qml/controls/DiscMetaModal.qml", + "qml/controls/TagEditorModal.qml", + "qml/controls/TrackMetadataModal.qml", + "qml/controls/LocalMediaInfoModal.qml", + "qml/controls/OfflineCacheChoiceModal.qml", + "qml/controls/TagEditorWorkspace.qml", + "qml/controls/RipWizardModal.qml", + "qml/controls/RipProgressModal.qml", + "qml/controls/RipTick.qml", + "qml/rows/BlacklistRow.qml", + "qml/rows/TrackCols.qml", + "qml/rows/TrackListHeader.qml", + "qml/rows/TrackRow.qml", + "qml/settings/AppearanceSettings.qml", + "qml/settings/CustomThemeEditor.qml", + "qml/settings/IntegrationsSettings.qml", + "qml/settings/SettingsView.qml", + "qml/shell/AmbientField.qml", + "qml/shell/AppShell.qml", + "qml/shell/ArtPreviewOverlay.qml", + "qml/shell/Cortinilla.qml", + "qml/shell/HeaderBar.qml", + "qml/shell/LyricsPanel.qml", + "qml/shell/NowPlayingBar.qml", + "qml/shell/NowPlayingBarSmall.qml", + "qml/shell/PlayerBar.qml", + "qml/shell/QueuePanel.qml", + // Kiosk shell (2026-08-02 kiosk-port contract). The router is + // SHARED with AppShell (contract D3) and lives here rather + // than under kiosk/ for that reason. + "qml/shell/ContentRouter.qml", + "qml/shell/KioskShell.qml", + "qml/kiosk/NavRail.qml", + "qml/kiosk/KioskCard.qml", + "qml/kiosk/KioskArtwork.qml", + "qml/kiosk/KioskCoverSource.qml", + "qml/kiosk/KioskEmptyState.qml", + "qml/kiosk/KioskLocalAlbumGrid.qml", + "qml/kiosk/KioskLocalAlbumsTab.qml", + "qml/kiosk/KioskLocalArtistsTab.qml", + "qml/kiosk/KioskLocalEphemeralTab.qml", + "qml/kiosk/KioskLocalFoldersTab.qml", + "qml/kiosk/KioskLocalGenresTab.qml", + "qml/kiosk/KioskLocalTrackList.qml", + "qml/kiosk/KioskLocalTracksTab.qml", + "qml/kiosk/KioskMyQBZDetailLite.qml", + "qml/kiosk/KioskNavigation.qml", + "qml/kiosk/KioskNowPlayingBar.qml", + "qml/kiosk/KioskShelf.qml", + "qml/kiosk/KioskSkeleton.qml", + "qml/kiosk/KioskAlbumGrid.qml", + "qml/kiosk/KioskTrackRow.qml", + "qml/kiosk/KioskArtistCard.qml", + "qml/kiosk/KioskSearch.qml", + "qml/kiosk/KioskNowPlaying.qml", + "qml/kiosk/KioskDiscover.qml", + "qml/kiosk/KioskLibrary.qml", + "qml/kiosk/KioskLocalAlbum.qml", + "qml/kiosk/KioskPlaylist.qml", + "qml/kiosk/KioskLocalLibrary.qml", + "qml/kiosk/KioskMyQBZ.qml", + "qml/kiosk/KioskArtist.qml", + "qml/kiosk/KioskAlbum.qml", + "qml/shell/Sidebar.qml", + "qml/shell/SidebarFolderFlyout.qml", + "qml/shell/SidebarRowMenu.qml", + "qml/shell/SidebarNowPlayingDock.qml", + "qml/shell/AudioSettingsMenu.qml", + "qml/shell/AlbumQuickView.qml", + "qml/shell/ViewModeMenu.qml", + // Hotkeys (2026-08-03 hotkeys-port contract §4.4/§4.5, block + // B3): the read-only cheatsheet + the editable customize + // editor, both self-gated on QbzHotkeys and mounted in + // AppShell with the global overlays. + "qml/shell/KeyboardShortcutsModal.qml", + "qml/shell/CustomizeShortcutsModal.qml", + // Qobuz Connect (2026-08-01 contract §2): the ONE shared + // device flyout both bars mount + the diagnostics modal + // AppShell mounts last. + "qml/shell/QconnectFlyout.qml", + "qml/shell/QconnectPlaybackConflictModal.qml", + "qml/shell/LogViewerModal.qml", + "qml/shell/ReportIssueModal.qml", + "qml/shell/QconnectDevModal.qml", + // About QBZ + What's New (the header menu's last two rows), + // both mounted at the AppShell overlay tail and self-gated on + // QbzAbout's two documents. + "qml/shell/AboutModal.qml", + "qml/shell/WhatsNewModal.qml", + "qml/shell/WindowsDisclaimerModal.qml", + // Immersive mode (2026-08-02 immersive-port contract §2) — + // its own module directory like views/local/ and + // views/playlistmanager/. B2 shipped the root overlay + the + // header band; B3 adds the atmosphere underlay, the five + // FOCUS panels and the song card / track meta / equalizer; + // B4 adds the SPLIT panels (lyrics split mount lives in + // ImmersiveView) + the two remaining FOCUS panels; B5 adds + // the player bar + the search cortinilla. + "qml/immersive/AlbumReactivePanel.qml", + "qml/immersive/CoverflowPanel.qml", + "qml/immersive/EqualizerBars.qml", + "qml/immersive/ImmersiveAtmosphere.qml", + "qml/immersive/ImmersiveHeader.qml", + "qml/immersive/ImmersivePlayerBar.qml", + "qml/immersive/ImmersiveSearchCortinilla.qml", + "qml/immersive/ImmersiveSongCard.qml", + "qml/immersive/ImmersiveTrackMeta.qml", + "qml/immersive/ImmersiveView.qml", + "qml/immersive/LyricsFocusPanel.qml", + "qml/immersive/QueueTabsPanel.qml", + "qml/immersive/ReactiveRingsPanel.qml", + "qml/immersive/ReactiveSplitPanel.qml", + // Shader scenes (2026-08-15 immersive-completion contract, + // block A1): the bottom-most scene layer (Tunnel / Aurora / + // Ambient) driven by QbzShaderScene. + "qml/immersive/ShaderSceneLayer.qml", + // Block A4: the Line Bed scene (mode 5) — the QML wrapper + // for the C++ LineBedItem (cxx/linebed_item.*). Its own + // file so the RHI item's contract is one screen of QML. + "qml/immersive/LineBedScene.qml", + // Block A2: Plasma (scene 1) — QML wrapper for PlasmaItem + // (cxx/plasma_item.*), the WGSL->GLSL feedback port. + "qml/immersive/PlasmaScene.qml", + // Block A3: Spectral Ribbon (scene 4) — RibbonItem wrapper + // plus the stream/FFT overlay ported from + // ImmersiveSpectralOverlay.slint. + "qml/immersive/RibbonScene.qml", + "qml/immersive/SpectralOverlay.qml", + // Block B1: Tunnel Flow (scene 8, Qt-only) — the QML wrapper + // for the C++ TunnelFlowItem (cxx/tunnelflow_item.*), the + // Tauri Canvas2D tunnel ported to a feedback fragment shader. + "qml/immersive/TunnelFlowScene.qml", + "qml/immersive/SpectrumPanel.qml", + "qml/immersive/StaticPanel.qml", + "qml/immersive/SuggestionsPanel.qml", + "qml/immersive/TinyBar.qml", + "qml/immersive/TrackInfoPanel.qml", + "qml/immersive/VolumeBar.qml", + "qml/immersive/WaveBedPanel.qml", + "qml/theme/AmbientAccent.qml", + "qml/theme/QbzIcon.qml", + "qml/theme/QbzKineticScroll.qml", + "qml/theme/QbzScrollBar.qml", + "qml/theme/QbzSpinner.qml", + "qml/theme/QbzTheme.qml", + "qml/theme/RoundedImage.qml", + "qml/views/AlbumView.qml", + "qml/views/CoverLightbox.qml", + "qml/views/ArtistReleasesView.qml", + "qml/views/ArtistSceneView.qml", + "qml/views/scene/SceneGenreFilter.qml", + "qml/views/scene/AlphaIndexRail.qml", + "qml/views/scene/ScenePanelMode.qml", + "qml/views/MusicianPageView.qml", + "qml/views/ArtistView.qml", + "qml/views/BlacklistManagerView.qml", + "qml/views/LibraryFoldersView.qml", + "qml/views/HomeView.qml", + "qml/views/LibraryView.qml", + "qml/views/LocalLibraryView.qml", + "qml/views/PlaylistRail.qml", + "qml/views/PlaylistView.qml", + "qml/views/QueueView.qml", + "qml/views/SearchView.qml", + "qml/views/SectionRail.qml", + "qml/shell/CastPicker.qml", + "qml/controls/BrowseGenreButton.qml", + "qml/controls/QbzToggleButton.qml", + "qml/controls/ViewModeToggle.qml", + "qml/views/AlbumCollection.qml", + "qml/views/AlbumListHeader.qml", + "qml/views/AlbumListRow.qml", + "qml/views/DiscoverBrowseView.qml", + "qml/views/LabelReleasesView.qml", + "qml/views/LabelView.qml", + "qml/views/MixView.qml", + "qml/views/PlayHistoryView.qml", + "qml/views/PlaylistBrowseView.qml", + "qml/views/PlaylistListRow.qml", + "qml/controls/DiscoverConfigModal.qml", + "qml/controls/GenreFilterPopup.qml", + "qml/shell/NavFlyout.qml", + "qml/shell/NavSectionGlyph.qml", + "qml/controls/HeaderGradient.qml", + "qml/controls/QbzJumpNavBar.qml", + "qml/shell/LyricsControlsFlyout.qml", + "qml/shell/LyricsLineRow.qml", + "qml/shell/LyricsLinesView.qml", + "qml/shell/LyricsSyncEngine.qml", + "qml/shell/NavGestureLayer.qml", + "qml/controls/QbzCheckbox.qml", + "qml/controls/SelectionModel.qml", + // Library lane (B1-B5): the promoted A-Z strip + the + // per-surface bodies LibraryView.qml was split into. + "qml/controls/QbzAlphaStrip.qml", + "qml/views/library/FeedGridCell.qml", + "qml/views/library/FeedListRow.qml", + "qml/views/library/LibraryAlbumsList.qml", + "qml/views/library/LibraryArtistsPanel.qml", + "qml/views/library/LibraryToolbar.qml", + "qml/controls/WarningBanner.qml", + "qml/settings/AudioSettings.qml", + "qml/settings/BlacklistSettings.qml", + "qml/settings/DacWizardModal.qml", + "qml/settings/DeveloperSettings.qml", + "qml/settings/ImportExportSettings.qml", + "qml/settings/MigrationSetupModal.qml", + "qml/settings/MigrationProgressModal.qml", + "qml/settings/DiagnosticsPanel.qml", + "qml/settings/LibFolderEditModal.qml", + "qml/settings/LibraryFolderTable.qml", + "qml/settings/LocalLibrarySettings.qml", + "qml/settings/LocalTabsConfigModal.qml", + "qml/settings/OfflineSettings.qml", + "qml/settings/PlaybackSettings.qml", + // ONE component instantiated twice (Jellyfin + Subsonic). + "qml/settings/MediaServerSettings.qml", + "qml/settings/PlexSettings.qml", + "qml/settings/SandboxSettings.qml", + "qml/settings/SettingsConfirmHost.qml", + "qml/controls/QbzSkeleton.qml", + "qml/shell/FavToggle.qml", + "qml/shell/InfoCreditCell.qml", + "qml/shell/InfoMetaCell.qml", + "qml/shell/SongCard.qml", + "qml/shell/TrackInfoBody.qml", + "qml/shell/TrackInfoModal.qml", + "qml/shell/AlbumInfoModal.qml", + "qml/shell/MusicianModal.qml", + "qml/shell/TransportControls.qml", + "qml/views/local/LocalIconSelect.qml", + "qml/views/local/LocalTip.qml", + "qml/controls/QualityBadgeFull.qml", + "qml/controls/QualityInline.qml", + "qml/shell/AudioStamp.qml", + "qml/shell/SongCardStamp.qml", + "qml/shell/SpectrumBand.qml", + "qml/shell/VizSettle.qml", + "qml/views/LocalAlbumView.qml", + "qml/views/local/FilterChip.qml", + "qml/views/local/FolderSubcard.qml", + "qml/views/local/LocalAlbumCollection.qml", + "qml/views/local/LocalAlbumHeader.qml", + "qml/views/local/LocalAlbumRow.qml", + "qml/views/local/LocalAlbumsTab.qml", + "qml/views/local/LocalArtistRow.qml", + "qml/views/local/LocalArtistsTab.qml", + "qml/views/local/LocalChrome.qml", + "qml/views/local/LocalScanProgress.qml", + "qml/views/local/LocalEphemeralPane.qml", + "qml/views/local/LocalFilterPopup.qml", + "qml/views/local/LocalFilterButton.qml", + "qml/views/local/LocalFolderDetail.qml", + "qml/views/local/LocalFoldersTab.qml", + "qml/views/local/LocalGenreColumn.qml", + "qml/views/local/LocalGenreDetails.qml", + "qml/views/local/LocalGenresTab.qml", + "qml/views/local/LocalNote.qml", + "qml/views/local/LocalSearchBox.qml", + "qml/views/local/LocalToolbar.qml", + "qml/views/local/LocalTrackRow.qml", + "qml/views/local/LocalTracksTab.qml", + "qml/views/local/LocalTreeRail.qml", + "qml/views/local/SelectCheck.qml", + "qml/views/local/TreeRow.qml", + "qml/views/local/VersionPicker.qml", + // MyQBZ (Mixtapes / Collections / Artist-Collection builder). + // Its own subdirectory, like views/local/, because the five + // files only ever mount each other. + "qml/views/myqbz/DiscoBuilderView.qml", + "qml/views/myqbz/DiscoCandidateRow.qml", + "qml/views/myqbz/MyQbzCard.qml", + "qml/views/myqbz/MyQbzDetailRow.qml", + "qml/views/myqbz/MyQbzDetailView.qml", + "qml/views/myqbz/MyQbzGridView.qml", + // Purchases (routes "purchases" and "purchase-album") — the + // opt-in Qobuz store surface, plus its own module directory + // like views/local/ and views/myqbz/. + // + // TWO different silent failures meet here, and the feature is + // the one nobody can smoke-test (it is not sold in the owner's + // region, so their account returns an empty list forever): + // a missing ROUTE view leaves the qrc without the file and the + // router mounts nothing, and a missing MODULE file fails its + // PARENT at load with "… is not a type" — taking the whole + // screen, not one row. Both are invisible to cargo check and to + // both audit scripts, and `show_purchases` defaults OFF, so + // neither would be noticed by anyone who never turns it on. + // Offline Cache Manager (route "offlinemanager") — reached + // only from Settings > Offline. A route view missing from this + // array is absent from the qrc and the router mounts NOTHING: + // a blank pane, invisible to cargo check and to both audits. + "qml/views/OfflineManagerView.qml", + // Awards (routes "award" and "awardalbums"). Both are route + // views: absent from the qrc, the router mounts NOTHING and + // the pane is blank — invisible to cargo check and to both + // audits. + "qml/views/AwardView.qml", + "qml/views/AwardAlbumsView.qml", + "qml/views/PurchasesView.qml", + "qml/views/PurchaseAlbumView.qml", + "qml/views/purchases/PurchaseAlbumsCollection.qml", + "qml/views/purchases/PurchaseGridCard.qml", + "qml/views/purchases/PurchaseListRow.qml", + "qml/views/purchases/PurchaseListHeader.qml", + "qml/views/purchases/PurchaseTrackRow.qml", + "qml/views/purchases/PurchaseTracksCollection.qml", + "qml/views/purchases/PurchasesToolbar.qml", + // Playlist Manager (route "playlistmanager"): the router target + // plus the TWELVE files of its own module directory. A .qml + // missing from this array is absent from the qrc and fails its + // PARENT file at load with "… is not a type" — invisible to + // cargo check, and it takes the whole view down, not one row. + "qml/views/PlaylistManagerView.qml", + "qml/views/playlistmanager/PmActionButton.qml", + "qml/views/playlistmanager/PmFolderCard.qml", + "qml/views/playlistmanager/PmFolderChip.qml", + "qml/views/playlistmanager/PmFolderMenu.qml", + "qml/views/playlistmanager/PmGridCard.qml", + "qml/views/playlistmanager/PmListRow.qml", + "qml/views/playlistmanager/PmLocalBadge.qml", + "qml/views/playlistmanager/PmMenuRow.qml", + "qml/views/playlistmanager/PmPageHead.qml", + "qml/views/playlistmanager/PmToolbar.qml", + "qml/views/playlistmanager/PmTreeFolderRow.qml", + "qml/views/playlistmanager/PmTreePlaylistRow.qml", + // Miniplayer (2026-08-03 miniplayer/tray-port contract §3.4) — + // its own module directory, like immersive/ and kiosk/. B2 + // ships the window, the card shell and the two DISPLAY + // surfaces; B3 the footer, its four primitives and the hover + // capsule; B4 the queue and lyrics surfaces. + "qml/miniplayer/MiniWindow.qml", + "qml/miniplayer/MiniShell.qml", + "qml/miniplayer/MiniExplicitBadge.qml", + "qml/miniplayer/MiniCoverArt.qml", + "qml/miniplayer/MiniCompactSurface.qml", + "qml/miniplayer/MiniArtworkSurface.qml", + "qml/miniplayer/MiniQueueSurface.qml", + "qml/miniplayer/MiniLyricsSurface.qml", + "qml/miniplayer/MiniFooter.qml", + "qml/miniplayer/MiniWindowControls.qml", + "qml/miniplayer/MiniSeek.qml", + "qml/miniplayer/MiniTransport.qml", + "qml/miniplayer/MiniVolume.qml", + "qml/miniplayer/TBtn.qml", + "qml/miniplayer/CapBtn.qml", + ], + qrc_files: &qrc_refs, + ..Default::default() + }) + .cc_builder(apply_msvc_qt_flags) + .build(); + + embed_windows_resources(); +} + +/// Icon, VERSIONINFO and the manifest, as PE resources. +/// +/// The manifest declares `longPathAware` and NOTHING ELSE. Never add +/// `dpiAware`/`dpiAwareness`: Qt 6 sets PerMonitorV2 itself at startup, a +/// manifest declaration is immutable, and the two together either log a +/// permanent COM error 0x5 or silently degrade scaling. +/// +/// `build.rs` runs with `crates/qbz-qt` as its working directory, hence +/// `../../packaging`. +#[cfg(windows)] +fn embed_windows_resources() { + println!("cargo:rerun-if-changed=../../packaging/icons/icon.ico"); + println!("cargo:rerun-if-changed=../../packaging/windows/qbz.exe.manifest"); + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../packaging/icons/icon.ico"); + res.set_manifest_file("../../packaging/windows/qbz.exe.manifest"); + res.set("ProductName", "QBZ"); + res.set("FileDescription", "QBZ — Qobuz hi-res player"); + res.set("LegalCopyright", "MIT — github.com/vicrodh/qbz"); + res.compile().expect("windows resource"); +} + +#[cfg(not(windows))] +fn embed_windows_resources() {} diff --git a/crates/qbz-qt/build_support.rs b/crates/qbz-qt/build_support.rs new file mode 100644 index 000000000..c4df827d9 --- /dev/null +++ b/crates/qbz-qt/build_support.rs @@ -0,0 +1,94 @@ +//! Pure build-policy checks, also exercised by the Qt unit-test gate. + +use std::path::Path; + +pub fn use_prebuilt_shaders(value: Option<&str>) -> Result { + match value { + None | Some("0") => Ok(false), + Some("1") => Ok(true), + Some(value) => Err(format!( + "QBZ_PREBUILT_SHADERS must be 0 or 1, got {value:?}" + )), + } +} + +/// Prebuilt mode must not turn a missing pack into a successful build. +/// Shader compilation/variant coverage remains the separate shader bake gate. +pub fn require_prebuilt_shader(source: &Path) -> Result<(), String> { + let extension = source.extension().and_then(|s| s.to_str()).unwrap_or(""); + let pack = source.with_extension(format!("{extension}.qsb")); + match std::fs::metadata(&pack) { + Ok(meta) if meta.is_file() && meta.len() > 0 => Ok(()), + _ => Err(format!( + "missing or empty prebuilt shader {}; re-bake with qsb before building", + pack.display() + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "qbz-shader-policy-{}-{nonce}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&dir).unwrap(); + Self(dir) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + + #[test] + fn prebuilt_mode_requires_an_explicit_valid_choice() { + assert!(!use_prebuilt_shaders(None).unwrap()); + assert!(!use_prebuilt_shaders(Some("0")).unwrap()); + assert!(use_prebuilt_shaders(Some("1")).unwrap()); + assert!(use_prebuilt_shaders(Some("true")).is_err()); + assert!(use_prebuilt_shaders(Some("")).is_err()); + } + + #[test] + fn prebuilt_mode_rejects_missing_empty_and_directory_packs() { + let fixture = Fixture::new(); + let source = fixture.0.join("effect.frag"); + let pack = fixture.0.join("effect.frag.qsb"); + assert!(require_prebuilt_shader(&source).is_err()); + std::fs::write(&pack, []).unwrap(); + assert!(require_prebuilt_shader(&source).is_err()); + std::fs::remove_file(&pack).unwrap(); + std::fs::create_dir(&pack).unwrap(); + assert!(require_prebuilt_shader(&source).is_err()); + } + + #[test] + fn prebuilt_mode_preserves_both_shader_stages_byte_for_byte() { + let fixture = Fixture::new(); + for stage in ["frag", "vert"] { + let source = fixture.0.join(format!("effect.{stage}")); + let pack = fixture.0.join(format!("effect.{stage}.qsb")); + let bytes = b"prebuilt fixture"; + std::fs::write(&pack, bytes).unwrap(); + require_prebuilt_shader(&source).unwrap(); + assert_eq!(std::fs::read(&pack).unwrap(), bytes); + } + } +} diff --git a/crates/qbz-qt/cxx/font_fallback.cpp b/crates/qbz-qt/cxx/font_fallback.cpp new file mode 100644 index 000000000..4c149dfab --- /dev/null +++ b/crates/qbz-qt/cxx/font_fallback.cpp @@ -0,0 +1,99 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +QtMessageHandler previousMessageHandler = nullptr; +bool messageFilterInstalled = false; + +bool isExpectedDevanagariCapabilityWarning( + QtMsgType type, const QMessageLogContext &context, const QString &message) +{ + return type == QtWarningMsg + && context.category != nullptr + && qstrcmp(context.category, "qt.text.font.db") == 0 + && message.startsWith(QStringLiteral("OpenType support missing for \"")) + && message.endsWith(QStringLiteral(", script 11")); +} + +void fontMessageHandler( + QtMsgType type, const QMessageLogContext &context, const QString &message) +{ + // Qt probes the selected UI face before consulting the application + // fallback. A face without Devanagari tables therefore emits this benign + // diagnostic even though the registered Noto face renders the text. Keep + // every other Qt diagnostic intact. + if (isExpectedDevanagariCapabilityWarning(type, context, message)) + return; + + if (previousMessageHandler != nullptr) { + previousMessageHandler(type, context, message); + return; + } + + const auto rendered = qFormatLogMessage(type, context, message).toLocal8Bit(); + std::fwrite(rendered.constData(), 1, rendered.size(), stderr); + std::fputc('\n', stderr); + std::fflush(stderr); +} + +void installFontMessageFilter() +{ + if (messageFilterInstalled) + return; + previousMessageHandler = qInstallMessageHandler(fontMessageHandler); + messageFilterInstalled = true; +} + +} // namespace + +extern "C" bool qbz_register_devanagari_fallback() +{ + // This is an application font, not the UI font. Register it only for + // Devanagari so Qt never has to walk (and warn about) unrelated system + // families when catalog metadata contains that script. + const auto id = QFontDatabase::addApplicationFont(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/fonts/" + "NotoSansDevanagari-VariableFont_wght.ttf")); + if (id < 0) + return false; + + const auto families = QFontDatabase::applicationFontFamilies(id); + if (families.isEmpty()) + return false; + QFontDatabase::addApplicationFallbackFontFamily( + QChar::Script_Devanagari, families.constFirst()); + installFontMessageFilter(); + return true; +} + +extern "C" bool qbz_register_japanese_fallback() +{ + // LINE Seed JP is already bundled as a selectable UI/lyrics face. Make + // it Qt's deterministic fallback for the three scripts used by Japanese + // text as well. Without this, a Latin-only choice such as Source Sans 3 + // delegates to the host's first fontconfig match; a user-installed + // MS Gothic can therefore receive synthetic Medium/Bold and render much + // heavier than the surrounding UI. + const auto id = QFontDatabase::addApplicationFont(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/fonts/" + "LINESeedJP-Regular.ttf")); + if (id < 0) + return false; + + const auto families = QFontDatabase::applicationFontFamilies(id); + if (families.isEmpty()) + return false; + const auto &family = families.constFirst(); + QFontDatabase::addApplicationFallbackFontFamily(QChar::Script_Han, family); + QFontDatabase::addApplicationFallbackFontFamily(QChar::Script_Hiragana, family); + QFontDatabase::addApplicationFallbackFontFamily(QChar::Script_Katakana, family); + return true; +} diff --git a/crates/qbz-qt/cxx/linebed_item.cpp b/crates/qbz-qt/cxx/linebed_item.cpp new file mode 100644 index 000000000..f442124eb --- /dev/null +++ b/crates/qbz-qt/cxx/linebed_item.cpp @@ -0,0 +1,286 @@ +// Line Bed (immersive shader scene, mode 5) — the QRhi renderer behind +// LineBedItem. Ports the Slint line-strip pipeline +// (crates/qbz/src/shader_underlay.rs:689-727) and its draw call +// (:995-998): 200 INSTANCED line strips of (255*6 + 1) = 1531 vertices +// each, alpha blending, the heights ring as an R32F texture sampled in the +// VERTEX stage. The vertex/instance ids the WGSL took from +// @builtin(vertex_index)/@builtin(instance_index) arrive here as two float +// vertex attributes (1531-vert template buffer + a 200-entry per-instance +// buffer) — the maximally portable spelling across GL/GLES/Vulkan/Metal/ +// D3D under QRhi. +// +// Resource cadence (the pulse law): `synchronize` snapshots the QML-side +// properties when the item updates (pulse tick only), `render` uploads the +// heights texture only when a NEW ring arrived, and the draw itself is +// ~306K line vertices at 30 Hz — trivial next to a fullscreen fragment +// scene. + +#include "linebed_item.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +// The lattice — pinned by FOUR files that must agree: here, linebed.vert +// (NUM_BANDS/SUBDIV), src/linebed_qt.rs (LINEBED_LINES/LINEBED_BANDS) and +// the Slint reference (shader_underlay.rs:47-54). +constexpr int LINEBED_LINES = 200; +constexpr int LINEBED_BANDS = 256; +constexpr int LINEBED_SUBDIV = 6; +constexpr int VERTS_PER_LINE = (LINEBED_BANDS - 1) * LINEBED_SUBDIV + 1; // 1531 +constexpr qsizetype HEIGHTS_BYTES = LINEBED_LINES * LINEBED_BANDS * qsizetype(sizeof(float)); + +// std140: vec2 + vec4 + vec4. The GLSL block (linebed.vert/.frag, binding 0): +// layout(std140, binding = 0) uniform buf { +// vec2 resolution; // 0 +// vec4 primary; // 16 +// vec4 accent; // 32 +// }; +// The WGSL reference reads the full 144-byte scene block but uses only +// resolution (VS) and primary/accent (FS) — line_bed.wgsl is the one scene +// with no time/level/energy input, so the block is trimmed to what the +// shader reads. Same field VALUES at the same meaning. +struct UniformData +{ + float res[2]; + float pad[2]; + float primary[4]; + float accent[4]; +}; + +QShader loadShader(const QString &qrcPath) +{ + QFile f(qrcPath); + if (f.open(QIODevice::ReadOnly)) { + const QShader s = QShader::fromSerialized(f.readAll()); + if (!s.isValid()) + qWarning("[linebed] shader %s failed to deserialize", qPrintable(qrcPath)); + return s; + } + qWarning("[linebed] shader %s not found in the qrc", qPrintable(qrcPath)); + return QShader(); +} + +class LineBedRenderer : public QQuickRhiItemRenderer +{ +public: + void initialize(QRhiCommandBuffer *cb) override + { + QRhi *r = cb->rhi(); + if (m_vbuf) // already built (initialize can re-run on rhi changes; + return; // the rebuild path below drops everything first) + m_vbuf.reset(r->newBuffer(QRhiBuffer::Immutable, + QRhiBuffer::VertexBuffer, + VERTS_PER_LINE * quint32(sizeof(float)))); + m_ibuf.reset(r->newBuffer(QRhiBuffer::Immutable, + QRhiBuffer::VertexBuffer, + LINEBED_LINES * quint32(sizeof(float)))); + m_ubuf.reset(r->newBuffer(QRhiBuffer::Dynamic, + QRhiBuffer::UniformBuffer, + quint32(sizeof(UniformData)))); + m_tex.reset(r->newTexture(QRhiTexture::R32F, QSize(LINEBED_BANDS, LINEBED_LINES))); + m_sampler.reset(r->newSampler(QRhiSampler::Nearest, + QRhiSampler::Nearest, + QRhiSampler::None, + QRhiSampler::ClampToEdge, + QRhiSampler::ClampToEdge)); + if (!m_vbuf->create() || !m_ibuf->create() || !m_ubuf->create() + || !m_tex->create() || !m_sampler->create()) { + qWarning("[linebed] resource creation failed — the scene stays dark"); + m_vbuf.reset(); + return; + } + m_srb.reset(r->newShaderResourceBindings()); + m_srb->setBindings({ + QRhiShaderResourceBinding::uniformBuffer( + 0, + QRhiShaderResourceBinding::VertexStage + | QRhiShaderResourceBinding::FragmentStage, + m_ubuf.get()), + // Binding 4, like the reference's @binding(4) heights_tex. + QRhiShaderResourceBinding::sampledTexture( + 4, QRhiShaderResourceBinding::VertexStage, m_tex.get(), m_sampler.get()), + }); + if (!m_srb->create()) { + qWarning("[linebed] shader resource bindings failed — the scene stays dark"); + m_vbuf.reset(); + return; + } + + QRhiResourceUpdateBatch *u = r->nextResourceUpdateBatch(); + // The vertex templates: vid = subdivided point index along the line + // (0..1530), line = the depth row per instance (0..199). + std::vector vid(VERTS_PER_LINE); + for (int i = 0; i < VERTS_PER_LINE; ++i) + vid[i] = float(i); + u->uploadStaticBuffer(m_vbuf.get(), 0, + quint32(vid.size() * sizeof(float)), vid.data()); + std::vector lines(LINEBED_LINES); + for (int i = 0; i < LINEBED_LINES; ++i) + lines[i] = float(i); + u->uploadStaticBuffer(m_ibuf.get(), 0, + quint32(lines.size() * sizeof(float)), lines.data()); + // Flat bed until the first ring publish (the Rust ring starts + // zeroed too — linebed_qt.rs LineBedState::default). + const QByteArray zeros(int(HEIGHTS_BYTES), 0); + QRhiTextureSubresourceUploadDescription sub(zeros); + sub.setDataStride(LINEBED_BANDS * quint32(sizeof(float))); + u->uploadTexture(m_tex.get(), + QRhiTextureUploadDescription(QRhiTextureUploadEntry(0, 0, sub))); + cb->resourceUpdate(u); + } + + void synchronize(QQuickRhiItem *item) override + { + // GUI thread: snapshot the item's properties. Runs on item update() + // (the pulse tick) and on geometry/rhi changes. + auto *lb = static_cast(item); + const QByteArray h = lb->heights(); + if (h.size() == HEIGHTS_BYTES && h != m_heights) { + m_heights = h; + m_heightsDirty = true; + } + const QColor p = lb->primary(); + const QColor a = lb->accent(); + m_primary = QVector4D(float(p.redF()), float(p.greenF()), float(p.blueF()), 1.0f); + m_accent = QVector4D(float(a.redF()), float(a.greenF()), float(a.blueF()), 1.0f); + } + + void render(QRhiCommandBuffer *cb) override + { + if (!m_vbuf) + return; // resource creation failed in initialize + QRhiRenderTarget *rt = renderTarget(); + if (!m_pipeline || rt->renderPassDescriptor() != m_passDescriptor) { + m_passDescriptor = rt->renderPassDescriptor(); + buildPipeline(); + if (!m_pipeline) + return; + } + + QRhiResourceUpdateBatch *u = rhi()->nextResourceUpdateBatch(); + if (m_heightsDirty) { + m_heightsDirty = false; + QRhiTextureSubresourceUploadDescription sub(m_heights); + sub.setDataStride(LINEBED_BANDS * quint32(sizeof(float))); + u->uploadTexture(m_tex.get(), + QRhiTextureUploadDescription(QRhiTextureUploadEntry(0, 0, sub))); + } + + const QSize ps = rt->pixelSize(); + UniformData ud = {}; + ud.res[0] = float(ps.width()); + ud.res[1] = float(ps.height()); + std::memcpy(ud.primary, &m_primary, sizeof(ud.primary)); + std::memcpy(ud.accent, &m_accent, sizeof(ud.accent)); + u->updateDynamicBuffer(m_ubuf.get(), 0, quint32(sizeof(ud)), &ud); + + // Opaque black clear, like the reference's LoadOp::Clear(BLACK): + // the scene OWNS the background while active (the atmosphere and + // panels gate themselves off), and the item composites opaquely + // (alphaBlending stays false). The FS alpha rides the explicit + // SrcAlpha blend of the pipeline, exactly wgpu ALPHA_BLENDING. + cb->beginPass(rt, QColor(0, 0, 0, 255), QRhiDepthStencilClearValue(1.0f, 0), u); + cb->setGraphicsPipeline(m_pipeline.get()); + cb->setViewport(QRhiViewport(0, 0, float(ps.width()), float(ps.height()))); + const QRhiCommandBuffer::VertexInput vbufs[] = { + QRhiCommandBuffer::VertexInput(m_vbuf.get(), 0), + QRhiCommandBuffer::VertexInput(m_ibuf.get(), 0), + }; + cb->setShaderResources(m_srb.get()); + cb->setVertexInput(0, 2, vbufs); + cb->draw(VERTS_PER_LINE, LINEBED_LINES); // 200 instanced strips of 1531 + cb->endPass(); + } + +private: + void buildPipeline() + { + if (!m_pipeline) + m_pipeline.reset(rhi()->newGraphicsPipeline()); + m_pipeline->setTopology(QRhiGraphicsPipeline::LineStrip); + m_pipeline->setCullMode(QRhiGraphicsPipeline::None); + m_pipeline->setDepthTest(false); + m_pipeline->setDepthWrite(false); + // wgpu BlendState::ALPHA_BLENDING verbatim. + QRhiGraphicsPipeline::TargetBlend blend; + blend.enable = true; + blend.srcColor = QRhiGraphicsPipeline::SrcAlpha; + blend.dstColor = QRhiGraphicsPipeline::OneMinusSrcAlpha; + blend.srcAlpha = QRhiGraphicsPipeline::One; + blend.dstAlpha = QRhiGraphicsPipeline::OneMinusSrcAlpha; + m_pipeline->setTargetBlends({ blend }); + m_pipeline->setShaderStages({ + { QRhiShaderStage::Vertex, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/linebed.vert.qsb")) }, + { QRhiShaderStage::Fragment, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/linebed.frag.qsb")) }, + }); + QRhiVertexInputLayout inputLayout; + inputLayout.setBindings({ + QRhiVertexInputBinding(quint32(sizeof(float))), + QRhiVertexInputBinding(quint32(sizeof(float)), + QRhiVertexInputBinding::PerInstance), + }); + inputLayout.setAttributes({ + { 0, 0, QRhiVertexInputAttribute::Float, 0 }, // vid + { 1, 1, QRhiVertexInputAttribute::Float, 0 }, // line (per instance) + }); + m_pipeline->setVertexInputLayout(inputLayout); + m_pipeline->setShaderResourceBindings(m_srb.get()); + m_pipeline->setRenderPassDescriptor(m_passDescriptor); + if (!m_pipeline->create()) { + qWarning("[linebed] pipeline creation failed — the scene stays dark"); + m_pipeline.reset(); + } + } + + std::unique_ptr m_vbuf; + std::unique_ptr m_ibuf; + std::unique_ptr m_ubuf; + std::unique_ptr m_tex; + std::unique_ptr m_sampler; + std::unique_ptr m_srb; + std::unique_ptr m_pipeline; + QRhiRenderPassDescriptor *m_passDescriptor = nullptr; + + QByteArray m_heights; + bool m_heightsDirty = false; + // The Slint default palette (see the header) until synchronize() runs. + QVector4D m_primary{ 0.0f, 0.863f, 0.784f, 1.0f }; + QVector4D m_accent{ 0.247f, 0.851f, 0.784f, 1.0f }; +}; + +} // namespace + +QQuickRhiItemRenderer *LineBedItem::createRenderer() +{ + return new LineBedRenderer; +} + +// QML type registration. The QML module is STATIC (linked into the +// binary), so there is no plugin load to hang this on: the startup function +// runs at QGuiApplication construction, BEFORE the engine loads the QML +// that names LineBedItem (ShaderSceneLayer.qml is compiled during the +// initial load; Component.onCompleted — where the singleton boots run — is +// already too late). +static bool g_linebedRegistered = false; + +extern "C" void qbz_linebed_register_qml_type() +{ + if (g_linebedRegistered) + return; + g_linebedRegistered = true; + qmlRegisterType("com.blitzfc.qbz", 1, 0, "LineBedItem"); +} + +Q_COREAPP_STARTUP_FUNCTION(qbz_linebed_register_qml_type) diff --git a/crates/qbz-qt/cxx/linebed_item.h b/crates/qbz-qt/cxx/linebed_item.h new file mode 100644 index 000000000..16dd2f351 --- /dev/null +++ b/crates/qbz-qt/cxx/linebed_item.h @@ -0,0 +1,62 @@ +// Line Bed (immersive shader scene, mode 5) — the GPU half. Block A4 of the +// 2026-08-15 immersive-completion contract (spec 01-shader-scenes-port.md +// §2.5). The project's FIRST QQuickRhiItem: the scene is not expressible as +// a ShaderEffect (fragment-only fullscreen quad) — it needs a LineStrip +// vertex stage with instancing and a vertex-sampled heights texture, the +// exact shape of the Slint linestrip pipeline +// (crates/qbz/src/shader_underlay.rs:689-727). +// +// The CPU half (the 512→256 reshape + the 200-deep ring) is Rust +// (src/linebed_qt.rs, a LineBedState port); the ring arrives per pulse tick +// through the `heights` property as raw f32 bytes (256x200, row 0 = +// newest). The palette rides the shell's ambient colors like every other +// scene (QbzShell.ambientPrimary/Accent, bound in LineBedScene.qml). +// +// Compiled by crates/qbz-qt/build.rs (moc via qt-build-utils + cc — the +// first hand-written C++ in this crate; cxx-qt's generated glue aside). +// Registered as QML type `LineBedItem` in `com.blitzfc.qbz` 1.0 at +// QGuiApplication construction (Q_COREAPP_STARTUP_FUNCTION in the .cpp), +// with `qbz_linebed_register_qml_type` as the Rust-reachable link anchor. + +#ifndef QBZ_LINEBED_ITEM_H +#define QBZ_LINEBED_ITEM_H + +#include +#include +#include + +class LineBedItem : public QQuickRhiItem +{ + Q_OBJECT + // No NOTIFY anywhere: the QML wrapper writes `heights` only on the + // pulse edge and calls update() right after (the pulse law — nothing + // here may schedule its own repaint), and nobody binds these. + Q_PROPERTY(QByteArray heights READ heights WRITE setHeights) + Q_PROPERTY(QColor primary READ primary WRITE setPrimary) + Q_PROPERTY(QColor accent READ accent WRITE setAccent) + +public: + explicit LineBedItem(QQuickItem *parent = nullptr) + : QQuickRhiItem(parent) + { + } + + QByteArray heights() const { return m_heights; } + void setHeights(const QByteArray &heights) { m_heights = heights; } + QColor primary() const { return m_primary; } + void setPrimary(const QColor &c) { m_primary = c; } + QColor accent() const { return m_accent; } + void setAccent(const QColor &c) { m_accent = c; } + + QQuickRhiItemRenderer *createRenderer() override; + +private: + QByteArray m_heights; + // The Slint defaults (shader_underlay.rs:121-129 Palette::DEFAULT + // #00dcc8 / #3fd9c8 — `secondary` is unused by this scene), so the bed + // opened before the first album-art palette resolves still looks right. + QColor m_primary{ 0, 220, 200 }; + QColor m_accent{ 63, 217, 200 }; +}; + +#endif // QBZ_LINEBED_ITEM_H diff --git a/crates/qbz-qt/cxx/local_albums_model.cpp b/crates/qbz-qt/cxx/local_albums_model.cpp new file mode 100644 index 000000000..72b327cbd --- /dev/null +++ b/crates/qbz-qt/cxx/local_albums_model.cpp @@ -0,0 +1,324 @@ +#include "local_albums_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QPointer g_model; +QPointer g_artist_model; + +void registerLocalAlbumsModel() +{ + auto *application = QCoreApplication::instance(); + auto *model = new LocalAlbumsModel(application); + g_model = model; + qmlRegisterSingletonInstance( + "com.blitzfc.qbz", 1, 0, "QbzLocalAlbums", model); + auto *artistModel = new LocalAlbumsModel(application); + g_artist_model = artistModel; + qmlRegisterSingletonInstance( + "com.blitzfc.qbz", 1, 0, "QbzLocalArtistAlbums", artistModel); +} + +Q_COREAPP_STARTUP_FUNCTION(registerLocalAlbumsModel) +} + +LocalAlbumsModel::LocalAlbumsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int LocalAlbumsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return int(qMin(m_totalCount, std::numeric_limits::max())); +} + +QVariant LocalAlbumsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || role != ModelDataRole) + return {}; + return modelData(index.row()); +} + +QHash LocalAlbumsModel::roleNames() const +{ + return {{ ModelDataRole, QByteArrayLiteral("modelData") }}; +} + +QVariantMap LocalAlbumsModel::rowAt(qint64 row) const +{ + if (row < 0 || row >= m_totalCount) + return {}; + return modelData(row); +} + +QVariantMap LocalAlbumsModel::modelData(qint64 row) const +{ + const int page = int(row / PageEntries); + const int offset = int(row % PageEntries); + const auto found = m_pages.constFind(page); + if (found == m_pages.cend() || offset >= found->size()) { + requestPage(page, false); + return { + { QStringLiteral("t"), 1 }, + { QStringLiteral("loading"), true }, + { QStringLiteral("base"), 0 }, + { QStringLiteral("items"), QVariantList() }, + }; + } + touch(page); + QVariantMap values = found->at(offset).values; + values.insert(QStringLiteral("loading"), false); + QVariantList items = values.value(QStringLiteral("items")).toList(); + for (QVariant &value : items) { + QVariantMap album = value.toMap(); + album.insert( + QStringLiteral("selected"), + selected(album.value(QStringLiteral("nativeIndex")).toLongLong())); + value = album; + } + values.insert(QStringLiteral("items"), items); + return values; +} + +void LocalAlbumsModel::requestPage(int page, bool prefetch) const +{ + if (page < 0) + return; + const QMetaMethod missSignal = QMetaMethod::fromSignal(&LocalAlbumsModel::pageMiss); + if (!isSignalConnected(missSignal)) + return; + if (m_pending.contains(page)) { + if (!prefetch) + m_pending.insert(page, false); + return; + } + m_pending.insert(page, prefetch); + const int generation = m_generation; + auto *self = const_cast(this); + QMetaObject::invokeMethod( + self, + [self, page, generation]() { + if (generation != self->m_generation) + return; + const QMetaMethod missSignal = + QMetaMethod::fromSignal(&LocalAlbumsModel::pageMiss); + if (self->isSignalConnected(missSignal)) { + emit self->pageMiss(page, generation); + } else { + self->m_pending.remove(page); + } + }, + Qt::QueuedConnection); +} + +void LocalAlbumsModel::touch(int page) const +{ + m_lru.removeAll(page); + m_lru.append(page); +} + +bool LocalAlbumsModel::selected(qint64 row) const +{ + bool inRange = false; + for (const auto &range : m_selectionRanges) { + if (row < range.first) + break; + if (row <= range.second) { + inRange = true; + break; + } + } + return m_selectAll ? !inRange : inRange; +} + +void LocalAlbumsModel::resetQuery(int generation, qint64 totalCount, qint64 albumTotal) +{ + beginResetModel(); + m_generation = generation; + m_totalCount = qMax(0, totalCount); + m_albumTotal = qMax(0, albumTotal); + m_pages.clear(); + m_lru.clear(); + m_pending.clear(); + m_residentAlbums = 0; + m_evictions = 0; + m_selectAll = false; + m_selectionRanges.clear(); + m_selectedCount = 0; + endResetModel(); + emit generationChanged(); + emit totalCountChanged(); + emit albumTotalChanged(); + emit residentAlbumsChanged(); + emit evictionsChanged(); + emit selectedCountChanged(); +} + +bool LocalAlbumsModel::applyPage(int generation, int page, const QByteArray &json) +{ + if (generation != m_generation || page < 0) + return false; + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(json, &error); + if (error.error != QJsonParseError::NoError || !document.isArray()) + return false; + + Page entries; + const QJsonArray array = document.array(); + entries.reserve(array.size()); + for (const QJsonValue &value : array) { + if (!value.isObject()) + return false; + Entry entry; + entry.values = value.toObject().toVariantMap(); + const QVariantList items = entry.values.value(QStringLiteral("items")).toList(); + entry.albumCount = items.size(); + for (const QVariant &item : items) { + const QString key = item.toMap().value(QStringLiteral("artKey")).toString(); + if (!key.isEmpty()) + entry.artKeys.append(key); + } + entries.append(std::move(entry)); + } + + const auto previous = m_pages.constFind(page); + if (previous != m_pages.cend()) { + for (const Entry &entry : *previous) + m_residentAlbums -= entry.albumCount; + } + m_pages.insert(page, std::move(entries)); + for (const Entry &entry : m_pages.value(page)) + m_residentAlbums += entry.albumCount; + const bool wasPrefetch = m_pending.take(page); + touch(page); + evictIfNeeded(); + + const int first = page * PageEntries; + const int last = qMin(rowCount() - 1, first + PageEntries - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + emit residentAlbumsChanged(); + + const int next = page + 1; + if (!wasPrefetch && qint64(next) * PageEntries < m_totalCount && !m_pages.contains(next)) + requestPage(next, true); + return true; +} + +void LocalAlbumsModel::evictIfNeeded() +{ + while (m_residentAlbums > MaxResidentAlbums && m_pages.size() > 1 && !m_lru.isEmpty()) { + const int page = m_lru.takeFirst(); + const auto found = m_pages.find(page); + if (found == m_pages.end()) + continue; + for (const Entry &entry : *found) + m_residentAlbums -= entry.albumCount; + m_pages.erase(found); + ++m_evictions; + const int first = page * PageEntries; + const int last = qMin(rowCount() - 1, first + PageEntries - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + } + emit evictionsChanged(); +} + +void LocalAlbumsModel::setArtwork(const QString &artKey, const QString &path) +{ + if (artKey.isEmpty() || path.isEmpty()) + return; + for (auto page = m_pages.begin(); page != m_pages.end(); ++page) { + for (int offset = 0; offset < page->size(); ++offset) { + Entry &entry = (*page)[offset]; + if (!entry.artKeys.contains(artKey)) + continue; + QVariantList items = entry.values.value(QStringLiteral("items")).toList(); + for (QVariant &value : items) { + QVariantMap album = value.toMap(); + if (album.value(QStringLiteral("artKey")).toString() == artKey) + album.insert(QStringLiteral("artPath"), path); + value = album; + } + entry.values.insert(QStringLiteral("items"), items); + const int row = page.key() * PageEntries + offset; + emit dataChanged(index(row, 0), index(row, 0), { ModelDataRole }); + } + } +} + +void LocalAlbumsModel::setSelection( + int generation, const QByteArray &json, qint64 selectedCount) +{ + if (generation != m_generation) + return; + const QJsonDocument document = QJsonDocument::fromJson(json); + if (!document.isObject()) + return; + const QJsonObject object = document.object(); + QList> ranges; + for (const QJsonValue &value : object.value(QStringLiteral("ranges")).toArray()) { + const QJsonArray pair = value.toArray(); + if (pair.size() != 2) + continue; + ranges.append({ qMax(0, pair.at(0).toInteger()), + qMax(0, pair.at(1).toInteger()) }); + } + m_selectAll = object.value(QStringLiteral("all")).toBool(); + m_selectionRanges = std::move(ranges); + m_selectedCount = qMax(0, selectedCount); + emit selectedCountChanged(); + if (rowCount() > 0) + emit dataChanged(index(0, 0), index(rowCount() - 1, 0), { ModelDataRole }); +} + +extern "C" void qbz_local_albums_register_qml_type() +{ + // Link anchor. Registration is performed by Q_COREAPP_STARTUP_FUNCTION. +} + +extern "C" void qbz_local_albums_reset( + int generation, qint64 totalCount, qint64 albumTotal) +{ + if (g_model) + g_model->resetQuery(generation, totalCount, albumTotal); +} + +extern "C" bool qbz_local_albums_apply_page(int generation, int page, const char *json) +{ + return g_model && g_model->applyPage( + generation, page, QByteArray(json ? json : "[]")); +} + +extern "C" void qbz_local_albums_set_selection( + int generation, const char *json, qint64 selectedCount) +{ + if (g_model) + g_model->setSelection( + generation, QByteArray(json ? json : "{}"), selectedCount); +} + +extern "C" void qbz_local_artist_albums_reset( + int generation, qint64 totalCount, qint64 albumTotal) +{ + if (g_artist_model) + g_artist_model->resetQuery(generation, totalCount, albumTotal); +} + +extern "C" bool qbz_local_artist_albums_apply_page( + int generation, int page, const char *json) +{ + return g_artist_model && g_artist_model->applyPage( + generation, page, QByteArray(json ? json : "[]")); +} diff --git a/crates/qbz-qt/cxx/local_albums_model.h b/crates/qbz-qt/cxx/local_albums_model.h new file mode 100644 index 000000000..89219d429 --- /dev/null +++ b/crates/qbz-qt/cxx/local_albums_model.h @@ -0,0 +1,90 @@ +#ifndef QBZ_LOCAL_ALBUMS_MODEL_H +#define QBZ_LOCAL_ALBUMS_MODEL_H + +#include +#include +#include +#include + +class LocalAlbumsModel final : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int generation READ generation NOTIFY generationChanged) + Q_PROPERTY(qint64 totalCount READ totalCount NOTIFY totalCountChanged) + Q_PROPERTY(qint64 albumTotal READ albumTotal NOTIFY albumTotalChanged) + Q_PROPERTY(int residentAlbums READ residentAlbums NOTIFY residentAlbumsChanged) + Q_PROPERTY(qint64 evictions READ evictions NOTIFY evictionsChanged) + Q_PROPERTY(qint64 selectedCount READ selectedCount NOTIFY selectedCountChanged) + +public: + enum Role { ModelDataRole = Qt::UserRole + 1 }; + + explicit LocalAlbumsModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + int generation() const { return m_generation; } + qint64 totalCount() const { return m_totalCount; } + qint64 albumTotal() const { return m_albumTotal; } + int residentAlbums() const { return m_residentAlbums; } + qint64 evictions() const { return m_evictions; } + qint64 selectedCount() const { return m_selectedCount; } + + Q_INVOKABLE QVariantMap rowAt(qint64 row) const; + Q_INVOKABLE void setArtwork(const QString &artKey, const QString &path); + + void resetQuery(int generation, qint64 totalCount, qint64 albumTotal); + bool applyPage(int generation, int page, const QByteArray &json); + void setSelection(int generation, const QByteArray &json, qint64 selectedCount); + +signals: + void generationChanged(); + void totalCountChanged(); + void albumTotalChanged(); + void residentAlbumsChanged(); + void evictionsChanged(); + void selectedCountChanged(); + void pageMiss(int page, int generation); + +private: + struct Entry { + QVariantMap values; + QStringList artKeys; + int albumCount = 0; + }; + using Page = QList; + + static constexpr int PageEntries = 100; + static constexpr int MaxResidentAlbums = 2000; + + QVariantMap modelData(qint64 row) const; + void requestPage(int page, bool prefetch = false) const; + void touch(int page) const; + bool selected(qint64 row) const; + void evictIfNeeded(); + + int m_generation = 0; + qint64 m_totalCount = 0; + qint64 m_albumTotal = 0; + QHash m_pages; + mutable QList m_lru; + mutable QHash m_pending; + int m_residentAlbums = 0; + qint64 m_evictions = 0; + bool m_selectAll = false; + QList> m_selectionRanges; + qint64 m_selectedCount = 0; +}; + +extern "C" { +void qbz_local_albums_register_qml_type(); +void qbz_local_albums_reset(int generation, qint64 totalCount, qint64 albumTotal); +bool qbz_local_albums_apply_page(int generation, int page, const char *json); +void qbz_local_albums_set_selection(int generation, const char *json, qint64 selectedCount); +void qbz_local_artist_albums_reset(int generation, qint64 totalCount, qint64 albumTotal); +bool qbz_local_artist_albums_apply_page(int generation, int page, const char *json); +} + +#endif // QBZ_LOCAL_ALBUMS_MODEL_H diff --git a/crates/qbz-qt/cxx/local_artists_model.cpp b/crates/qbz-qt/cxx/local_artists_model.cpp new file mode 100644 index 000000000..b287419e8 --- /dev/null +++ b/crates/qbz-qt/cxx/local_artists_model.cpp @@ -0,0 +1,235 @@ +#include "local_artists_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QPointer g_model; + +void registerLocalArtistsModel() +{ + auto *model = new LocalArtistsModel(QCoreApplication::instance()); + g_model = model; + qmlRegisterSingletonInstance( + "com.blitzfc.qbz", 1, 0, "QbzLocalArtists", model); +} + +Q_COREAPP_STARTUP_FUNCTION(registerLocalArtistsModel) +} + +LocalArtistsModel::LocalArtistsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int LocalArtistsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return int(qMin(m_totalCount, std::numeric_limits::max())); +} + +QVariant LocalArtistsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || role != ModelDataRole) + return {}; + return modelData(index.row()); +} + +QHash LocalArtistsModel::roleNames() const +{ + return {{ ModelDataRole, QByteArrayLiteral("modelData") }}; +} + +QVariantMap LocalArtistsModel::rowAt(qint64 row) const +{ + if (row < 0 || row >= m_totalCount) + return {}; + return modelData(row); +} + +QVariantMap LocalArtistsModel::modelData(qint64 row) const +{ + const int page = int(row / PageEntries); + const int offset = int(row % PageEntries); + const auto found = m_pages.constFind(page); + if (found == m_pages.cend() || offset >= found->size()) { + requestPage(page, false); + return { + { QStringLiteral("t"), 1 }, + { QStringLiteral("loading"), true }, + { QStringLiteral("base"), 0 }, + { QStringLiteral("item"), QVariantMap() }, + }; + } + touch(page); + QVariantMap values = found->at(offset).values; + values.insert(QStringLiteral("loading"), false); + return values; +} + +void LocalArtistsModel::requestPage(int page, bool prefetch) const +{ + if (page < 0) + return; + const QMetaMethod missSignal = QMetaMethod::fromSignal(&LocalArtistsModel::pageMiss); + if (!isSignalConnected(missSignal)) + return; + if (m_pending.contains(page)) { + if (!prefetch) + m_pending.insert(page, false); + return; + } + m_pending.insert(page, prefetch); + const int generation = m_generation; + auto *self = const_cast(this); + QMetaObject::invokeMethod( + self, + [self, page, generation]() { + if (generation != self->m_generation) + return; + if (self->isSignalConnected( + QMetaMethod::fromSignal(&LocalArtistsModel::pageMiss))) { + emit self->pageMiss(page, generation); + } else { + self->m_pending.remove(page); + } + }, + Qt::QueuedConnection); +} + +void LocalArtistsModel::touch(int page) const +{ + m_lru.removeAll(page); + m_lru.append(page); +} + +void LocalArtistsModel::resetQuery(int generation, qint64 totalCount, qint64 artistTotal) +{ + beginResetModel(); + m_generation = generation; + m_totalCount = qMax(0, totalCount); + m_artistTotal = qMax(0, artistTotal); + m_pages.clear(); + m_lru.clear(); + m_pending.clear(); + m_residentArtists = 0; + m_evictions = 0; + endResetModel(); + emit generationChanged(); + emit totalCountChanged(); + emit artistTotalChanged(); + emit residentArtistsChanged(); + emit evictionsChanged(); +} + +bool LocalArtistsModel::applyPage(int generation, int page, const QByteArray &json) +{ + if (generation != m_generation || page < 0) + return false; + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(json, &error); + if (error.error != QJsonParseError::NoError || !document.isArray()) + return false; + + Page entries; + const QJsonArray array = document.array(); + entries.reserve(array.size()); + for (const QJsonValue &value : array) { + if (!value.isObject()) + return false; + Entry entry; + entry.values = value.toObject().toVariantMap(); + entry.isArtist = entry.values.value(QStringLiteral("t")).toInt() == 1; + entry.artKey = entry.values.value(QStringLiteral("item")) + .toMap() + .value(QStringLiteral("artKey")) + .toString(); + entries.append(std::move(entry)); + } + + const auto previous = m_pages.constFind(page); + if (previous != m_pages.cend()) { + for (const Entry &entry : *previous) + m_residentArtists -= int(entry.isArtist); + } + m_pages.insert(page, std::move(entries)); + for (const Entry &entry : m_pages.value(page)) + m_residentArtists += int(entry.isArtist); + const bool wasPrefetch = m_pending.take(page); + touch(page); + evictIfNeeded(); + + const int first = page * PageEntries; + const int last = qMin(rowCount() - 1, first + PageEntries - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + emit residentArtistsChanged(); + const int next = page + 1; + if (!wasPrefetch && qint64(next) * PageEntries < m_totalCount && !m_pages.contains(next)) + requestPage(next, true); + return true; +} + +void LocalArtistsModel::evictIfNeeded() +{ + while (m_residentArtists > MaxResidentArtists && m_pages.size() > 1 && !m_lru.isEmpty()) { + const int page = m_lru.takeFirst(); + const auto found = m_pages.find(page); + if (found == m_pages.end()) + continue; + for (const Entry &entry : *found) + m_residentArtists -= int(entry.isArtist); + m_pages.erase(found); + ++m_evictions; + const int first = page * PageEntries; + const int last = qMin(rowCount() - 1, first + PageEntries - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + } + emit evictionsChanged(); +} + +void LocalArtistsModel::setArtwork(const QString &artKey, const QString &path) +{ + if (artKey.isEmpty() || path.isEmpty()) + return; + for (auto page = m_pages.begin(); page != m_pages.end(); ++page) { + for (int offset = 0; offset < page->size(); ++offset) { + Entry &entry = (*page)[offset]; + if (entry.artKey != artKey) + continue; + QVariantMap item = entry.values.value(QStringLiteral("item")).toMap(); + item.insert(QStringLiteral("artPath"), path); + entry.values.insert(QStringLiteral("item"), item); + const int row = page.key() * PageEntries + offset; + emit dataChanged(index(row, 0), index(row, 0), { ModelDataRole }); + } + } +} + +extern "C" void qbz_local_artists_register_qml_type() +{ + // Link anchor. Registration is performed by Q_COREAPP_STARTUP_FUNCTION. +} + +extern "C" void qbz_local_artists_reset( + int generation, qint64 totalCount, qint64 artistTotal) +{ + if (g_model) + g_model->resetQuery(generation, totalCount, artistTotal); +} + +extern "C" bool qbz_local_artists_apply_page(int generation, int page, const char *json) +{ + return g_model && g_model->applyPage( + generation, page, QByteArray(json ? json : "[]")); +} diff --git a/crates/qbz-qt/cxx/local_artists_model.h b/crates/qbz-qt/cxx/local_artists_model.h new file mode 100644 index 000000000..e99597005 --- /dev/null +++ b/crates/qbz-qt/cxx/local_artists_model.h @@ -0,0 +1,79 @@ +#ifndef QBZ_LOCAL_ARTISTS_MODEL_H +#define QBZ_LOCAL_ARTISTS_MODEL_H + +#include +#include +#include +#include + +class LocalArtistsModel final : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int generation READ generation NOTIFY generationChanged) + Q_PROPERTY(qint64 totalCount READ totalCount NOTIFY totalCountChanged) + Q_PROPERTY(qint64 artistTotal READ artistTotal NOTIFY artistTotalChanged) + Q_PROPERTY(int residentArtists READ residentArtists NOTIFY residentArtistsChanged) + Q_PROPERTY(qint64 evictions READ evictions NOTIFY evictionsChanged) + +public: + enum Role { ModelDataRole = Qt::UserRole + 1 }; + + explicit LocalArtistsModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + int generation() const { return m_generation; } + qint64 totalCount() const { return m_totalCount; } + qint64 artistTotal() const { return m_artistTotal; } + int residentArtists() const { return m_residentArtists; } + qint64 evictions() const { return m_evictions; } + + Q_INVOKABLE QVariantMap rowAt(qint64 row) const; + Q_INVOKABLE void setArtwork(const QString &artKey, const QString &path); + + void resetQuery(int generation, qint64 totalCount, qint64 artistTotal); + bool applyPage(int generation, int page, const QByteArray &json); + +signals: + void generationChanged(); + void totalCountChanged(); + void artistTotalChanged(); + void residentArtistsChanged(); + void evictionsChanged(); + void pageMiss(int page, int generation); + +private: + struct Entry { + QVariantMap values; + QString artKey; + bool isArtist = false; + }; + using Page = QList; + + static constexpr int PageEntries = 100; + static constexpr int MaxResidentArtists = 2000; + + QVariantMap modelData(qint64 row) const; + void requestPage(int page, bool prefetch = false) const; + void touch(int page) const; + void evictIfNeeded(); + + int m_generation = 0; + qint64 m_totalCount = 0; + qint64 m_artistTotal = 0; + QHash m_pages; + mutable QList m_lru; + mutable QHash m_pending; + int m_residentArtists = 0; + qint64 m_evictions = 0; +}; + +extern "C" { +void qbz_local_artists_register_qml_type(); +void qbz_local_artists_reset(int generation, qint64 totalCount, qint64 artistTotal); +bool qbz_local_artists_apply_page(int generation, int page, const char *json); +} + +#endif // QBZ_LOCAL_ARTISTS_MODEL_H diff --git a/crates/qbz-qt/cxx/local_tracks_model.cpp b/crates/qbz-qt/cxx/local_tracks_model.cpp new file mode 100644 index 000000000..119e313da --- /dev/null +++ b/crates/qbz-qt/cxx/local_tracks_model.cpp @@ -0,0 +1,296 @@ +#include "local_tracks_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QPointer g_model; + +void registerLocalTracksModel() +{ + auto *application = QCoreApplication::instance(); + auto *model = new LocalTracksModel(application); + g_model = model; + qmlRegisterSingletonInstance( + "com.blitzfc.qbz", 1, 0, "QbzLocalTracks", model); +} + +Q_COREAPP_STARTUP_FUNCTION(registerLocalTracksModel) +} + +LocalTracksModel::LocalTracksModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int LocalTracksModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return int(qMin(m_totalCount, std::numeric_limits::max())); +} + +QVariant LocalTracksModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || role != ModelDataRole) + return {}; + return modelData(index.row()); +} + +QHash LocalTracksModel::roleNames() const +{ + return {{ ModelDataRole, QByteArrayLiteral("modelData") }}; +} + +QVariantMap LocalTracksModel::rowAt(qint64 row) const +{ + if (row < 0 || row >= m_totalCount) + return {}; + return modelData(row); +} + +QVariantMap LocalTracksModel::modelData(qint64 row) const +{ + const int page = int(row / PageRows); + const int offset = int(row % PageRows); + const auto found = m_pages.constFind(page); + if (found == m_pages.cend() || offset >= found->size()) { + requestPage(page, false); + return { + { QStringLiteral("t"), 1 }, + { QStringLiteral("n"), row + 1 }, + { QStringLiteral("loading"), true }, + { QStringLiteral("groupStart"), false }, + { QStringLiteral("groupLabel"), QString() }, + { QStringLiteral("selected"), false }, + { QStringLiteral("row"), QVariantMap() }, + }; + } + touch(page); + const TrackRow &track = found->at(offset); + QVariantMap values = track.row; + values.insert(QStringLiteral("selected"), selected(row)); + return { + { QStringLiteral("t"), 1 }, + { QStringLiteral("n"), row + 1 }, + { QStringLiteral("loading"), false }, + { QStringLiteral("groupStart"), track.groupStart }, + { QStringLiteral("groupLabel"), track.groupLabel }, + { QStringLiteral("selected"), selected(row) }, + { QStringLiteral("row"), values }, + }; +} + +void LocalTracksModel::requestPage(int page, bool prefetch) const +{ + if (page < 0) + return; + const QMetaMethod missSignal = QMetaMethod::fromSignal(&LocalTracksModel::pageMiss); + // The Local Library view is lazy. A prefetch published before its + // Connections object exists would otherwise be lost while leaving this + // page permanently marked pending across the later mount. + if (!isSignalConnected(missSignal)) + return; + if (m_pending.contains(page)) { + if (!prefetch) + m_pending.insert(page, false); + return; + } + m_pending.insert(page, prefetch); + const int generation = m_generation; + auto *self = const_cast(this); + QMetaObject::invokeMethod( + self, + [self, page, generation]() { + if (generation != self->m_generation) + return; + const QMetaMethod missSignal = + QMetaMethod::fromSignal(&LocalTracksModel::pageMiss); + if (self->isSignalConnected(missSignal)) { + emit self->pageMiss(page, generation); + } else { + self->m_pending.remove(page); + } + }, + Qt::QueuedConnection); +} + +void LocalTracksModel::touch(int page) const +{ + m_lru.removeAll(page); + m_lru.append(page); +} + +bool LocalTracksModel::selected(qint64 row) const +{ + bool inRange = false; + for (const auto &range : m_selectionRanges) { + if (row < range.first) + break; + if (row <= range.second) { + inRange = true; + break; + } + } + return m_selectAll ? !inRange : inRange; +} + +void LocalTracksModel::resetQuery(int generation, qint64 totalCount, const QString &group) +{ + beginResetModel(); + m_generation = generation; + m_totalCount = qMax(0, totalCount); + m_group = group; + m_pages.clear(); + m_lru.clear(); + m_pending.clear(); + m_residentRows = 0; + m_evictions = 0; + m_selectAll = false; + m_selectionRanges.clear(); + m_selectedCount = 0; + endResetModel(); + emit generationChanged(); + emit totalCountChanged(); + emit residentRowsChanged(); + emit evictionsChanged(); + emit selectedCountChanged(); +} + +bool LocalTracksModel::applyPage(int generation, int page, const QByteArray &json) +{ + if (generation != m_generation || page < 0) + return false; + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(json, &error); + if (error.error != QJsonParseError::NoError || !document.isArray()) + return false; + + Page rows; + const QJsonArray array = document.array(); + rows.reserve(array.size()); + for (const QJsonValue &value : array) { + if (!value.isObject()) + return false; + const QJsonObject object = value.toObject(); + TrackRow track; + track.groupStart = object.value(QStringLiteral("groupStart")).toBool(); + track.groupLabel = object.value(QStringLiteral("groupLabel")).toString(); + track.row = object.value(QStringLiteral("row")).toObject().toVariantMap(); + track.artKey = track.row.value(QStringLiteral("artKey")).toString(); + rows.append(std::move(track)); + } + + const auto previous = m_pages.constFind(page); + if (previous != m_pages.cend()) + m_residentRows -= previous->size(); + m_pages.insert(page, std::move(rows)); + m_residentRows += m_pages.value(page).size(); + const bool wasPrefetch = m_pending.take(page); + touch(page); + evictIfNeeded(); + + const int first = page * PageRows; + const int last = qMin(rowCount() - 1, first + PageRows - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + emit residentRowsChanged(); + + const int next = page + 1; + if (!wasPrefetch && qint64(next) * PageRows < m_totalCount && !m_pages.contains(next)) + requestPage(next, true); + return true; +} + +void LocalTracksModel::evictIfNeeded() +{ + while (m_pages.size() > MaxResidentPages && !m_lru.isEmpty()) { + const int page = m_lru.takeFirst(); + const auto found = m_pages.find(page); + if (found == m_pages.end()) + continue; + m_residentRows -= found->size(); + m_pages.erase(found); + ++m_evictions; + const int first = page * PageRows; + const int last = qMin(rowCount() - 1, first + PageRows - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + } + emit evictionsChanged(); +} + +void LocalTracksModel::setArtwork(const QString &artKey, const QString &path) +{ + if (artKey.isEmpty() || path.isEmpty()) + return; + for (auto page = m_pages.begin(); page != m_pages.end(); ++page) { + for (int offset = 0; offset < page->size(); ++offset) { + TrackRow &track = (*page)[offset]; + if (track.artKey != artKey) + continue; + track.row.insert(QStringLiteral("artPath"), path); + const int row = page.key() * PageRows + offset; + emit dataChanged(index(row, 0), index(row, 0), { ModelDataRole }); + } + } +} + +void LocalTracksModel::setSelection( + int generation, const QByteArray &json, qint64 selectedCount) +{ + if (generation != m_generation) + return; + const QJsonDocument document = QJsonDocument::fromJson(json); + if (!document.isObject()) + return; + const QJsonObject object = document.object(); + QList> ranges; + for (const QJsonValue &value : object.value(QStringLiteral("ranges")).toArray()) { + const QJsonArray pair = value.toArray(); + if (pair.size() != 2) + continue; + ranges.append({ qMax(0, pair.at(0).toInteger()), + qMax(0, pair.at(1).toInteger()) }); + } + m_selectAll = object.value(QStringLiteral("all")).toBool(); + m_selectionRanges = std::move(ranges); + m_selectedCount = qMax(0, selectedCount); + emit selectedCountChanged(); + if (rowCount() > 0) + emit dataChanged(index(0, 0), index(rowCount() - 1, 0), { ModelDataRole }); +} + +extern "C" void qbz_local_tracks_register_qml_type() +{ + // Link anchor. Registration is performed by Q_COREAPP_STARTUP_FUNCTION. +} + +extern "C" void qbz_local_tracks_reset( + int generation, qint64 totalCount, const char *group) +{ + if (g_model) + g_model->resetQuery(generation, totalCount, QString::fromUtf8(group ? group : "")); +} + +extern "C" bool qbz_local_tracks_apply_page(int generation, int page, const char *json) +{ + return g_model && g_model->applyPage( + generation, page, QByteArray(json ? json : "[]")); +} + +extern "C" void qbz_local_tracks_set_selection( + int generation, const char *json, qint64 selectedCount) +{ + if (g_model) + g_model->setSelection( + generation, QByteArray(json ? json : "{}"), selectedCount); +} diff --git a/crates/qbz-qt/cxx/local_tracks_model.h b/crates/qbz-qt/cxx/local_tracks_model.h new file mode 100644 index 000000000..94fa2c6a2 --- /dev/null +++ b/crates/qbz-qt/cxx/local_tracks_model.h @@ -0,0 +1,86 @@ +#ifndef QBZ_LOCAL_TRACKS_MODEL_H +#define QBZ_LOCAL_TRACKS_MODEL_H + +#include +#include +#include +#include + +class LocalTracksModel final : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int generation READ generation NOTIFY generationChanged) + Q_PROPERTY(qint64 totalCount READ totalCount NOTIFY totalCountChanged) + Q_PROPERTY(int residentRows READ residentRows NOTIFY residentRowsChanged) + Q_PROPERTY(qint64 evictions READ evictions NOTIFY evictionsChanged) + Q_PROPERTY(qint64 selectedCount READ selectedCount NOTIFY selectedCountChanged) + +public: + enum Role { ModelDataRole = Qt::UserRole + 1 }; + + explicit LocalTracksModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + int generation() const { return m_generation; } + qint64 totalCount() const { return m_totalCount; } + int residentRows() const { return m_residentRows; } + qint64 evictions() const { return m_evictions; } + qint64 selectedCount() const { return m_selectedCount; } + + Q_INVOKABLE QVariantMap rowAt(qint64 row) const; + Q_INVOKABLE void setArtwork(const QString &artKey, const QString &path); + + void resetQuery(int generation, qint64 totalCount, const QString &group); + bool applyPage(int generation, int page, const QByteArray &json); + void setSelection(int generation, const QByteArray &json, qint64 selectedCount); + +signals: + void generationChanged(); + void totalCountChanged(); + void residentRowsChanged(); + void evictionsChanged(); + void selectedCountChanged(); + void pageMiss(int page, int generation); + +private: + struct TrackRow { + QVariantMap row; + QString artKey; + QString groupLabel; + bool groupStart = false; + }; + using Page = QList; + + static constexpr int PageRows = 250; + static constexpr int MaxResidentPages = 8; + + QVariantMap modelData(qint64 row) const; + void requestPage(int page, bool prefetch = false) const; + void touch(int page) const; + bool selected(qint64 row) const; + void evictIfNeeded(); + + int m_generation = 0; + qint64 m_totalCount = 0; + QString m_group; + QHash m_pages; + mutable QList m_lru; + mutable QHash m_pending; + int m_residentRows = 0; + qint64 m_evictions = 0; + bool m_selectAll = false; + QList> m_selectionRanges; + qint64 m_selectedCount = 0; +}; + +extern "C" { +void qbz_local_tracks_register_qml_type(); +void qbz_local_tracks_reset(int generation, qint64 totalCount, const char *group); +bool qbz_local_tracks_apply_page(int generation, int page, const char *json); +void qbz_local_tracks_set_selection(int generation, const char *json, qint64 selectedCount); +} + +#endif // QBZ_LOCAL_TRACKS_MODEL_H diff --git a/crates/qbz-qt/cxx/plasma_item.cpp b/crates/qbz-qt/cxx/plasma_item.cpp new file mode 100644 index 000000000..381c46b4c --- /dev/null +++ b/crates/qbz-qt/cxx/plasma_item.cpp @@ -0,0 +1,433 @@ +// Plasma (immersive shader scene, mode 1) — the QRhi renderer behind +// PlasmaItem. Ports the Slint feedback loop (crates/qbz/src/ +// shader_underlay.rs:1003-1025: render the frame sampling the history +// texture, then copy it back) as a PING-PONG over two owned RGBA8 textures +// (the reference's TEX_FORMAT Rgba8Unorm, shader_underlay.rs:39): pass 1 +// renders the plasma into history[write] sampling history[read], pass 2 +// blits history[write] into the item's render target, then the indices +// swap. Owning both targets means the item's texture is never read back and +// no Qt-internal attachment is touched. +// +// Resource cadence (the pulse law): `synchronize` snapshots the QML-side +// properties when the item updates (pulse tick only); per frame the GPU +// work is one uniform upload + two fullscreen triangles — cheaper than any +// of the ShaderEffect scenes. +// +// History size tracks the item's physical size CAPPED at 2560x1440 (the +// reference's offscreen-target ceiling, shader_underlay.rs:37-39; the +// ShaderSceneLayer resW/resH rule); on a bigger screen the blit upscales. +// A resize rebuilds the pair and re-clears it (the field restarts — the +// same thing the reference gets when its window-size-tracking resources +// rebuild, shader_underlay.rs:522-523). + +#include "plasma_item.h" + +#include +#include +#include +#include + +#include + +namespace { + +// The history-target ceiling (see the header). +constexpr int MAX_W = 2560; +constexpr int MAX_H = 1440; + +// The FULL 144-byte std140 scene block (spec 01 §1) — plasma.frag declares +// every field even though it reads only some; the unread ones stay zero. +struct UniformData +{ + float time; // 0 + float phase; // 4 + float beat; // 8 + float level; // 12 + float res[2]; // 16 + float levelSmooth; // 24 + float transient; // 28 + float energyLo[4]; // 32 + float energyHi[4]; // 48 + float bandsLo[4]; // 64 + float bandsHi[4]; // 80 + float primary[4]; // 96 + float secondary[4];// 112 + float accent[4]; // 128 +}; +static_assert(sizeof(UniformData) == 144, "std140 scene block"); + +QShader loadShader(const QString &qrcPath) +{ + QFile f(qrcPath); + if (f.open(QIODevice::ReadOnly)) { + const QShader s = QShader::fromSerialized(f.readAll()); + if (!s.isValid()) + qWarning("[plasma] shader %s failed to deserialize", qPrintable(qrcPath)); + return s; + } + qWarning("[plasma] shader %s not found in the qrc", qPrintable(qrcPath)); + return QShader(); +} + +QVector4D colorVec(const QColor &c) +{ + return QVector4D(float(c.redF()), float(c.greenF()), float(c.blueF()), 1.0f); +} + +class PlasmaRenderer : public QQuickRhiItemRenderer +{ +public: + ~PlasmaRenderer() override + { + // The render pass descriptors are OURS (the RTs borrow them). + delete m_histRpDesc[0]; + delete m_histRpDesc[1]; + } + + void initialize(QRhiCommandBuffer *cb) override + { + QRhi *r = cb->rhi(); + if (m_vbuf) // already built (initialize can re-run on rhi changes; + return; // the rebuild path drops everything first) + // The fullscreen triangle (rhi_triangle.vert replaces gl_VertexID + // with this buffer — the GLES 3.0-portable spelling, linebed + // idiom). Shared by the feedback AND blit passes. + static const float TRI[] = { -1.0f, -1.0f, 3.0f, -1.0f, -1.0f, 3.0f }; + m_vbuf.reset(r->newBuffer(QRhiBuffer::Immutable, + QRhiBuffer::VertexBuffer, + quint32(sizeof(TRI)))); + m_ubuf.reset(r->newBuffer(QRhiBuffer::Dynamic, + QRhiBuffer::UniformBuffer, + quint32(sizeof(UniformData)))); + m_sampler.reset(r->newSampler(QRhiSampler::Linear, + QRhiSampler::Linear, + QRhiSampler::None, + QRhiSampler::ClampToEdge, + QRhiSampler::ClampToEdge)); + if (!m_vbuf->create() || !m_ubuf->create() || !m_sampler->create()) { + qWarning("[plasma] resource creation failed — the scene stays dark"); + m_vbuf.reset(); + return; + } + QRhiResourceUpdateBatch *u = r->nextResourceUpdateBatch(); + u->uploadStaticBuffer(m_vbuf.get(), TRI); + cb->resourceUpdate(u); + } + + void synchronize(QQuickRhiItem *item) override + { + // GUI thread: snapshot the item's properties. Runs on item update() + // (the pulse tick) and on geometry/rhi changes. + auto *pl = static_cast(item); + m_time = pl->m_time; + m_beat = pl->m_beat; + m_level = pl->m_level; + m_levelSmooth = pl->m_levelSmooth; + m_energyLo = pl->m_energyLo; + m_energyHi = pl->m_energyHi; + m_transientAmp = pl->m_transientAmp; + m_bandsLo = pl->m_bandsLo; + m_bandsHi = pl->m_bandsHi; + m_primary = colorVec(pl->m_primary); + m_secondary = colorVec(pl->m_secondary); + m_accent = colorVec(pl->m_accent); + } + + void render(QRhiCommandBuffer *cb) override + { + if (!m_vbuf) + return; // resource creation failed in initialize + QRhiRenderTarget *rt = renderTarget(); + const QSize ps = rt->pixelSize(); + if (ps.isEmpty()) + return; + const QSize hist = capped(ps); + if (hist != m_histSize) + rebuildHistory(hist); + if (!m_hist[0]) + return; // history creation failed — stay dark + + // Pipeline A (plasma) renders into the history pair's pass; pipeline + // B (blit) into the item's. Rebuild each when its descriptor moves. + if (!m_plasmaPipe || m_histRt[0]->renderPassDescriptor() != m_plasmaPassDescriptor) { + m_plasmaPassDescriptor = m_histRt[0]->renderPassDescriptor(); + buildPlasmaPipeline(); + if (!m_plasmaPipe) + return; + } + if (!m_blitPipe || rt->renderPassDescriptor() != m_blitPassDescriptor) { + m_blitPassDescriptor = rt->renderPassDescriptor(); + buildBlitPipeline(); + if (!m_blitPipe) + return; + } + + QRhiResourceUpdateBatch *u = rhi()->nextResourceUpdateBatch(); + UniformData ud = {}; + ud.time = m_time; + ud.beat = m_beat; + ud.level = m_level; + ud.res[0] = float(hist.width()); + ud.res[1] = float(hist.height()); + ud.levelSmooth = m_levelSmooth; + ud.transient = m_transientAmp; + std::memcpy(ud.energyLo, &m_energyLo, sizeof(ud.energyLo)); + std::memcpy(ud.energyHi, &m_energyHi, sizeof(ud.energyHi)); + std::memcpy(ud.bandsLo, &m_bandsLo, sizeof(ud.bandsLo)); + std::memcpy(ud.bandsHi, &m_bandsHi, sizeof(ud.bandsHi)); + std::memcpy(ud.primary, &m_primary, sizeof(ud.primary)); + std::memcpy(ud.secondary, &m_secondary, sizeof(ud.secondary)); + std::memcpy(ud.accent, &m_accent, sizeof(ud.accent)); + u->updateDynamicBuffer(m_ubuf.get(), 0, quint32(sizeof(ud)), &ud); + + // A fresh pair starts BLACK (the reference's history starts + // zero-filled too — an unsampled-cleared first frame would advect + // garbage). + if (m_needClear) { + m_needClear = false; + for (int i = 0; i < 2; ++i) { + cb->beginPass(m_histRt[i].get(), QColor(0, 0, 0, 255), + QRhiDepthStencilClearValue(1.0f, 0), nullptr); + cb->endPass(); + } + } + + const int read = m_read; + const int write = 1 - m_read; + + // Pass 1: plasma into history[write], sampling history[read]. + cb->beginPass(m_histRt[write].get(), QColor(0, 0, 0, 255), + QRhiDepthStencilClearValue(1.0f, 0), u); + cb->setGraphicsPipeline(m_plasmaPipe.get()); + cb->setViewport(QRhiViewport(0, 0, float(hist.width()), float(hist.height()))); + cb->setShaderResources(m_srbPlasma[read].get()); + const QRhiCommandBuffer::VertexInput tri(m_vbuf.get(), 0); + cb->setVertexInput(0, 1, &tri); + cb->draw(3); + cb->endPass(); + + // Pass 2: blit history[write] into the item target (the composite). + cb->beginPass(rt, QColor(0, 0, 0, 255), QRhiDepthStencilClearValue(1.0f, 0), nullptr); + cb->setGraphicsPipeline(m_blitPipe.get()); + cb->setViewport(QRhiViewport(0, 0, float(ps.width()), float(ps.height()))); + cb->setShaderResources(m_srbBlit[write].get()); + cb->setVertexInput(0, 1, &tri); + cb->draw(3); + cb->endPass(); + + m_read = write; + } + +private: + static QSize capped(const QSize &ps) + { + if (ps.width() <= MAX_W && ps.height() <= MAX_H) + return ps; + const double s = qMin(double(MAX_W) / ps.width(), double(MAX_H) / ps.height()); + return QSize(qMax(1, int(ps.width() * s)), qMax(1, int(ps.height() * s))); + } + + // (Re)build the ping-pong pair + its render targets + both SRB sets. + // Old resources go through deleteLater() — an in-flight frame may still + // reference them (QRhiResource::deleteLater is the documented deferral). + void rebuildHistory(const QSize &size) + { + QRhi *r = rhi(); + for (int i = 0; i < 2; ++i) { + if (m_hist[i]) + m_hist[i].release()->deleteLater(); + if (m_histRt[i]) + m_histRt[i].release()->deleteLater(); + if (m_srbPlasma[i]) + m_srbPlasma[i].release()->deleteLater(); + if (m_srbBlit[i]) + m_srbBlit[i].release()->deleteLater(); + } + if (m_plasmaPipe) + m_plasmaPipe.release()->deleteLater(); + m_plasmaPassDescriptor = nullptr; + m_histSize = size; + m_needClear = true; + m_read = 0; + + for (int i = 0; i < 2; ++i) { + // These textures are both sampled and rendered into. Vulkan's + // driver path happened to accept the missing usage bit, while the + // D3D backend correctly refused the texture render target and left + // Plasma black on Windows. + m_hist[i].reset(r->newTexture(QRhiTexture::RGBA8, + size, + 1, + QRhiTexture::RenderTarget)); + if (!m_hist[i]->create()) { + qWarning("[plasma] history texture creation failed — the scene stays dark"); + m_hist[0].reset(); + m_hist[1].reset(); + return; + } + // Vulkan/D3D/Metal REQUIRE a render pass descriptor before + // create(); without it the RT creation fails and the later + // beginPass corrupts the whole window (2026-08-15 owner smoke: + // Plasma left a transparent, unusable window). The RT does NOT + // own the descriptor — free the previous one on rebuild. + if (m_histRpDesc[i]) { + delete m_histRpDesc[i]; + m_histRpDesc[i] = nullptr; + } + m_histRt[i].reset(r->newTextureRenderTarget({ m_hist[i].get() })); + m_histRpDesc[i] = m_histRt[i]->newCompatibleRenderPassDescriptor(); + m_histRt[i]->setRenderPassDescriptor(m_histRpDesc[i]); + if (!m_histRt[i]->create()) { + qWarning("[plasma] history render target failed — the scene stays dark"); + m_hist[0].reset(); + m_hist[1].reset(); + return; + } + } + // SRB sets: plasma binds ubuf(0) + the READ texture(1); the blit + // binds only the WRITE texture(1) (its shader declares nothing + // else). Both bilinear (the reference's prev_samp is bilinear). + for (int i = 0; i < 2; ++i) { + m_srbPlasma[i].reset(r->newShaderResourceBindings()); + m_srbPlasma[i]->setBindings({ + QRhiShaderResourceBinding::uniformBuffer( + 0, + QRhiShaderResourceBinding::VertexStage + | QRhiShaderResourceBinding::FragmentStage, + m_ubuf.get()), + QRhiShaderResourceBinding::sampledTexture( + 1, QRhiShaderResourceBinding::FragmentStage, + m_hist[i].get(), m_sampler.get()), + }); + m_srbBlit[i].reset(r->newShaderResourceBindings()); + m_srbBlit[i]->setBindings({ + QRhiShaderResourceBinding::sampledTexture( + 1, QRhiShaderResourceBinding::FragmentStage, + m_hist[i].get(), m_sampler.get()), + }); + if (!m_srbPlasma[i]->create() || !m_srbBlit[i]->create()) { + qWarning("[plasma] shader resource bindings failed — the scene stays dark"); + m_hist[0].reset(); + m_hist[1].reset(); + return; + } + } + } + + void setVertexLayout(QRhiGraphicsPipeline *pipe) + { + QRhiVertexInputLayout inputLayout; + inputLayout.setBindings({ QRhiVertexInputBinding(quint32(2 * sizeof(float))) }); + inputLayout.setAttributes({ + { 0, 0, QRhiVertexInputAttribute::Float2, 0 }, // pos + }); + pipe->setVertexInputLayout(inputLayout); + } + + void buildPlasmaPipeline() + { + if (!m_plasmaPipe) + m_plasmaPipe.reset(rhi()->newGraphicsPipeline()); + m_plasmaPipe->setTopology(QRhiGraphicsPipeline::Triangles); + m_plasmaPipe->setCullMode(QRhiGraphicsPipeline::None); + m_plasmaPipe->setDepthTest(false); + m_plasmaPipe->setDepthWrite(false); + // OPAQUE output (the FS writes alpha 1; the scene owns the + // background while active — the linebed header documents the same + // choice for its explicit blend). + m_plasmaPipe->setShaderStages({ + { QRhiShaderStage::Vertex, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/rhi_triangle.vert.qsb")) }, + { QRhiShaderStage::Fragment, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/plasma.frag.qsb")) }, + }); + setVertexLayout(m_plasmaPipe.get()); + m_plasmaPipe->setShaderResourceBindings(m_srbPlasma[0].get()); + m_plasmaPipe->setRenderPassDescriptor(m_plasmaPassDescriptor); + if (!m_plasmaPipe->create()) { + qWarning("[plasma] pipeline creation failed — the scene stays dark"); + m_plasmaPipe.reset(); + } + } + + void buildBlitPipeline() + { + if (!m_blitPipe) + m_blitPipe.reset(rhi()->newGraphicsPipeline()); + m_blitPipe->setTopology(QRhiGraphicsPipeline::Triangles); + m_blitPipe->setCullMode(QRhiGraphicsPipeline::None); + m_blitPipe->setDepthTest(false); + m_blitPipe->setDepthWrite(false); + m_blitPipe->setShaderStages({ + { QRhiShaderStage::Vertex, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/rhi_triangle.vert.qsb")) }, + { QRhiShaderStage::Fragment, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/plasma_blit.frag.qsb")) }, + }); + setVertexLayout(m_blitPipe.get()); + m_blitPipe->setShaderResourceBindings(m_srbBlit[0].get()); + m_blitPipe->setRenderPassDescriptor(m_blitPassDescriptor); + if (!m_blitPipe->create()) { + qWarning("[plasma] blit pipeline creation failed — the scene stays dark"); + m_blitPipe.reset(); + } + } + + std::unique_ptr m_vbuf; + std::unique_ptr m_ubuf; + std::unique_ptr m_sampler; + std::unique_ptr m_hist[2]; + std::unique_ptr m_histRt[2]; + // Render pass descriptors owned BY US (the RT borrows them) — see the + // rebuild loop. Raw pointers, freed on rebuild. + QRhiRenderPassDescriptor *m_histRpDesc[2] = { nullptr, nullptr }; + std::unique_ptr m_srbPlasma[2]; + std::unique_ptr m_srbBlit[2]; + std::unique_ptr m_plasmaPipe; + std::unique_ptr m_blitPipe; + QRhiRenderPassDescriptor *m_plasmaPassDescriptor = nullptr; + QRhiRenderPassDescriptor *m_blitPassDescriptor = nullptr; + QSize m_histSize; + int m_read = 0; + bool m_needClear = true; + + // synchronize() snapshots. + float m_time = 0.0f; + float m_beat = 0.0f; + float m_level = 0.0f; + float m_levelSmooth = 0.0f; + QVector4D m_energyLo{ 0.0f, 0.0f, 0.0f, 0.0f }; + QVector4D m_energyHi{ 0.0f, 0.0f, 0.0f, 0.0f }; + float m_transientAmp = 0.0f; + QVector4D m_bandsLo{ 0.0f, 0.0f, 0.0f, 0.0f }; + QVector4D m_bandsHi{ 0.0f, 0.0f, 0.0f, 0.0f }; + QVector4D m_primary{ 0.0f, 0.863f, 0.784f, 1.0f }; + QVector4D m_secondary{ 0.588f, 0.196f, 1.0f, 1.0f }; + QVector4D m_accent{ 0.247f, 0.851f, 0.784f, 1.0f }; +}; + +} // namespace + +QQuickRhiItemRenderer *PlasmaItem::createRenderer() +{ + return new PlasmaRenderer; +} + +// QML type registration — the linebed_item.cpp idiom: the module is STATIC, +// so a startup function runs at QGuiApplication construction, BEFORE the +// engine loads the QML that names PlasmaItem. +static bool g_plasmaRegistered = false; + +extern "C" void qbz_plasma_register_qml_type() +{ + if (g_plasmaRegistered) + return; + g_plasmaRegistered = true; + qmlRegisterType("com.blitzfc.qbz", 1, 0, "PlasmaItem"); +} + +Q_COREAPP_STARTUP_FUNCTION(qbz_plasma_register_qml_type) diff --git a/crates/qbz-qt/cxx/plasma_item.h b/crates/qbz-qt/cxx/plasma_item.h new file mode 100644 index 000000000..48a4c52e5 --- /dev/null +++ b/crates/qbz-qt/cxx/plasma_item.h @@ -0,0 +1,66 @@ +// Plasma (immersive shader scene, mode 1) — the QQuickRhiItem behind the +// feedback fluid. Block A2 of the 2026-08-15 immersive-completion contract +// (spec 01 §2.1). Second hand-written C++ item after LineBedItem +// (cxx/linebed_item.h carries the why-C++ rationale: a ShaderEffect cannot +// own a feedback texture pair). +// +// QML properties are MEMBER-only (no NOTIFY): the QML wrapper +// (PlasmaScene.qml) writes them on the pulse edge and calls update() +// itself — the same cadence contract as LineBedItem. +// +// Registered as QML type `PlasmaItem` in `com.blitzfc.qbz` 1.0 (see the +// .cpp). + +#pragma once + +#include +#include +#include + +class PlasmaItem : public QQuickRhiItem +{ + Q_OBJECT + Q_PROPERTY(float time MEMBER m_time) + Q_PROPERTY(float beat MEMBER m_beat) + Q_PROPERTY(float level MEMBER m_level) + Q_PROPERTY(float levelSmooth MEMBER m_levelSmooth) + Q_PROPERTY(QVector4D energyLo MEMBER m_energyLo) + Q_PROPERTY(QVector4D energyHi MEMBER m_energyHi) + // THE FAST HALF OF THE PACK, which used to arrive zeroed. + // + // `plasma.frag` has always declared `transientAmp`, `bandsLo` and + // `bandsHi` in its std140 block and the pack has always published them — + // `ShaderSceneLayer` even parses and stores all three. They simply never + // reached this item, so the shader read zeros and ran entirely off the + // ENERGY bands, which decay x0.85 per frame (~473 ms to a tenth) against + // the bars' x0.65 (~178 ms). Plasma was being driven by the slowest + // signals in the pipeline while the fast ones travelled beside them + // unconnected. + Q_PROPERTY(float transientAmp MEMBER m_transientAmp) + Q_PROPERTY(QVector4D bandsLo MEMBER m_bandsLo) + Q_PROPERTY(QVector4D bandsHi MEMBER m_bandsHi) + Q_PROPERTY(QColor primary MEMBER m_primary) + Q_PROPERTY(QColor secondary MEMBER m_secondary) + Q_PROPERTY(QColor accent MEMBER m_accent) + +public: + explicit PlasmaItem(QQuickItem *parent = nullptr) : QQuickRhiItem(parent) {} + + QQuickRhiItemRenderer *createRenderer() override; + + // Member snapshots the renderer reads in synchronize(). Defaults are the + // Slint palette (shader_underlay.rs:121-129) so a pre-art track still + // looks right. + float m_time = 0.0f; + float m_beat = 0.0f; + float m_level = 0.0f; + float m_levelSmooth = 0.0f; + QVector4D m_energyLo{ 0.0f, 0.0f, 0.0f, 0.0f }; + QVector4D m_energyHi{ 0.0f, 0.0f, 0.0f, 0.0f }; + float m_transientAmp = 0.0f; + QVector4D m_bandsLo{ 0.0f, 0.0f, 0.0f, 0.0f }; + QVector4D m_bandsHi{ 0.0f, 0.0f, 0.0f, 0.0f }; + QColor m_primary{ 0, 220, 200 }; + QColor m_secondary{ 150, 50, 255 }; + QColor m_accent{ 63, 217, 200 }; +}; diff --git a/crates/qbz-qt/cxx/qt_vulkan_probe.cpp b/crates/qbz-qt/cxx/qt_vulkan_probe.cpp new file mode 100644 index 000000000..d22806c6f --- /dev/null +++ b/crates/qbz-qt/cxx/qt_vulkan_probe.cpp @@ -0,0 +1,193 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if QT_CONFIG(vulkan) && __has_include() +#include +#include +#endif + +namespace { + +QByteArray serializedDevices("[]"); + +#if QT_CONFIG(vulkan) && __has_include() +QByteArray deviceUuid(QVulkanFunctions *functions, VkPhysicalDevice device, + const QVulkanInstance *instance) +{ + if (instance->apiVersion() < QVersionNumber(1, 1)) + return {}; + + VkPhysicalDeviceIDProperties id{}; + id.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES; + VkPhysicalDeviceProperties2 properties{}; + properties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; + properties.pNext = &id; + functions->vkGetPhysicalDeviceProperties2(device, &properties); + + bool any = false; + for (const auto byte : id.deviceUUID) + any = any || byte != 0; + if (!any) + return {}; + return QByteArray(reinterpret_cast(id.deviceUUID), VK_UUID_SIZE).toHex(); +} +#endif + +} // namespace + +// Enumerate through the very QVulkanInstance Qt Quick will later hand to +// QRhi. This is intentionally not a second raw vkCreateInstance: implicit +// layers (notably hybrid-GPU layers) may expose a different order depending +// on the instance extensions and platform integration. Returning Qt's exact +// order makes QT_VK_PHYSICAL_DEVICE_INDEX an identity translation instead of +// a guess. +extern "C" const char *qbz_qt_vulkan_devices_json() +{ + serializedDevices = "[]"; + +#if QT_CONFIG(vulkan) && __has_include() + // QVulkanDefaultInstance needs the platform integration installed by + // QGuiApplication. Rust calls this helper after constructing it and before + // constructing the first QQuickWindow. + if (QCoreApplication::instance() == nullptr) + return serializedDevices.constData(); + + QVulkanInstance *instance = QVulkanDefaultInstance::instance(); + if (instance == nullptr || !instance->isValid()) + return serializedDevices.constData(); + + QVulkanFunctions *functions = instance->functions(); + if (functions == nullptr) + return serializedDevices.constData(); + + uint32_t count = 0; + if (functions->vkEnumeratePhysicalDevices(instance->vkInstance(), &count, nullptr) + != VK_SUCCESS || count == 0) { + return serializedDevices.constData(); + } + + QVector devices(static_cast(count)); + if (functions->vkEnumeratePhysicalDevices(instance->vkInstance(), &count, devices.data()) + != VK_SUCCESS) { + return serializedDevices.constData(); + } + + QJsonArray result; + for (uint32_t index = 0; index < count; ++index) { + VkPhysicalDeviceProperties properties{}; + functions->vkGetPhysicalDeviceProperties(devices.at(index), &properties); + + QJsonObject item; + item.insert(QStringLiteral("index"), static_cast(index)); + item.insert(QStringLiteral("name"), + QString::fromUtf8(properties.deviceName).trimmed()); + item.insert(QStringLiteral("vendor"), static_cast(properties.vendorID)); + item.insert(QStringLiteral("device"), static_cast(properties.deviceID)); + item.insert(QStringLiteral("type"), static_cast(properties.deviceType)); + item.insert(QStringLiteral("uuid"), + QString::fromLatin1(deviceUuid(functions, devices.at(index), instance))); + result.append(item); + } + serializedDevices = QJsonDocument(result).toJson(QJsonDocument::Compact); +#endif + + return serializedDevices.constData(); +} + +// Exercise the exact path the real shell needs, but inside the disposable +// preflight child: a Qt Quick Vulkan swapchain has to present actual frames to +// the active QPA compositor. Merely creating VkDevice succeeds on the owner's +// unsupported Intel-render -> NVIDIA/KWin path; the fatal failure only arrives +// when Wayland imports the first DMA-BUF. If that protocol error kills this +// process, the parent remains alive and falls back to Auto. +static int presentationPreflight(bool autoBackend) +{ + if (qobject_cast(QCoreApplication::instance()) == nullptr) + return 71; + + QQuickWindow window; + window.setTitle(QStringLiteral("QBZ graphics preflight")); + window.setFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowDoesNotAcceptFocus); + window.resize(2, 2); + window.setColor(Qt::transparent); + // Keep the native surface effectively invisible while still requiring a + // real buffer commit/import. An offscreen window would miss the exact + // Wayland failure this probe exists to catch. + window.setOpacity(0.01); + + QEventLoop loop; + QTimer pulse; + QTimer settle; + QTimer deadline; + pulse.setInterval(16); + settle.setSingleShot(true); + deadline.setSingleShot(true); + + int frames = 0; + int result = 76; // hard deadline / no usable presentation + + QObject::connect(&pulse, &QTimer::timeout, &window, &QQuickWindow::update); + QObject::connect(&window, &QQuickWindow::frameSwapped, &loop, [&] { + ++frames; + if (autoBackend && frames >= 3) { + // Pump successive native presentations, not just context creation. + // Auto has no fixed grace sleep on the healthy startup path. + result = 0; + loop.quit(); + } else if (!autoBackend && frames == 1) { + settle.start(700); + } + window.update(); + }); + QObject::connect(&window, &QQuickWindow::sceneGraphError, &loop, + [&](QQuickWindow::SceneGraphError, const QString &) { + result = 74; + loop.quit(); + }); + QObject::connect(&settle, &QTimer::timeout, &loop, [&] { + // Two swaps plus a grace interval keep the Wayland connection pumping + // long enough for an asynchronous compositor import error to arrive. + result = frames >= 2 ? 0 : 75; + loop.quit(); + }); + QObject::connect(&deadline, &QTimer::timeout, &loop, [&] { + result = 76; + loop.quit(); + }); + + window.show(); + window.update(); + pulse.start(); + deadline.start(5000); + loop.exec(); + + pulse.stop(); + settle.stop(); + deadline.stop(); + window.hide(); + window.releaseResources(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + return result; +} + +extern "C" int qbz_qt_vulkan_preflight_window() +{ + return presentationPreflight(false); +} + +// Uses Qt's actual default (OpenGL on Linux), or software selected BEFORE +// QGuiApplication. No Vulkan inventory or multiple-adapter requirement. +extern "C" int qbz_qt_auto_preflight_window() +{ + return presentationPreflight(true); +} diff --git a/crates/qbz-qt/cxx/ribbon_item.cpp b/crates/qbz-qt/cxx/ribbon_item.cpp new file mode 100644 index 000000000..5163229cc --- /dev/null +++ b/crates/qbz-qt/cxx/ribbon_item.cpp @@ -0,0 +1,280 @@ +// Spectral Ribbon (immersive shader scene, mode 4) — the QRhi renderer +// behind RibbonItem. Ports the spectrogram half of the Slint pipeline +// (crates/qbz/src/shader_underlay.rs:855-929): a persistent R8 texture +// (512 freq bands wide × 2048 time columns tall, SPECTRO_BANDS/COLS +// verbatim), one 512-byte row written at the playback-time column per +// frame, the SAME gap-fill (progress updates ~1 Hz, so the column jumps +// several slots — every skipped column gets the row), and a full clear on +// track change/seek (the reset flag). The shader is only a colorizer +// (spectral_ribbon.frag). +// +// Unlike the reference (which resets a thread-local LAST_COL on clear), +// the last-column state lives HERE, next to the texture it describes. +// +// Resource cadence (the pulse law): `synchronize` snapshots the frame +// QByteArray when the item updates (pulse tick only); `render` uploads the +// 512 B row (or the 1 MB clear, rare) and draws ONE fullscreen triangle. + +#include "ribbon_item.h" + +#include +#include +#include +#include + +#include + +namespace { + +// The lattice — pinned with spectral_ribbon.frag, src/shader_scene_bridge.rs +// (the frame publisher) and the Slint reference (shader_underlay.rs:44-45). +constexpr int SPECTRO_BANDS = 512; +constexpr int SPECTRO_COLS = 2048; +constexpr qsizetype FRAME_BYTES = 4 + 1 + SPECTRO_BANDS; + +// The FULL 144-byte std140 scene block (spec 01 §1) — the ribbon reads only +// energyHi.y (the real-time ceiling line); the rest arrives zeroed except +// resolution. +struct UniformData +{ + float time; // 0 + float phase; // 4 + float beat; // 8 + float level; // 12 + float res[2]; // 16 + float levelSmooth; // 24 + float transient; // 28 + float energyLo[4]; // 32 + float energyHi[4]; // 48 + float bandsLo[4]; // 64 + float bandsHi[4]; // 80 + float primary[4]; // 96 + float secondary[4];// 112 + float accent[4]; // 128 +}; +static_assert(sizeof(UniformData) == 144, "std140 scene block"); + +QShader loadShader(const QString &qrcPath) +{ + QFile f(qrcPath); + if (f.open(QIODevice::ReadOnly)) { + const QShader s = QShader::fromSerialized(f.readAll()); + if (!s.isValid()) + qWarning("[ribbon] shader %s failed to deserialize", qPrintable(qrcPath)); + return s; + } + qWarning("[ribbon] shader %s not found in the qrc", qPrintable(qrcPath)); + return QShader(); +} + +class RibbonRenderer : public QQuickRhiItemRenderer +{ +public: + void initialize(QRhiCommandBuffer *cb) override + { + QRhi *r = cb->rhi(); + if (m_vbuf) // already built (initialize can re-run on rhi changes) + return; + static const float TRI[] = { -1.0f, -1.0f, 3.0f, -1.0f, -1.0f, 3.0f }; + m_vbuf.reset(r->newBuffer(QRhiBuffer::Immutable, + QRhiBuffer::VertexBuffer, + quint32(sizeof(TRI)))); + m_ubuf.reset(r->newBuffer(QRhiBuffer::Dynamic, + QRhiBuffer::UniformBuffer, + quint32(sizeof(UniformData)))); + m_tex.reset(r->newTexture(QRhiTexture::R8, QSize(SPECTRO_BANDS, SPECTRO_COLS))); + // Bilinear — the reference's `samp` smooths between bands/columns. + m_sampler.reset(r->newSampler(QRhiSampler::Linear, + QRhiSampler::Linear, + QRhiSampler::None, + QRhiSampler::ClampToEdge, + QRhiSampler::ClampToEdge)); + if (!m_vbuf->create() || !m_ubuf->create() || !m_tex->create() + || !m_sampler->create()) { + qWarning("[ribbon] resource creation failed — the scene stays dark"); + m_vbuf.reset(); + return; + } + m_srb.reset(r->newShaderResourceBindings()); + m_srb->setBindings({ + QRhiShaderResourceBinding::uniformBuffer( + 0, + QRhiShaderResourceBinding::VertexStage + | QRhiShaderResourceBinding::FragmentStage, + m_ubuf.get()), + // Binding 3, like the reference's @binding(3) spectrogram. + QRhiShaderResourceBinding::sampledTexture( + 3, QRhiShaderResourceBinding::FragmentStage, m_tex.get(), m_sampler.get()), + }); + if (!m_srb->create()) { + qWarning("[ribbon] shader resource bindings failed — the scene stays dark"); + m_vbuf.reset(); + return; + } + QRhiResourceUpdateBatch *u = r->nextResourceUpdateBatch(); + u->uploadStaticBuffer(m_vbuf.get(), TRI); + cb->resourceUpdate(u); + m_needClear = true; // the texture starts UNDEFINED — clear on frame 1 + } + + void synchronize(QQuickRhiItem *item) override + { + // GUI thread: snapshot the item's properties (pulse tick only). + auto *rb = static_cast(item); + m_frame = rb->m_frame; + m_energyHi = rb->m_energyHi; + } + + void render(QRhiCommandBuffer *cb) override + { + if (!m_vbuf) + return; + QRhiRenderTarget *rt = renderTarget(); + if (!m_pipeline || rt->renderPassDescriptor() != m_passDescriptor) { + m_passDescriptor = rt->renderPassDescriptor(); + buildPipeline(); + if (!m_pipeline) + return; + } + + QRhiResourceUpdateBatch *u = rhi()->nextResourceUpdateBatch(); + + if (m_needClear) { + m_needClear = false; + uploadClear(u); + } + + // The pending frame: [col u32 LE][reset u8][512 row bytes]. + if (m_frame.size() == FRAME_BYTES) { + const auto *p = reinterpret_cast(m_frame.constData()); + const quint32 col = quint32(p[0]) | (quint32(p[1]) << 8) + | (quint32(p[2]) << 16) | (quint32(p[3]) << 24); + if (p[4] != 0) + uploadClear(u); + uploadRow(u, p + 5, int(col)); + m_frame.clear(); + } + + const QSize ps = rt->pixelSize(); + UniformData ud = {}; + ud.res[0] = float(ps.width()); + ud.res[1] = float(ps.height()); + std::memcpy(ud.energyHi, &m_energyHi, sizeof(ud.energyHi)); + u->updateDynamicBuffer(m_ubuf.get(), 0, quint32(sizeof(ud)), &ud); + + // Opaque clear (#03070c comes from the shader itself for the + // margins; the clear colour only covers a degenerate frame). + cb->beginPass(rt, QColor(3, 7, 12, 255), QRhiDepthStencilClearValue(1.0f, 0), u); + cb->setGraphicsPipeline(m_pipeline.get()); + cb->setViewport(QRhiViewport(0, 0, float(ps.width()), float(ps.height()))); + cb->setShaderResources(m_srb.get()); + const QRhiCommandBuffer::VertexInput tri(m_vbuf.get(), 0); + cb->setVertexInput(0, 1, &tri); + cb->draw(3); + cb->endPass(); + } + +private: + // Full-texture zero — track change / seek (shader_underlay.rs:859-883). + // Resets the gap-fill cursor, like the reference's SPECTRO_LAST_COL.set(0). + void uploadClear(QRhiResourceUpdateBatch *u) + { + m_lastCol = 0; + const QByteArray zeros(SPECTRO_BANDS * SPECTRO_COLS, 0); + QRhiTextureSubresourceUploadDescription sub(zeros); + sub.setDataStride(SPECTRO_BANDS); + u->uploadTexture(m_tex.get(), + QRhiTextureUploadDescription(QRhiTextureUploadEntry(0, 0, sub))); + } + + // The row write + gap-fill (shader_underlay.rs:884-927): every column + // skipped since the last write gets THIS row (progress updates ~1 Hz, + // so the column jumps several slots between ticks). + void uploadRow(QRhiResourceUpdateBatch *u, const uchar *row, int col) + { + col = qBound(0, col, SPECTRO_COLS - 1); + const int start = col > m_lastCol ? m_lastCol + 1 : col; + const int count = col + 1 - start; + QByteArray data; + data.resize(SPECTRO_BANDS * count); + for (int i = 0; i < count; ++i) + std::memcpy(data.data() + i * SPECTRO_BANDS, row, SPECTRO_BANDS); + QRhiTextureSubresourceUploadDescription sub(data); + sub.setDataStride(SPECTRO_BANDS); + sub.setDestinationTopLeft(QPoint(0, start)); + // The partial upload NEEDS the explicit source size: without it the + // deduced region is wrong (the rows never landed — the 2026-08-15 + // "axes but empty plot" bug; the full-texture clear painted fine). + sub.setSourceSize(QSize(SPECTRO_BANDS, count)); + u->uploadTexture(m_tex.get(), + QRhiTextureUploadDescription(QRhiTextureUploadEntry(0, 0, sub))); + m_lastCol = col; + } + + void buildPipeline() + { + if (!m_pipeline) + m_pipeline.reset(rhi()->newGraphicsPipeline()); + m_pipeline->setTopology(QRhiGraphicsPipeline::Triangles); + m_pipeline->setCullMode(QRhiGraphicsPipeline::None); + m_pipeline->setDepthTest(false); + m_pipeline->setDepthWrite(false); + // Opaque output (the FS writes alpha 1; the scene owns the + // background while active). + m_pipeline->setShaderStages({ + { QRhiShaderStage::Vertex, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/rhi_triangle.vert.qsb")) }, + { QRhiShaderStage::Fragment, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/spectral_ribbon.frag.qsb")) }, + }); + QRhiVertexInputLayout inputLayout; + inputLayout.setBindings({ QRhiVertexInputBinding(quint32(2 * sizeof(float))) }); + inputLayout.setAttributes({ + { 0, 0, QRhiVertexInputAttribute::Float2, 0 }, // pos + }); + m_pipeline->setVertexInputLayout(inputLayout); + m_pipeline->setShaderResourceBindings(m_srb.get()); + m_pipeline->setRenderPassDescriptor(m_passDescriptor); + if (!m_pipeline->create()) { + qWarning("[ribbon] pipeline creation failed — the scene stays dark"); + m_pipeline.reset(); + } + } + + std::unique_ptr m_vbuf; + std::unique_ptr m_ubuf; + std::unique_ptr m_tex; + std::unique_ptr m_sampler; + std::unique_ptr m_srb; + std::unique_ptr m_pipeline; + QRhiRenderPassDescriptor *m_passDescriptor = nullptr; + + QByteArray m_frame; + QVector4D m_energyHi{ 0.0f, 0.0f, 0.0f, 0.0f }; + int m_lastCol = 0; + bool m_needClear = false; +}; + +} // namespace + +QQuickRhiItemRenderer *RibbonItem::createRenderer() +{ + return new RibbonRenderer; +} + +// QML type registration — the linebed_item.cpp idiom: the module is STATIC, +// so a startup function runs at QGuiApplication construction, BEFORE the +// engine loads the QML that names RibbonItem. +static bool g_ribbonRegistered = false; + +extern "C" void qbz_ribbon_register_qml_type() +{ + if (g_ribbonRegistered) + return; + g_ribbonRegistered = true; + qmlRegisterType("com.blitzfc.qbz", 1, 0, "RibbonItem"); +} + +Q_COREAPP_STARTUP_FUNCTION(qbz_ribbon_register_qml_type) diff --git a/crates/qbz-qt/cxx/ribbon_item.h b/crates/qbz-qt/cxx/ribbon_item.h new file mode 100644 index 000000000..9d044fe37 --- /dev/null +++ b/crates/qbz-qt/cxx/ribbon_item.h @@ -0,0 +1,41 @@ +// Spectral Ribbon (immersive shader scene, mode 4) — the QQuickRhiItem +// behind the GPU spectrogram. Block A3 of the 2026-08-15 +// immersive-completion contract (spec 01 §2.4). Third hand-written C++ +// item after LineBedItem/PlasmaItem (cxx/linebed_item.h carries the +// why-C++ rationale; this scene additionally needs a PERSISTENT texture +// written in sub-rects, which a ShaderEffect cannot express). +// +// QML properties are MEMBER-only (no NOTIFY): the QML wrapper +// (RibbonScene.qml) writes them on the pulse edge and calls update() +// itself — the same cadence contract as LineBedItem/PlasmaItem. +// +// `frame` is ONE QByteArray per viz tick, self-describing: +// bytes 0..4 column (u32 LE) — playback-time column, 0..2047 +// byte 4 reset flag (0/1) — full spectrogram clear (track +// change / seek), applied BEFORE the row write +// bytes 5..517 the 512-band row (u8 per band, dB-scaled host-side) +// (the pack's batching pattern: ONE property = ONE notify per tick). +// +// Registered as QML type `RibbonItem` in `com.blitzfc.qbz` 1.0 (see the +// .cpp). + +#pragma once + +#include +#include +#include + +class RibbonItem : public QQuickRhiItem +{ + Q_OBJECT + Q_PROPERTY(QByteArray frame MEMBER m_frame) + Q_PROPERTY(QVector4D energyHi MEMBER m_energyHi) + +public: + explicit RibbonItem(QQuickItem *parent = nullptr) : QQuickRhiItem(parent) {} + + QQuickRhiItemRenderer *createRenderer() override; + + QByteArray m_frame; + QVector4D m_energyHi{ 0.0f, 0.0f, 0.0f, 0.0f }; +}; diff --git a/crates/qbz-qt/cxx/scope_trace_item.cpp b/crates/qbz-qt/cxx/scope_trace_item.cpp new file mode 100644 index 000000000..96d257171 --- /dev/null +++ b/crates/qbz-qt/cxx/scope_trace_item.cpp @@ -0,0 +1,353 @@ +#include "scope_trace_item.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr int kMaxTrailDepth = 5; + +QSGGeometryNode *makeColorNode() +{ + auto *node = new QSGGeometryNode; + auto *geometry = new QSGGeometry(QSGGeometry::defaultAttributes_ColoredPoint2D(), 0); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + node->setGeometry(geometry); + node->setFlag(QSGNode::OwnsGeometry); + node->setMaterial(new QSGVertexColorMaterial); + node->setFlag(QSGNode::OwnsMaterial); + return node; +} + +class ScopeRootNode final : public QSGNode +{ +public: + ScopeRootNode() + { + fill = makeColorNode(); + appendChildNode(fill); + for (int age = kMaxTrailDepth - 1; age >= 0; --age) { + glow[age] = makeColorNode(); + core[age] = makeColorNode(); + appendChildNode(glow[age]); + appendChildNode(core[age]); + } + } + + QSGGeometryNode *fill = nullptr; + QSGGeometryNode *glow[kMaxTrailDepth]{}; + QSGGeometryNode *core[kMaxTrailDepth]{}; +}; + +void setVertex(QSGGeometry::ColoredPoint2D &vertex, + const QPointF &point, + const QColor &color, + qreal alpha) +{ + const qreal combined = qBound(0.0, color.alphaF() * alpha, 1.0); + vertex.set(static_cast(point.x()), + static_cast(point.y()), + static_cast(std::round(color.red() * combined)), + static_cast(std::round(color.green() * combined)), + static_cast(std::round(color.blue() * combined)), + static_cast(std::round(255.0 * combined))); +} + +qreal robustGain(const QVariantList &source, int mode) +{ + const int count = mode == 0 ? source.size() / 2 : source.size(); + if (count < 2) + return 1.0; + + std::vector magnitudes; + magnitudes.reserve(count); + for (int index = 0; index < count; ++index) { + const float magnitude = mode == 0 + ? std::hypot(source[index * 2].toFloat(), source[index * 2 + 1].toFloat()) + : std::abs(source[index].toFloat()); + if (std::isfinite(magnitude)) + magnitudes.push_back(magnitude); + } + if (magnitudes.empty()) + return 1.0; + + const size_t percentile = static_cast((magnitudes.size() - 1) * 0.96); + std::nth_element(magnitudes.begin(), magnitudes.begin() + percentile, magnitudes.end()); + const qreal reference = magnitudes[percentile]; + if (reference < 0.025) + return 1.0; + const qreal target = mode == 0 ? 0.88 : 0.84; + return qBound(0.82, target / reference, mode == 0 ? 3.6 : 4.5); +} + +QVector mapPoints(const QVariantList &source, + int mode, + qreal width, + qreal height, + qreal lineWidth) +{ + const int count = mode == 0 ? source.size() / 2 : source.size(); + QVector points; + points.reserve(count); + if (count < 2) + return points; + + const qreal pad = qMax(5.0, lineWidth * 3.8); + const qreal drawWidth = qMax(1.0, width - pad * 2.0); + const qreal drawHeight = qMax(1.0, height - pad * 2.0); + const qreal gain = robustGain(source, mode); + for (int index = 0; index < count; ++index) { + if (mode == 0) { + const qreal side = qBound(-1.0, source[index * 2].toReal() * gain, 1.0); + const qreal mid = qBound(-1.0, source[index * 2 + 1].toReal() * gain, 1.0); + points.push_back(QPointF(pad + (0.5 + side * 0.47) * drawWidth, + pad + (0.5 - mid * 0.47) * drawHeight)); + } else { + // The DSP publishes two pitch-locked periods. Repeat that stable + // window according to the item's aspect ratio so a wide monitor + // gains temporal detail instead of stretching two enormous + // lobes. A square panel remains the original two-period scope. + const int repeats = qBound(1, qRound(width / qMax(1.0, height)), 4); + const qreal wrapped = std::fmod(index * repeats, count - 1.0); + const int first = qBound(0, static_cast(std::floor(wrapped)), count - 1); + const int second = qMin(first + 1, count - 1); + const qreal fraction = wrapped - first; + const qreal sample = source[first].toReal() * (1.0 - fraction) + + source[second].toReal() * fraction; + const qreal value = qBound(-1.0, sample * gain, 1.0); + points.push_back(QPointF(pad + index * drawWidth / qMax(1, count - 1), + pad + (0.5 - value * 0.47) * drawHeight)); + } + } + return points; +} + +void clearGeometry(QSGGeometryNode *node) +{ + if (node->geometry()->vertexCount() == 0) + return; + node->geometry()->allocate(0); + node->markDirty(QSGNode::DirtyGeometry); +} + +void setRibbon(QSGGeometryNode *node, + const QVector &points, + qreal width, + const QColor &color, + qreal alpha, + bool fadeAlongTrace) +{ + if (points.size() < 2 || alpha <= 0.001) { + clearGeometry(node); + return; + } + + auto *geometry = node->geometry(); + geometry->allocate(points.size() * 2); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + auto *vertices = geometry->vertexDataAsColoredPoint2D(); + const qreal halfWidth = qMax(0.5, width * 0.5); + for (int index = 0; index < points.size(); ++index) { + const QPointF before = points[qMax(0, index - 1)]; + const QPointF after = points[qMin(points.size() - 1, index + 1)]; + const QPointF tangent = after - before; + const qreal length = std::hypot(tangent.x(), tangent.y()); + const QPointF normal = length > 0.0001 + ? QPointF(-tangent.y() / length * halfWidth, + tangent.x() / length * halfWidth) + : QPointF(0.0, halfWidth); + const qreal progress = points.size() > 1 + ? static_cast(index) / (points.size() - 1) + : 1.0; + const qreal pointAlpha = alpha * (fadeAlongTrace ? 0.22 + progress * 0.78 : 1.0); + setVertex(vertices[index * 2], points[index] + normal, color, pointAlpha); + setVertex(vertices[index * 2 + 1], points[index] - normal, color, pointAlpha); + } + node->markDirty(QSGNode::DirtyGeometry); +} + +void setPointCloud(QSGGeometryNode *node, + const QVector &points, + qreal diameter, + const QColor &color, + qreal alpha) +{ + if (points.isEmpty() || alpha <= 0.001) { + clearGeometry(node); + return; + } + + auto *geometry = node->geometry(); + geometry->allocate(points.size() * 6); + geometry->setDrawingMode(QSGGeometry::DrawTriangles); + auto *vertices = geometry->vertexDataAsColoredPoint2D(); + const qreal radius = qMax(0.5, diameter * 0.5); + for (int index = 0; index < points.size(); ++index) { + const QPointF point = points[index]; + const qreal progress = points.size() > 1 + ? static_cast(index) / (points.size() - 1) + : 1.0; + const qreal pointAlpha = alpha * (0.18 + progress * 0.82); + const QPointF topLeft(point.x() - radius, point.y() - radius); + const QPointF topRight(point.x() + radius, point.y() - radius); + const QPointF bottomLeft(point.x() - radius, point.y() + radius); + const QPointF bottomRight(point.x() + radius, point.y() + radius); + auto *quad = vertices + index * 6; + setVertex(quad[0], topLeft, color, pointAlpha); + setVertex(quad[1], bottomLeft, color, pointAlpha); + setVertex(quad[2], topRight, color, pointAlpha); + setVertex(quad[3], topRight, color, pointAlpha); + setVertex(quad[4], bottomLeft, color, pointAlpha); + setVertex(quad[5], bottomRight, color, pointAlpha); + } + node->markDirty(QSGNode::DirtyGeometry); +} + +void setOscilloscopeFill(QSGGeometryNode *node, + const QVector &points, + qreal centreY, + const QColor &color, + qreal opacity) +{ + if (points.size() < 2 || opacity <= 0.001) { + clearGeometry(node); + return; + } + auto *geometry = node->geometry(); + geometry->allocate(points.size() * 2); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + auto *vertices = geometry->vertexDataAsColoredPoint2D(); + for (int index = 0; index < points.size(); ++index) { + setVertex(vertices[index * 2], points[index], color, opacity); + setVertex(vertices[index * 2 + 1], QPointF(points[index].x(), centreY), color, 0.0); + } + node->markDirty(QSGNode::DirtyGeometry); +} + +} // namespace + +ScopeTraceItem::ScopeTraceItem(QQuickItem *parent) + : QQuickItem(parent) +{ + setFlag(ItemHasContents, true); + setAntialiasing(true); +} + +void ScopeTraceItem::setPoints(const QVariantList &points) +{ + if (m_points == points) + return; + m_points = points; + m_history.prepend(points); + while (m_history.size() > kMaxTrailDepth) + m_history.removeLast(); + update(); +} + +void ScopeTraceItem::setTraceColor(const QColor &color) +{ + if (m_traceColor == color) + return; + m_traceColor = color; + update(); +} + +void ScopeTraceItem::setMode(int mode) +{ + mode = mode == 1 ? 1 : 0; + if (m_mode == mode) + return; + m_mode = mode; + m_history.clear(); + if (!m_points.isEmpty()) + m_history.push_back(m_points); + update(); +} + +void ScopeTraceItem::setLineWidth(qreal width) +{ + width = qMax(1.0, width); + if (qFuzzyCompare(m_lineWidth, width)) + return; + m_lineWidth = width; + update(); +} + +void ScopeTraceItem::setTrailDepth(int depth) +{ + depth = qBound(1, depth, kMaxTrailDepth); + if (m_trailDepth == depth) + return; + m_trailDepth = depth; + update(); +} + +void ScopeTraceItem::setFillOpacity(qreal opacity) +{ + opacity = qBound(0.0, opacity, 0.4); + if (qFuzzyCompare(m_fillOpacity, opacity)) + return; + m_fillOpacity = opacity; + update(); +} + +QSGNode *ScopeTraceItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) +{ + const int count = m_mode == 0 ? m_points.size() / 2 : m_points.size(); + if (count < 2 || width() <= 0.0 || height() <= 0.0) { + delete oldNode; + return nullptr; + } + + auto *root = static_cast(oldNode); + if (!root) + root = new ScopeRootNode; + + const QVector current = mapPoints(m_history.isEmpty() ? m_points : m_history[0], + m_mode, width(), height(), m_lineWidth); + setOscilloscopeFill(root->fill, current, height() * 0.5, m_traceColor, + m_mode == 1 ? m_fillOpacity : 0.0); + + static constexpr qreal trailAlpha[kMaxTrailDepth] = { 1.0, 0.42, 0.20, 0.09, 0.04 }; + for (int age = 0; age < kMaxTrailDepth; ++age) { + if (age >= m_trailDepth || age >= m_history.size()) { + clearGeometry(root->glow[age]); + clearGeometry(root->core[age]); + continue; + } + const QVector points = age == 0 + ? current + : mapPoints(m_history[age], m_mode, width(), height(), m_lineWidth); + const qreal alpha = trailAlpha[age]; + if (m_mode == 0) { + setPointCloud(root->glow[age], points, m_lineWidth * 4.8, + m_traceColor, alpha * 0.10); + setPointCloud(root->core[age], points, m_lineWidth * 1.25, + m_traceColor, alpha * 0.92); + } else { + setRibbon(root->glow[age], points, m_lineWidth * 5.2, m_traceColor, + alpha * 0.13, false); + setRibbon(root->core[age], points, m_lineWidth, m_traceColor, + alpha * 0.96, false); + } + } + return root; +} + +static bool g_scopeTraceRegistered = false; + +extern "C" void qbz_scope_trace_register_qml_type() +{ + if (g_scopeTraceRegistered) + return; + g_scopeTraceRegistered = true; + qmlRegisterType("com.blitzfc.qbz", 1, 0, "ScopeTraceItem"); +} + +Q_COREAPP_STARTUP_FUNCTION(qbz_scope_trace_register_qml_type) diff --git a/crates/qbz-qt/cxx/scope_trace_item.h b/crates/qbz-qt/cxx/scope_trace_item.h new file mode 100644 index 000000000..c09b6ffc4 --- /dev/null +++ b/crates/qbz-qt/cxx/scope_trace_item.h @@ -0,0 +1,50 @@ +#ifndef QBZ_SCOPE_TRACE_ITEM_H +#define QBZ_SCOPE_TRACE_ITEM_H + +#include +#include +#include +#include + +class ScopeTraceItem : public QQuickItem +{ + Q_OBJECT + Q_PROPERTY(QVariantList points READ points WRITE setPoints) + Q_PROPERTY(QColor traceColor READ traceColor WRITE setTraceColor) + Q_PROPERTY(int mode READ mode WRITE setMode) + Q_PROPERTY(qreal lineWidth READ lineWidth WRITE setLineWidth) + Q_PROPERTY(int trailDepth READ trailDepth WRITE setTrailDepth) + Q_PROPERTY(qreal fillOpacity READ fillOpacity WRITE setFillOpacity) + +public: + explicit ScopeTraceItem(QQuickItem *parent = nullptr); + + QVariantList points() const { return m_points; } + void setPoints(const QVariantList &points); + QColor traceColor() const { return m_traceColor; } + void setTraceColor(const QColor &color); + int mode() const { return m_mode; } + void setMode(int mode); + qreal lineWidth() const { return m_lineWidth; } + void setLineWidth(qreal width); + int trailDepth() const { return m_trailDepth; } + void setTrailDepth(int depth); + qreal fillOpacity() const { return m_fillOpacity; } + void setFillOpacity(qreal opacity); + +protected: + QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) override; + +private: + QVariantList m_points; + QVector m_history; + QColor m_traceColor{ 63, 217, 200 }; + int m_mode = 0; + qreal m_lineWidth = 1.5; + int m_trailDepth = 4; + qreal m_fillOpacity = 0.14; +}; + +extern "C" void qbz_scope_trace_register_qml_type(); + +#endif diff --git a/crates/qbz-qt/cxx/seek_waveform_item.cpp b/crates/qbz-qt/cxx/seek_waveform_item.cpp new file mode 100644 index 000000000..65aca5d89 --- /dev/null +++ b/crates/qbz-qt/cxx/seek_waveform_item.cpp @@ -0,0 +1,439 @@ +#include "seek_waveform_item.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +QSGGeometryNode *makeLayer() +{ + auto *node = new QSGGeometryNode; + auto *geometry = new QSGGeometry(QSGGeometry::defaultAttributes_ColoredPoint2D(), 0); + geometry->setDrawingMode(QSGGeometry::DrawTriangles); + node->setGeometry(geometry); + node->setFlag(QSGNode::OwnsGeometry); + node->setMaterial(new QSGVertexColorMaterial); + node->setFlag(QSGNode::OwnsMaterial); + return node; +} + +class WaveformRootNode : public QSGNode +{ +public: + WaveformRootNode() + { + baseClip.setIsRectangular(true); + cacheClip.setIsRectangular(true); + playedClip.setIsRectangular(true); + baseLayer = makeLayer(); + cacheLayer = makeLayer(); + playedLayer = makeLayer(); + baseClip.appendChildNode(baseLayer); + cacheClip.appendChildNode(cacheLayer); + playedClip.appendChildNode(playedLayer); + appendChildNode(&baseClip); + appendChildNode(&cacheClip); + appendChildNode(&playedClip); + } + + ~WaveformRootNode() override + { + removeChildNode(&baseClip); + removeChildNode(&cacheClip); + removeChildNode(&playedClip); + } + + QSGClipNode baseClip; + QSGClipNode cacheClip; + QSGClipNode playedClip; + QSGGeometryNode *baseLayer = nullptr; + QSGGeometryNode *cacheLayer = nullptr; + QSGGeometryNode *playedLayer = nullptr; + quint64 geometryRevision = std::numeric_limits::max(); + QSizeF geometrySize; + QColor baseGeometryColor; + QColor cacheGeometryColor; + QColor playedGeometryColor; + int geometryRenderMode = -1; +}; + +void setVertex(QSGGeometry::ColoredPoint2D &vertex, + float x, + float y, + const QColor &color, + float alpha) +{ + const float combined = std::clamp(static_cast(color.alphaF()) * alpha, 0.0f, 1.0f); + vertex.set(x, + y, + static_cast(std::round(color.red() * combined)), + static_cast(std::round(color.green() * combined)), + static_cast(std::round(color.blue() * combined)), + static_cast(std::round(255.0f * combined))); +} + +void setQuad(QSGGeometry::ColoredPoint2D *vertices, + float left, + float top, + float right, + float bottom, + const QColor &color, + float topAlpha, + float bottomAlpha) +{ + setVertex(vertices[0], left, top, color, topAlpha); + setVertex(vertices[1], left, bottom, color, bottomAlpha); + setVertex(vertices[2], right, top, color, topAlpha); + setVertex(vertices[3], right, top, color, topAlpha); + setVertex(vertices[4], left, bottom, color, bottomAlpha); + setVertex(vertices[5], right, bottom, color, bottomAlpha); +} + +constexpr int capsuleCapSegments = 4; +constexpr int capsuleVertexCount = 12 + capsuleCapSegments * 6; + +void setTriangle(QSGGeometry::ColoredPoint2D *vertices, + const QPointF &first, + const QPointF &second, + const QPointF &third, + const QColor &color, + float firstAlpha, + float secondAlpha, + float thirdAlpha) +{ + setVertex(vertices[0], static_cast(first.x()), static_cast(first.y()), + color, firstAlpha); + setVertex(vertices[1], static_cast(second.x()), static_cast(second.y()), + color, secondAlpha); + setVertex(vertices[2], static_cast(third.x()), static_cast(third.y()), + color, thirdAlpha); +} + +void setVerticalCapsule(QSGGeometry::ColoredPoint2D *vertices, + float left, + float top, + float right, + float bottom, + const QColor &color) +{ + constexpr qreal pi = 3.14159265358979323846; + const float centreX = (left + right) * 0.5f; + const float centreY = (top + bottom) * 0.5f; + const float radius = std::min((right - left) * 0.5f, (bottom - top) * 0.5f); + const float topCentre = top + radius; + const float bottomCentre = bottom - radius; + + setQuad(vertices, left, topCentre, right, centreY, color, 0.62f, 1.0f); + setQuad(vertices + 6, left, centreY, right, bottomCentre, color, 1.0f, 0.58f); + + int vertex = 12; + for (int segment = 0; segment < capsuleCapSegments; ++segment) { + const qreal firstAngle = pi + pi * segment / capsuleCapSegments; + const qreal secondAngle = pi + pi * (segment + 1) / capsuleCapSegments; + setTriangle(vertices + vertex, + QPointF(centreX, topCentre), + QPointF(centreX + std::cos(firstAngle) * radius, + topCentre + std::sin(firstAngle) * radius), + QPointF(centreX + std::cos(secondAngle) * radius, + topCentre + std::sin(secondAngle) * radius), + color, 0.62f, 0.42f, 0.42f); + vertex += 3; + } + for (int segment = 0; segment < capsuleCapSegments; ++segment) { + const qreal firstAngle = pi * segment / capsuleCapSegments; + const qreal secondAngle = pi * (segment + 1) / capsuleCapSegments; + setTriangle(vertices + vertex, + QPointF(centreX, bottomCentre), + QPointF(centreX + std::cos(firstAngle) * radius, + bottomCentre + std::sin(firstAngle) * radius), + QPointF(centreX + std::cos(secondAngle) * radius, + bottomCentre + std::sin(secondAngle) * radius), + color, 0.58f, 0.38f, 0.38f); + vertex += 3; + } +} + +void setLayerGeometry(QSGGeometryNode *node, + const std::vector &values, + qreal width, + qreal height, + const QColor &color, + int renderMode) +{ + auto *geometry = node->geometry(); + const int count = static_cast(values.size()); + const float centre = static_cast(height * 0.5); + const float halfHeight = std::max(1.0f, static_cast(height * 0.47)); + + if (renderMode == 1) { + // Small NPB: this is a stable three-sine motif rather than decoded + // RMS. At thirteen pixels the real envelope cannot communicate track + // structure, while a smooth repeated contour remains readable and + // still carries played/cache clipping as a seek affordance. + geometry->allocate(6 + std::max(0, count - 1) * 6); + geometry->setDrawingMode(QSGGeometry::DrawTriangles); + auto *vertices = geometry->vertexDataAsColoredPoint2D(); + const float railHalf = std::min(0.42f, halfHeight * 0.12f); + setQuad(vertices, 0.0f, centre - railHalf, static_cast(width), + centre + railHalf, color, 0.32f, 0.32f); + if (count < 2) { + node->markDirty(QSGNode::DirtyGeometry); + return; + } + + const float lineHalf = std::clamp(static_cast(height * 0.085), 0.62f, 1.15f); + const float swing = static_cast(height * 0.36); + auto pointAt = [&](int index) { + const float x = index * static_cast(width) / (count - 1); + const float y = centre + (0.5f - values[index]) * swing * 2.0f; + return QPointF(x, y); + }; + int vertex = 6; + for (int index = 0; index + 1 < count; ++index) { + const QPointF first = pointAt(index); + const QPointF second = pointAt(index + 1); + const QPointF delta = second - first; + const qreal length = std::hypot(delta.x(), delta.y()); + const QPointF normal = length > 0.0001 + ? QPointF(-delta.y() / length * lineHalf, + delta.x() / length * lineHalf) + : QPointF(0.0, lineHalf); + setVertex(vertices[vertex], static_cast((first + normal).x()), + static_cast((first + normal).y()), color, 0.92f); + setVertex(vertices[vertex + 1], static_cast((first - normal).x()), + static_cast((first - normal).y()), color, 0.92f); + setVertex(vertices[vertex + 2], static_cast((second + normal).x()), + static_cast((second + normal).y()), color, 0.92f); + setVertex(vertices[vertex + 3], static_cast((second + normal).x()), + static_cast((second + normal).y()), color, 0.92f); + setVertex(vertices[vertex + 4], static_cast((first - normal).x()), + static_cast((first - normal).y()), color, 0.92f); + setVertex(vertices[vertex + 5], static_cast((second - normal).x()), + static_cast((second - normal).y()), color, 0.92f); + vertex += 6; + } + node->markDirty(QSGNode::DirtyGeometry); + return; + } + + const int visibleBars = static_cast(std::count_if(values.begin(), values.end(), + [](float value) { return value > 0.0005f; })); + geometry->allocate(6 + visibleBars * capsuleVertexCount); + geometry->setDrawingMode(QSGGeometry::DrawTriangles); + auto *vertices = geometry->vertexDataAsColoredPoint2D(); + + // A permanent hairline makes the temporal axis readable before the whole + // track has been analysed. Its three clipped color layers retain the + // existing played/cache/base semantics. + const float railHalf = std::min(0.55f, halfHeight * 0.16f); + setQuad(vertices, 0.0f, centre - railHalf, static_cast(width), centre + railHalf, + color, 0.46f, 0.46f); + + if (count == 0) { + node->markDirty(QSGNode::DirtyGeometry); + return; + } + + constexpr int groupSize = 12; + const int groupBreaks = (count - 1) / groupSize; + const float nominalSlot = static_cast(width) / count; + const float groupGap = std::min(2.4f, std::max(0.9f, nominalSlot * 0.34f)); + const float usableWidth = std::max(1.0f, static_cast(width) - groupBreaks * groupGap); + const float slot = usableWidth / count; + const float barWidth = std::clamp(slot * 0.56f, 1.8f, 3.6f); + int vertex = 6; + + for (int index = 0; index < count; ++index) { + const float value = std::clamp(values[index], 0.0f, 1.0f); + if (value <= 0.0005f) + continue; + const float amplitude = std::min(halfHeight, + 0.85f + value * std::max(0.0f, halfHeight - 0.85f)); + const float left = slot * index + groupGap * (index / groupSize) + + (slot - barWidth) * 0.5f; + const float right = left + barWidth; + setVerticalCapsule(vertices + vertex, left, centre - amplitude, + right, centre + amplitude, color); + vertex += capsuleVertexCount; + } + node->markDirty(QSGNode::DirtyGeometry); +} + +std::vector renderValues(const QVariantList &source, int columns, float gamma) +{ + if (source.isEmpty() || columns < 2) + return {}; + + std::vector values(columns, 0.0f); + const int sourceCount = source.size(); + for (int column = 0; column < columns; ++column) { + const int begin = column * sourceCount / columns; + const int end = std::max(begin + 1, (column + 1) * sourceCount / columns); + float peak = 0.0f; + for (int index = begin; index < std::min(end, sourceCount); ++index) + peak = std::max(peak, std::clamp(source[index].toFloat(), 0.0f, 1.0f)); + values[column] = std::pow(peak, gamma); + } + + std::vector scratch(columns, 0.0f); + for (int pass = 0; pass < 2; ++pass) { + scratch.front() = values.front(); + scratch.back() = values.back(); + for (int index = 1; index + 1 < columns; ++index) { + scratch[index] = values[index - 1] * 0.20f + + values[index] * 0.60f + + values[index + 1] * 0.20f; + } + values.swap(scratch); + } + const float maximum = *std::max_element(values.begin(), values.end()); + if (maximum > 0.0005f) { + for (float &value : values) + value = std::clamp(value / maximum, 0.0f, 1.0f); + } + return values; +} + +std::vector decorativeWaveValues(int columns, qreal width) +{ + std::vector values(columns, 0.5f); + if (columns < 2) + return values; + + constexpr qreal twoPi = 6.28318530717958647692; + const qreal repeats = std::max(3.0, width / 210.0); + for (int index = 0; index < columns; ++index) { + const qreal progress = static_cast(index) / (columns - 1); + const qreal phase = twoPi * repeats * progress; + const qreal composite = 0.55 * std::sin(phase) + + 0.28 * std::sin(phase * 2.0 + 0.85) + + 0.17 * std::sin(phase * 3.0 + 2.1); + values[index] = static_cast(0.5 + composite * 0.46); + } + return values; +} + +} // namespace + +SeekWaveformItem::SeekWaveformItem(QQuickItem *parent) + : QQuickItem(parent) +{ + setFlag(ItemHasContents, true); + setAntialiasing(true); +} + +void SeekWaveformItem::setValues(const QVariantList &values) +{ + if (m_values == values) + return; + m_values = values; + ++m_valuesRevision; + update(); +} + +void SeekWaveformItem::setPlayedProgress(qreal progress) +{ + progress = qBound(0.0, progress, 1.0); + if (qFuzzyCompare(m_playedProgress, progress)) + return; + m_playedProgress = progress; + update(); +} + +void SeekWaveformItem::setCacheProgress(qreal progress) +{ + progress = qBound(0.0, progress, 1.0); + if (qFuzzyCompare(m_cacheProgress, progress)) + return; + m_cacheProgress = progress; + update(); +} + +void SeekWaveformItem::setBaseColor(const QColor &color) +{ + if (m_baseColor == color) + return; + m_baseColor = color; + update(); +} + +void SeekWaveformItem::setCacheColor(const QColor &color) +{ + if (m_cacheColor == color) + return; + m_cacheColor = color; + update(); +} + +void SeekWaveformItem::setPlayedColor(const QColor &color) +{ + if (m_playedColor == color) + return; + m_playedColor = color; + update(); +} + +void SeekWaveformItem::setRenderMode(int mode) +{ + mode = mode == 1 ? 1 : 0; + if (m_renderMode == mode) + return; + m_renderMode = mode; + update(); +} + +QSGNode *SeekWaveformItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) +{ + if ((m_values.isEmpty() && m_renderMode == 0) || width() <= 0.0 || height() <= 0.0) { + delete oldNode; + return nullptr; + } + + auto *root = static_cast(oldNode); + if (!root) + root = new WaveformRootNode; + + const QRectF fullRect(0.0, 0.0, width(), height()); + root->baseClip.setClipRect(fullRect); + root->cacheClip.setClipRect(QRectF(0.0, 0.0, width() * m_cacheProgress, height())); + root->playedClip.setClipRect(QRectF(0.0, 0.0, width() * m_playedProgress, height())); + + const bool colorsChanged = root->baseGeometryColor != m_baseColor + || root->cacheGeometryColor != m_cacheColor + || root->playedGeometryColor != m_playedColor; + if (root->geometryRevision != m_valuesRevision || root->geometrySize != size() + || colorsChanged || root->geometryRenderMode != m_renderMode) { + const int columns = m_renderMode == 1 + ? qBound(64, static_cast(std::ceil(width() / 3.0)), 512) + : qBound(48, static_cast(std::ceil(width() / 7.5)), 240); + const auto values = m_renderMode == 1 + ? decorativeWaveValues(columns, width()) + : renderValues(m_values, columns, 0.72f); + setLayerGeometry(root->baseLayer, values, width(), height(), m_baseColor, m_renderMode); + setLayerGeometry(root->cacheLayer, values, width(), height(), m_cacheColor, m_renderMode); + setLayerGeometry(root->playedLayer, values, width(), height(), m_playedColor, m_renderMode); + root->geometryRevision = m_valuesRevision; + root->geometrySize = size(); + root->baseGeometryColor = m_baseColor; + root->cacheGeometryColor = m_cacheColor; + root->playedGeometryColor = m_playedColor; + root->geometryRenderMode = m_renderMode; + } + return root; +} + +extern "C" void qbz_seek_waveform_register_qml_type() +{ + static std::atomic_bool registered{ false }; + if (registered.exchange(true)) + return; + qmlRegisterType("com.blitzfc.qbz", 1, 0, "SeekWaveformItem"); +} diff --git a/crates/qbz-qt/cxx/seek_waveform_item.h b/crates/qbz-qt/cxx/seek_waveform_item.h new file mode 100644 index 000000000..942122cb3 --- /dev/null +++ b/crates/qbz-qt/cxx/seek_waveform_item.h @@ -0,0 +1,53 @@ +#ifndef QBZ_SEEK_WAVEFORM_ITEM_H +#define QBZ_SEEK_WAVEFORM_ITEM_H + +#include +#include +#include + +class SeekWaveformItem : public QQuickItem +{ + Q_OBJECT + Q_PROPERTY(QVariantList values READ values WRITE setValues) + Q_PROPERTY(qreal playedProgress READ playedProgress WRITE setPlayedProgress) + Q_PROPERTY(qreal cacheProgress READ cacheProgress WRITE setCacheProgress) + Q_PROPERTY(QColor baseColor READ baseColor WRITE setBaseColor) + Q_PROPERTY(QColor cacheColor READ cacheColor WRITE setCacheColor) + Q_PROPERTY(QColor playedColor READ playedColor WRITE setPlayedColor) + Q_PROPERTY(int renderMode READ renderMode WRITE setRenderMode) + +public: + explicit SeekWaveformItem(QQuickItem *parent = nullptr); + + QVariantList values() const { return m_values; } + void setValues(const QVariantList &values); + qreal playedProgress() const { return m_playedProgress; } + void setPlayedProgress(qreal progress); + qreal cacheProgress() const { return m_cacheProgress; } + void setCacheProgress(qreal progress); + QColor baseColor() const { return m_baseColor; } + void setBaseColor(const QColor &color); + QColor cacheColor() const { return m_cacheColor; } + void setCacheColor(const QColor &color); + QColor playedColor() const { return m_playedColor; } + void setPlayedColor(const QColor &color); + int renderMode() const { return m_renderMode; } + void setRenderMode(int mode); + +protected: + QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) override; + +private: + QVariantList m_values; + qreal m_playedProgress = 0.0; + qreal m_cacheProgress = 0.0; + QColor m_baseColor{ 64, 68, 74 }; + QColor m_cacheColor{ 128, 132, 140 }; + QColor m_playedColor{ 63, 169, 232 }; + int m_renderMode = 0; + quint64 m_valuesRevision = 0; +}; + +extern "C" void qbz_seek_waveform_register_qml_type(); + +#endif diff --git a/crates/qbz-qt/cxx/tunnelflow_item.cpp b/crates/qbz-qt/cxx/tunnelflow_item.cpp new file mode 100644 index 000000000..d381d1ffb --- /dev/null +++ b/crates/qbz-qt/cxx/tunnelflow_item.cpp @@ -0,0 +1,421 @@ +// Tunnel Flow (immersive shader scene, mode 8 — Qt-only) — the QRhi +// renderer behind TunnelFlowItem. Block B1 of the 2026-08-15 +// immersive-completion contract (spec 02-tauri-tunnel-port.md). The +// mechanism is the PlasmaItem one (cxx/plasma_item.cpp carries the full +// rationale): a PING-PONG over two owned RGBA8 textures — pass 1 renders the +// tunnel into history[write] sampling history[read] (the source panel's +// self-drawImage trail system), pass 2 blits history[write] into the item's +// render target, then the indices swap. Owning both targets means the item's +// texture is never read back and no Qt-internal attachment is touched. The +// blit reuses plasma_blit.frag.qsb (a pure 1:1 textured triangle). +// +// Resource cadence (the pulse law): `synchronize` snapshots the QML-side +// properties when the item updates (pulse tick only); per frame the GPU work +// is one uniform upload + two fullscreen triangles. +// +// History size tracks the item's physical size CAPPED at 2560x1440 (the +// plasma_item / ShaderSceneLayer resW/resH rule); on a bigger screen the +// blit upscales. A resize rebuilds the pair and re-clears it (the trails +// restart). + +#include "tunnelflow_item.h" + +#include +#include +#include +#include + +#include + +namespace { + +// The history-target ceiling (see the header). +constexpr int MAX_W = 2560; +constexpr int MAX_H = 1440; + +// The scene's own std140 block — NOT the 144-byte spec-01 block: Tunnel Flow +// consumes the Viz16 stream (smoothed in QML), not the shader pack, so the +// block holds exactly what tunnel_flow.frag reads. vec4-aligned. +struct UniformData +{ + float time; // 0 (seconds) + float phase; // 4 + float bass; // 8 + float mid; // 12 + float high; // 16 + float kick; // 20 + float res[2]; // 24 + float palette0[4]; // 32 + float palette1[4]; // 48 + float palette2[4]; // 64 + float palette3[4]; // 80 +}; +static_assert(sizeof(UniformData) == 96, "std140 tunnel-flow block"); + +QShader loadShader(const QString &qrcPath) +{ + QFile f(qrcPath); + if (f.open(QIODevice::ReadOnly)) { + const QShader s = QShader::fromSerialized(f.readAll()); + if (!s.isValid()) + qWarning("[tunnelflow] shader %s failed to deserialize", qPrintable(qrcPath)); + return s; + } + qWarning("[tunnelflow] shader %s not found in the qrc", qPrintable(qrcPath)); + return QShader(); +} + +QVector4D colorVec(const QColor &c) +{ + return QVector4D(float(c.redF()), float(c.greenF()), float(c.blueF()), 1.0f); +} + +class TunnelFlowRenderer : public QQuickRhiItemRenderer +{ +public: + ~TunnelFlowRenderer() override + { + // The render pass descriptors are OURS (the RTs borrow them). + delete m_histRpDesc[0]; + delete m_histRpDesc[1]; + } + + void initialize(QRhiCommandBuffer *cb) override + { + QRhi *r = cb->rhi(); + if (m_vbuf) // already built (initialize can re-run on rhi changes; + return; // the rebuild path drops everything first) + // The fullscreen triangle (rhi_triangle.vert replaces gl_VertexID + // with this buffer — the GLES 3.0-portable spelling, plasma idiom). + // Shared by the feedback AND blit passes. + static const float TRI[] = { -1.0f, -1.0f, 3.0f, -1.0f, -1.0f, 3.0f }; + m_vbuf.reset(r->newBuffer(QRhiBuffer::Immutable, + QRhiBuffer::VertexBuffer, + quint32(sizeof(TRI)))); + m_ubuf.reset(r->newBuffer(QRhiBuffer::Dynamic, + QRhiBuffer::UniformBuffer, + quint32(sizeof(UniformData)))); + m_sampler.reset(r->newSampler(QRhiSampler::Linear, + QRhiSampler::Linear, + QRhiSampler::None, + QRhiSampler::ClampToEdge, + QRhiSampler::ClampToEdge)); + if (!m_vbuf->create() || !m_ubuf->create() || !m_sampler->create()) { + qWarning("[tunnelflow] resource creation failed — the scene stays dark"); + m_vbuf.reset(); + return; + } + QRhiResourceUpdateBatch *u = r->nextResourceUpdateBatch(); + u->uploadStaticBuffer(m_vbuf.get(), TRI); + cb->resourceUpdate(u); + } + + void synchronize(QQuickRhiItem *item) override + { + // GUI thread: snapshot the item's properties. Runs on item update() + // (the pulse tick) and on geometry/rhi changes. + auto *tf = static_cast(item); + m_time = tf->m_time; + m_phase = tf->m_phase; + m_bass = tf->m_bass; + m_mid = tf->m_mid; + m_high = tf->m_high; + m_kick = tf->m_kick; + m_palette0 = colorVec(tf->m_palette0); + m_palette1 = colorVec(tf->m_palette1); + m_palette2 = colorVec(tf->m_palette2); + m_palette3 = colorVec(tf->m_palette3); + } + + void render(QRhiCommandBuffer *cb) override + { + if (!m_vbuf) + return; // resource creation failed in initialize + QRhiRenderTarget *rt = renderTarget(); + const QSize ps = rt->pixelSize(); + if (ps.isEmpty()) + return; + const QSize hist = capped(ps); + if (hist != m_histSize) + rebuildHistory(hist); + if (!m_hist[0]) + return; // history creation failed — stay dark + + // Pipeline A (scene) renders into the history pair's pass; pipeline + // B (blit) into the item's. Rebuild each when its descriptor moves. + if (!m_scenePipe || m_histRt[0]->renderPassDescriptor() != m_scenePassDescriptor) { + m_scenePassDescriptor = m_histRt[0]->renderPassDescriptor(); + buildScenePipeline(); + if (!m_scenePipe) + return; + } + if (!m_blitPipe || rt->renderPassDescriptor() != m_blitPassDescriptor) { + m_blitPassDescriptor = rt->renderPassDescriptor(); + buildBlitPipeline(); + if (!m_blitPipe) + return; + } + + QRhiResourceUpdateBatch *u = rhi()->nextResourceUpdateBatch(); + UniformData ud = {}; + ud.time = m_time; + ud.phase = m_phase; + ud.bass = m_bass; + ud.mid = m_mid; + ud.high = m_high; + ud.kick = m_kick; + ud.res[0] = float(hist.width()); + ud.res[1] = float(hist.height()); + std::memcpy(ud.palette0, &m_palette0, sizeof(ud.palette0)); + std::memcpy(ud.palette1, &m_palette1, sizeof(ud.palette1)); + std::memcpy(ud.palette2, &m_palette2, sizeof(ud.palette2)); + std::memcpy(ud.palette3, &m_palette3, sizeof(ud.palette3)); + u->updateDynamicBuffer(m_ubuf.get(), 0, quint32(sizeof(ud)), &ud); + + // A fresh pair starts BLACK (the source panel's canvas starts blank + // too — an unsampled-cleared first frame would advect garbage). + if (m_needClear) { + m_needClear = false; + for (int i = 0; i < 2; ++i) { + cb->beginPass(m_histRt[i].get(), QColor(0, 0, 0, 255), + QRhiDepthStencilClearValue(1.0f, 0), nullptr); + cb->endPass(); + } + } + + const int read = m_read; + const int write = 1 - m_read; + + // Pass 1: the tunnel into history[write], sampling history[read]. + cb->beginPass(m_histRt[write].get(), QColor(0, 0, 0, 255), + QRhiDepthStencilClearValue(1.0f, 0), u); + cb->setGraphicsPipeline(m_scenePipe.get()); + cb->setViewport(QRhiViewport(0, 0, float(hist.width()), float(hist.height()))); + cb->setShaderResources(m_srbScene[read].get()); + const QRhiCommandBuffer::VertexInput tri(m_vbuf.get(), 0); + cb->setVertexInput(0, 1, &tri); + cb->draw(3); + cb->endPass(); + + // Pass 2: blit history[write] into the item target (the composite). + cb->beginPass(rt, QColor(0, 0, 0, 255), QRhiDepthStencilClearValue(1.0f, 0), nullptr); + cb->setGraphicsPipeline(m_blitPipe.get()); + cb->setViewport(QRhiViewport(0, 0, float(ps.width()), float(ps.height()))); + cb->setShaderResources(m_srbBlit[write].get()); + cb->setVertexInput(0, 1, &tri); + cb->draw(3); + cb->endPass(); + + m_read = write; + } + +private: + static QSize capped(const QSize &ps) + { + if (ps.width() <= MAX_W && ps.height() <= MAX_H) + return ps; + const double s = qMin(double(MAX_W) / ps.width(), double(MAX_H) / ps.height()); + return QSize(qMax(1, int(ps.width() * s)), qMax(1, int(ps.height() * s))); + } + + // (Re)build the ping-pong pair + its render targets + both SRB sets. + // Old resources go through deleteLater() — an in-flight frame may still + // reference them (QRhiResource::deleteLater is the documented deferral). + void rebuildHistory(const QSize &size) + { + QRhi *r = rhi(); + for (int i = 0; i < 2; ++i) { + if (m_hist[i]) + m_hist[i].release()->deleteLater(); + if (m_histRt[i]) + m_histRt[i].release()->deleteLater(); + if (m_srbScene[i]) + m_srbScene[i].release()->deleteLater(); + if (m_srbBlit[i]) + m_srbBlit[i].release()->deleteLater(); + } + if (m_scenePipe) + m_scenePipe.release()->deleteLater(); + m_scenePassDescriptor = nullptr; + m_histSize = size; + m_needClear = true; + m_read = 0; + + for (int i = 0; i < 2; ++i) { + // The feedback texture is sampled on one pass and is the color + // target on the next. D3D requires the RenderTarget usage bit; + // without it Tunnel Flow stays black on Windows. + m_hist[i].reset(r->newTexture(QRhiTexture::RGBA8, + size, + 1, + QRhiTexture::RenderTarget)); + if (!m_hist[i]->create()) { + qWarning("[tunnelflow] history texture creation failed — the scene stays dark"); + m_hist[0].reset(); + m_hist[1].reset(); + return; + } + // Vulkan/D3D/Metal REQUIRE a render pass descriptor before + // create(); without it QVkTextureRenderTarget warns and the + // later beginPass segfaults (2026-08-15 owner smoke: Tunnel + // Flow killed the app). The RT does NOT own the descriptor — + // free the previous one on rebuild. + if (m_histRpDesc[i]) { + delete m_histRpDesc[i]; + m_histRpDesc[i] = nullptr; + } + m_histRt[i].reset(r->newTextureRenderTarget({ m_hist[i].get() })); + m_histRpDesc[i] = m_histRt[i]->newCompatibleRenderPassDescriptor(); + m_histRt[i]->setRenderPassDescriptor(m_histRpDesc[i]); + if (!m_histRt[i]->create()) { + qWarning("[tunnelflow] history render target failed — the scene stays dark"); + m_hist[0].reset(); + m_hist[1].reset(); + return; + } + } + // SRB sets: the scene pass binds ubuf(0) + the READ texture(1); the + // blit binds only the WRITE texture(1) (its shader declares nothing + // else). Both bilinear. + for (int i = 0; i < 2; ++i) { + m_srbScene[i].reset(r->newShaderResourceBindings()); + m_srbScene[i]->setBindings({ + QRhiShaderResourceBinding::uniformBuffer( + 0, + QRhiShaderResourceBinding::VertexStage + | QRhiShaderResourceBinding::FragmentStage, + m_ubuf.get()), + QRhiShaderResourceBinding::sampledTexture( + 1, QRhiShaderResourceBinding::FragmentStage, + m_hist[i].get(), m_sampler.get()), + }); + m_srbBlit[i].reset(r->newShaderResourceBindings()); + m_srbBlit[i]->setBindings({ + QRhiShaderResourceBinding::sampledTexture( + 1, QRhiShaderResourceBinding::FragmentStage, + m_hist[i].get(), m_sampler.get()), + }); + if (!m_srbScene[i]->create() || !m_srbBlit[i]->create()) { + qWarning("[tunnelflow] shader resource bindings failed — the scene stays dark"); + m_hist[0].reset(); + m_hist[1].reset(); + return; + } + } + } + + void setVertexLayout(QRhiGraphicsPipeline *pipe) + { + QRhiVertexInputLayout inputLayout; + inputLayout.setBindings({ QRhiVertexInputBinding(quint32(2 * sizeof(float))) }); + inputLayout.setAttributes({ + { 0, 0, QRhiVertexInputAttribute::Float2, 0 }, // pos + }); + pipe->setVertexInputLayout(inputLayout); + } + + void buildScenePipeline() + { + if (!m_scenePipe) + m_scenePipe.reset(rhi()->newGraphicsPipeline()); + m_scenePipe->setTopology(QRhiGraphicsPipeline::Triangles); + m_scenePipe->setCullMode(QRhiGraphicsPipeline::None); + m_scenePipe->setDepthTest(false); + m_scenePipe->setDepthWrite(false); + // OPAQUE output (the FS writes alpha 1; the scene owns the + // background while active — the plasma header documents the same + // choice). + m_scenePipe->setShaderStages({ + { QRhiShaderStage::Vertex, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/rhi_triangle.vert.qsb")) }, + { QRhiShaderStage::Fragment, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/tunnel_flow.frag.qsb")) }, + }); + setVertexLayout(m_scenePipe.get()); + m_scenePipe->setShaderResourceBindings(m_srbScene[0].get()); + m_scenePipe->setRenderPassDescriptor(m_scenePassDescriptor); + if (!m_scenePipe->create()) { + qWarning("[tunnelflow] pipeline creation failed — the scene stays dark"); + m_scenePipe.reset(); + } + } + + void buildBlitPipeline() + { + if (!m_blitPipe) + m_blitPipe.reset(rhi()->newGraphicsPipeline()); + m_blitPipe->setTopology(QRhiGraphicsPipeline::Triangles); + m_blitPipe->setCullMode(QRhiGraphicsPipeline::None); + m_blitPipe->setDepthTest(false); + m_blitPipe->setDepthWrite(false); + m_blitPipe->setShaderStages({ + { QRhiShaderStage::Vertex, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/rhi_triangle.vert.qsb")) }, + { QRhiShaderStage::Fragment, + loadShader(QStringLiteral( + ":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/plasma_blit.frag.qsb")) }, + }); + setVertexLayout(m_blitPipe.get()); + m_blitPipe->setShaderResourceBindings(m_srbBlit[0].get()); + m_blitPipe->setRenderPassDescriptor(m_blitPassDescriptor); + if (!m_blitPipe->create()) { + qWarning("[tunnelflow] blit pipeline creation failed — the scene stays dark"); + m_blitPipe.reset(); + } + } + + std::unique_ptr m_vbuf; + std::unique_ptr m_ubuf; + std::unique_ptr m_sampler; + std::unique_ptr m_hist[2]; + std::unique_ptr m_histRt[2]; + // Render pass descriptors owned BY US (the RT borrows them) — see the + // rebuild loop. Raw pointers, freed on rebuild. + QRhiRenderPassDescriptor *m_histRpDesc[2] = { nullptr, nullptr }; + std::unique_ptr m_srbScene[2]; + std::unique_ptr m_srbBlit[2]; + std::unique_ptr m_scenePipe; + std::unique_ptr m_blitPipe; + QRhiRenderPassDescriptor *m_scenePassDescriptor = nullptr; + QRhiRenderPassDescriptor *m_blitPassDescriptor = nullptr; + QSize m_histSize; + int m_read = 0; + bool m_needClear = true; + + // synchronize() snapshots. + float m_time = 0.0f; + float m_phase = 0.0f; + float m_bass = 0.0f; + float m_mid = 0.0f; + float m_high = 0.0f; + float m_kick = 0.0f; + QVector4D m_palette0{ 1.0f, 0.416f, 0.416f, 1.0f }; + QVector4D m_palette1{ 1.0f, 0.804f, 0.361f, 1.0f }; + QVector4D m_palette2{ 0.408f, 0.863f, 0.667f, 1.0f }; + QVector4D m_palette3{ 0.431f, 0.690f, 1.0f, 1.0f }; +}; + +} // namespace + +QQuickRhiItemRenderer *TunnelFlowItem::createRenderer() +{ + return new TunnelFlowRenderer; +} + +// QML type registration — the plasma_item.cpp idiom: the module is STATIC, +// so a startup function runs at QGuiApplication construction, BEFORE the +// engine loads the QML that names TunnelFlowItem. +static bool g_tunnelflowRegistered = false; + +extern "C" void qbz_tunnelflow_register_qml_type() +{ + if (g_tunnelflowRegistered) + return; + g_tunnelflowRegistered = true; + qmlRegisterType("com.blitzfc.qbz", 1, 0, "TunnelFlowItem"); +} + +Q_COREAPP_STARTUP_FUNCTION(qbz_tunnelflow_register_qml_type) diff --git a/crates/qbz-qt/cxx/tunnelflow_item.h b/crates/qbz-qt/cxx/tunnelflow_item.h new file mode 100644 index 000000000..ef1fc4c12 --- /dev/null +++ b/crates/qbz-qt/cxx/tunnelflow_item.h @@ -0,0 +1,55 @@ +// Tunnel Flow (immersive shader scene, mode 8 — Qt-only) — the +// QQuickRhiItem behind the feedback tunnel. Block B1 of the 2026-08-15 +// immersive-completion contract (spec 02-tauri-tunnel-port.md): a rewrite of +// the legacy Tauri Canvas2D panel (TunnelFlowPanel.svelte) as +// qml/assets/shaders/tunnel_flow.frag. Same mechanism as PlasmaItem +// (cxx/plasma_item.h): a feedback ping-pong over two owned RGBA8 textures a +// ShaderEffect cannot own. +// +// QML properties are MEMBER-only (no NOTIFY): the QML wrapper +// (TunnelFlowScene.qml) computes the audio state (Viz16 smoothing, kick +// detector, the TAURI phase accumulator) and writes these on the pulse edge +// via pulseTick() — the same cadence contract as PlasmaItem/LineBedItem. +// +// Registered as QML type `TunnelFlowItem` in `com.blitzfc.qbz` 1.0 (see the +// .cpp). + +#pragma once + +#include +#include + +class TunnelFlowItem : public QQuickRhiItem +{ + Q_OBJECT + Q_PROPERTY(float time MEMBER m_time) + Q_PROPERTY(float phase MEMBER m_phase) + Q_PROPERTY(float bass MEMBER m_bass) + Q_PROPERTY(float mid MEMBER m_mid) + Q_PROPERTY(float high MEMBER m_high) + Q_PROPERTY(float kick MEMBER m_kick) + Q_PROPERTY(QColor palette0 MEMBER m_palette0) + Q_PROPERTY(QColor palette1 MEMBER m_palette1) + Q_PROPERTY(QColor palette2 MEMBER m_palette2) + Q_PROPERTY(QColor palette3 MEMBER m_palette3) + +public: + explicit TunnelFlowItem(QQuickItem *parent = nullptr) : QQuickRhiItem(parent) {} + + QQuickRhiItemRenderer *createRenderer() override; + + // Member snapshots the renderer reads in synchronize(). The palette + // defaults are the Tauri fallback (TunnelFlowPanel.svelte:63-68) so a + // pre-art track still looks right; time is SECONDS here (the shader + // converts to the source's ms domain). + float m_time = 0.0f; + float m_phase = 0.0f; + float m_bass = 0.0f; + float m_mid = 0.0f; + float m_high = 0.0f; + float m_kick = 0.0f; + QColor m_palette0{ 255, 106, 106 }; + QColor m_palette1{ 255, 205, 92 }; + QColor m_palette2{ 104, 220, 170 }; + QColor m_palette3{ 110, 176, 255 }; +}; diff --git a/crates/qbz-qt/cxx/win_shell.cpp b/crates/qbz-qt/cxx/win_shell.cpp new file mode 100644 index 000000000..2c8f24537 --- /dev/null +++ b/crates/qbz-qt/cxx/win_shell.cpp @@ -0,0 +1,132 @@ +// Windows shell helpers with no Q_OBJECT of their own. +// +// Compiled on EVERY platform on purpose: the bodies are portable Qt and only +// the caller's use of the WId is Windows-specific. Keeping it unconditional +// means the file is type-checked by the Linux and macOS builds too, which is +// where a signature drift would otherwise go unnoticed until a Windows CI run. + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#endif + +// The top-level window's native handle, or nullptr before one is shown. +// +// MUST NOT be called before exec(): QWindow::winId() CREATES the platform +// window as a side effect, so an early call would hand back a handle for a +// window Qt has not finished setting up, and the real one would then get a +// different HWND. Callers hop to the GUI thread first; winId() is not +// thread-safe. +extern "C" void *qbz_main_window_hwnd() +{ + const auto windows = QGuiApplication::topLevelWindows(); + for (QWindow *w : windows) + if (w->isVisible() && w->type() == Qt::Window) + return reinterpret_cast(w->winId()); + return nullptr; +} + +#ifdef _WIN32 +namespace { + +// Give the custom-chrome window a real hit-test back. +// +// MEASURED, not assumed. With `Qt::CustomizeWindowHint` -- which is the only +// way to stop Qt painting its own minimise/maximise/close cluster on top of +// QBZ's header -- Qt answers WM_NCHITTEST with HTNOWHERE for the ENTIRE +// window: centre, header, player bar and sidebar all returned 0. Windows +// delivers no mouse input to HTNOWHERE, so the app looked frozen while it was +// running perfectly and the tray menu still drove playback. +// +// Without that hint Qt hit-tests correctly but populates the default title +// hints, and its customized-title path draws the buttons over our header. No +// combination of public flags gives all three of: no drawn buttons, working +// clicks, and the WS_THICKFRAME that Windows tiling needs. +// +// So the flags keep CustomizeWindowHint and this filter answers the hit test +// the way an ordinary sizable, caption-less window would: DefWindowProcW +// returns the eight edge codes near the frame and HTCLIENT everywhere else. +// Nothing here invents geometry -- it defers to the same code every other +// borderless-resizable window uses. +// +// Dragging is unaffected and stays with QML's `startSystemMove()`: with no +// caption there is no HTCAPTION band to inherit, which is the arrangement the +// header was already written for. +class QbzNcHitTestFilter : public QAbstractNativeEventFilter +{ +public: + bool nativeEventFilter(const QByteArray &type, void *message, qintptr *result) override + { + if (type != QByteArrayLiteral("windows_generic_MSG")) + return false; + MSG *msg = static_cast(message); + if (!msg || msg->message != WM_NCHITTEST) + return false; + + // NARROW ON PURPOSE: only a window that is sizable AND caption-less, + // which is exactly the custom-chrome main window. Menus, tooltips and + // popups have no WS_THICKFRAME; a window showing the system title bar + // has WS_CAPTION and must keep Qt's own answer. Getting this wrong + // would break hit-testing everywhere instead of fixing it in one + // place. + const LONG_PTR style = GetWindowLongPtrW(msg->hwnd, GWL_STYLE); + if ((style & WS_THICKFRAME) == 0 || (style & WS_CAPTION) == WS_CAPTION) + return false; + + *result = static_cast( + DefWindowProcW(msg->hwnd, WM_NCHITTEST, msg->wParam, msg->lParam)); + return true; + } +}; + +QbzNcHitTestFilter *g_hit_filter = nullptr; + +} // namespace +#endif // _WIN32 + +// Install the hit-test filter. Idempotent; a no-op off Windows. +extern "C" void qbz_install_hittest_filter() +{ +#ifdef _WIN32 + if (g_hit_filter) + return; + g_hit_filter = new QbzNcHitTestFilter; + QCoreApplication::instance()->installNativeEventFilter(g_hit_filter); +#endif +} + +// WM_QUERYENDSESSION -> QGuiApplication::commitDataRequest, via Qt's Windows +// session manager. It fires BEFORE Windows decides whether to proceed with the +// logoff and Qt blocks until the handler returns, which makes it the one safe +// place to persist synchronously: anything deferred to a queued connection or +// another thread races the process being killed. +extern "C" void qbz_install_commit_data_handler(void (*cb)()) +{ +#ifndef QT_NO_SESSIONMANAGER + // Once. Qt::UniqueConnection does NOT deduplicate lambdas -- each connect + // makes an independent connection -- so a second install would run the + // session persist twice on every logoff. + static bool installed = false; + if (installed) + return; + installed = true; + + QObject::connect(qApp, &QGuiApplication::commitDataRequest, qApp, + [cb](QSessionManager &) { cb(); }); +#else + // A Qt built without the session manager has no commitDataRequest at all, + // and this file compiles on every platform. + (void)cb; +#endif +} diff --git a/crates/qbz-qt/cxx/win_tray.cpp b/crates/qbz-qt/cxx/win_tray.cpp new file mode 100644 index 000000000..712be4545 --- /dev/null +++ b/crates/qbz-qt/cxx/win_tray.cpp @@ -0,0 +1,356 @@ +// Windows notification-area icon, modelled on Qt's own +// qwindowssystemtrayicon.cpp. The whole body is inside `#ifdef _WIN32` so the +// file can sit unconditionally in build.rs beside font_fallback.cpp and +// compile to nothing on Linux and macOS. + +#include "win_tray.h" + +#ifdef _WIN32 + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +// ORDER MATTERS, and it is not alphabetical. must come first: +// uses DECLSPEC_IMPORT, HDROP and EXTERN_C without declaring +// them, so including it first produces a wall of C4430/C2146 inside the SDK +// header itself and points nowhere near this file. +#include + +#include +// GET_X_LPARAM / GET_Y_LPARAM: signed extraction, which matters on a +// multi-monitor desktop where a secondary display has negative coordinates. +#include + +#pragma comment(lib, "shell32.lib") +#pragma comment(lib, "user32.lib") + +namespace { + +// WM_APP+101: the tray's callback message. Anything in the WM_APP range is +// ours to define, and this window class receives nothing else. +constexpr UINT kTrayCallback = WM_APP + 101; +constexpr UINT kIconId = 1; + +enum MenuId : UINT { + kMenuPlayPause = 1001, + kMenuNext, + kMenuPrevious, + kMenuShowHide, + kMenuQuit, +}; + +HWND g_hwnd = nullptr; +HICON g_icon = nullptr; +// LoadImageW hands us an icon WE own; the LoadIconW(nullptr, ...) fallback is a +// shared system icon that must never be destroyed. Which one we got decides +// whether qbz_win_tray_destroy calls DestroyIcon. +bool g_icon_owned = false; +UINT g_taskbar_created = 0; // RegisterWindowMessageW("TaskbarCreated") +bool g_playing = false; +wchar_t g_tooltip[128] = L"QBZ"; + +wchar_t g_label_play_pause[64] = L"Play/Pause"; +wchar_t g_label_next[64] = L"Next Track"; +wchar_t g_label_previous[64] = L"Previous Track"; +wchar_t g_label_show_hide[64] = L"Show/Hide Window"; +wchar_t g_label_quit[64] = L"Quit QBZ"; + +void (*g_on_left_click)() = nullptr; +void (*g_on_play_pause)() = nullptr; +void (*g_on_next)() = nullptr; +void (*g_on_previous)() = nullptr; +void (*g_on_quit)() = nullptr; + +void copy_label(wchar_t *dst, size_t cap, const wchar_t *src) +{ + if (!src) + return; + wcsncpy_s(dst, cap, src, _TRUNCATE); +} + +// The icon Windows draws. winresource assigns id 1 to the first icon it +// embeds (W4), and LoadImageW at 16x16 picks the matching RT_ICON out of that +// group. If it is ever missing we fall back to the generic application icon +// rather than returning null: a tray entry with the wrong picture is a cosmetic +// bug, a tray entry that never appears is close-to-tray hiding the window with +// no way back. +// +// Sets g_icon_owned: without LR_SHARED the LoadImageW handle is OURS and leaks +// unless destroyed, while the LoadIconW fallback is shared and must not be. +HICON load_tray_icon() +{ + HINSTANCE inst = GetModuleHandleW(nullptr); + const int cx = GetSystemMetrics(SM_CXSMICON); + const int cy = GetSystemMetrics(SM_CYSMICON); + if (HICON icon = static_cast( + LoadImageW(inst, MAKEINTRESOURCEW(1), IMAGE_ICON, cx, cy, LR_DEFAULTCOLOR))) { + g_icon_owned = true; + return icon; + } + g_icon_owned = false; + // MAKEINTRESOURCEW, not IDI_APPLICATION: this build does not define + // UNICODE, so the bare macro expands to the ANSI MAKEINTRESOURCEA and + // will not convert to the LPCWSTR LoadIconW wants. 32512 IS + // IDI_APPLICATION. Every Win32 call in this file is an explicit W for the + // same reason. + return LoadIconW(nullptr, MAKEINTRESOURCEW(32512)); +} + +NOTIFYICONDATAW base_data() +{ + NOTIFYICONDATAW nid = {}; + nid.cbSize = sizeof(nid); + nid.hWnd = g_hwnd; + nid.uID = kIconId; + return nid; +} + +bool add_icon() +{ + NOTIFYICONDATAW nid = base_data(); + // NIF_SHOWTIP is REQUIRED with version 4. Version 4 suppresses the standard + // tooltip unless it is asked for, so without this flag the icon appears, + // the tooltip is set on every track, and nothing ever shows on hover. + nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP; + nid.uCallbackMessage = kTrayCallback; + nid.hIcon = g_icon; + wcsncpy_s(nid.szTip, g_tooltip, _TRUNCATE); + if (!Shell_NotifyIconW(NIM_ADD, &nid)) + return false; + + // Version 4 is what makes the callback carry the event in LOWORD(lParam) + // and real screen coordinates in wParam. Without it the right-click + // arrives as a bare WM_RBUTTONUP with no position and the menu opens in + // the wrong place on a multi-monitor desktop. + // + // The result is CHECKED. wnd_proc decodes callbacks as version 4 and + // nothing else, so a silent negotiation failure would leave a live icon + // whose clicks and menu do nothing -- worse than no icon at all, because + // close-to-tray would then hide the window behind it. + NOTIFYICONDATAW ver = base_data(); + ver.uVersion = NOTIFYICON_VERSION_4; + if (!Shell_NotifyIconW(NIM_SETVERSION, &ver)) { + NOTIFYICONDATAW del = base_data(); + Shell_NotifyIconW(NIM_DELETE, &del); + return false; + } + return true; +} + +void show_menu(int x, int y) +{ + HMENU menu = CreatePopupMenu(); + if (!menu) + return; + + AppendMenuW(menu, MF_STRING, kMenuPlayPause, g_label_play_pause); + AppendMenuW(menu, MF_STRING, kMenuPrevious, g_label_previous); + AppendMenuW(menu, MF_STRING, kMenuNext, g_label_next); + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING, kMenuShowHide, g_label_show_hide); + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING, kMenuQuit, g_label_quit); + + // The documented ritual. Without the SetForegroundWindow the menu does not + // dismiss when the user clicks elsewhere; without the trailing PostMessage + // it can stay up after a selection (MSDN TrackPopupMenu remarks). + SetForegroundWindow(g_hwnd); + const UINT cmd = TrackPopupMenu(menu, TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, + x, y, 0, g_hwnd, nullptr); + PostMessageW(g_hwnd, WM_NULL, 0, 0); + DestroyMenu(menu); + + // Hand keyboard focus back to the notification area, which the Shell + // documentation asks for after a context menu closes -- particularly when + // it was dismissed with Escape rather than a selection. + NOTIFYICONDATAW focus = base_data(); + Shell_NotifyIconW(NIM_SETFOCUS, &focus); + + switch (cmd) { + case kMenuPlayPause: + if (g_on_play_pause) + g_on_play_pause(); + break; + case kMenuNext: + if (g_on_next) + g_on_next(); + break; + case kMenuPrevious: + if (g_on_previous) + g_on_previous(); + break; + case kMenuShowHide: + if (g_on_left_click) + g_on_left_click(); + break; + case kMenuQuit: + if (g_on_quit) + g_on_quit(); + break; + default: + break; + } +} + +LRESULT CALLBACK tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) +{ + // Explorer restarted (or crashed): every icon is gone from the new taskbar + // and each app has to add its own back. Missing this is why a tray icon + // "disappears forever" after an Explorer restart. + if (msg == g_taskbar_created && g_taskbar_created != 0) { + add_icon(); + return 0; + } + + if (msg == kTrayCallback) { + // Version 4 packing: event in the low word of lParam, cursor position + // in wParam. + switch (LOWORD(lparam)) { + case NIN_SELECT: + case NIN_KEYSELECT: + if (g_on_left_click) + g_on_left_click(); + return 0; + case WM_CONTEXTMENU: + show_menu(GET_X_LPARAM(wparam), GET_Y_LPARAM(wparam)); + return 0; + default: + return 0; + } + } + + return DefWindowProcW(hwnd, msg, wparam, lparam); +} + +bool ensure_window() +{ + if (g_hwnd) + return true; + + HINSTANCE inst = GetModuleHandleW(nullptr); + static const wchar_t *kClass = L"QbzTrayMessageWindow"; + + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = tray_wnd_proc; + wc.hInstance = inst; + wc.lpszClassName = kClass; + // Re-registering the same class returns 0 with ERROR_CLASS_ALREADY_EXISTS, + // which is fine on a second create. + RegisterClassExW(&wc); + + // NOT HWND_MESSAGE. A message-only window never receives the broadcast + // TaskbarCreated, so the icon would never come back after an Explorer + // restart. WS_EX_TOOLWINDOW keeps this invisible helper out of the taskbar + // and the Alt+Tab list, which is the reason HWND_MESSAGE is tempting. + g_hwnd = CreateWindowExW(WS_EX_TOOLWINDOW, kClass, L"QbzTrayMessageWindow", + WS_OVERLAPPED, 0, 0, 0, 0, nullptr, nullptr, inst, nullptr); + if (!g_hwnd) + return false; + + g_taskbar_created = RegisterWindowMessageW(L"TaskbarCreated"); + // A broadcast message is filtered out by UIPI unless the window opts in. + if (g_taskbar_created) + ChangeWindowMessageFilterEx(g_hwnd, g_taskbar_created, MSGFLT_ALLOW, nullptr); + + return true; +} + +} // namespace + +extern "C" void qbz_win_tray_set_labels(const wchar_t *play_pause, const wchar_t *next, + const wchar_t *previous, const wchar_t *show_hide, + const wchar_t *quit) +{ + copy_label(g_label_play_pause, 64, play_pause); + copy_label(g_label_next, 64, next); + copy_label(g_label_previous, 64, previous); + copy_label(g_label_show_hide, 64, show_hide); + copy_label(g_label_quit, 64, quit); +} + +extern "C" void qbz_win_tray_set_callbacks(void (*on_left_click)(), void (*on_play_pause)(), + void (*on_next)(), void (*on_previous)(), + void (*on_quit)()) +{ + g_on_left_click = on_left_click; + g_on_play_pause = on_play_pause; + g_on_next = on_next; + g_on_previous = on_previous; + g_on_quit = on_quit; +} + +extern "C" bool qbz_win_tray_create(const wchar_t *tooltip) +{ + if (tooltip) + wcsncpy_s(g_tooltip, tooltip, _TRUNCATE); + + // Idempotent: a second create must not stack a second NIM_ADD on a live + // icon (which fails and would report the whole tray dead while the first + // icon is still sitting there). Remove the old one first. + if (g_hwnd) { + NOTIFYICONDATAW del = base_data(); + Shell_NotifyIconW(NIM_DELETE, &del); + } + + if (!ensure_window()) + return false; + if (!g_icon) + g_icon = load_tray_icon(); + + if (add_icon()) + return true; + + // Failed. Give back the window and the icon rather than leaving them + // parked for the life of the process: the Rust side will not retry, so + // nothing else would ever free them. + if (g_icon && g_icon_owned) + DestroyIcon(g_icon); + g_icon = nullptr; + g_icon_owned = false; + DestroyWindow(g_hwnd); + g_hwnd = nullptr; + return false; +} + +extern "C" void qbz_win_tray_set_tooltip(const wchar_t *tooltip) +{ + if (!tooltip) + return; + // szTip is 128 wchar INCLUDING the terminator; _TRUNCATE keeps the copy + // safe and Windows simply elides the rest in the hover text. + wcsncpy_s(g_tooltip, tooltip, _TRUNCATE); + if (!g_hwnd) + return; + NOTIFYICONDATAW nid = base_data(); + // NIF_SHOWTIP again: it is a per-call flag, not sticky state. + nid.uFlags = NIF_TIP | NIF_SHOWTIP; + wcsncpy_s(nid.szTip, g_tooltip, _TRUNCATE); + Shell_NotifyIconW(NIM_MODIFY, &nid); +} + +extern "C" void qbz_win_tray_set_playing(bool playing) +{ + // Recorded for the menu; the label itself is the platform-neutral + // "Play/Pause" the other two trays use, so nothing needs rebuilding here. + g_playing = playing; +} + +extern "C" void qbz_win_tray_destroy(void) +{ + if (!g_hwnd) + return; + NOTIFYICONDATAW nid = base_data(); + Shell_NotifyIconW(NIM_DELETE, &nid); + DestroyWindow(g_hwnd); + g_hwnd = nullptr; + // LoadImageW WITHOUT LR_SHARED returns an icon this process owns, and it + // leaks unless destroyed. Only the LoadIconW(nullptr, ...) fallback is a + // shared system icon, which must never be destroyed. + if (g_icon && g_icon_owned) + DestroyIcon(g_icon); + g_icon = nullptr; + g_icon_owned = false; +} + +#endif // _WIN32 diff --git a/crates/qbz-qt/cxx/win_tray.h b/crates/qbz-qt/cxx/win_tray.h new file mode 100644 index 000000000..680368c16 --- /dev/null +++ b/crates/qbz-qt/cxx/win_tray.h @@ -0,0 +1,40 @@ +// Windows notification-area icon. C ABI so the Rust seam (src/tray_windows.rs) +// can drive it exactly as tray_linux.rs drives the StatusNotifierItem and +// tray_macos.rs drives the NSStatusItem. +// +// EVERY function here is GUI-THREAD ONLY. The icon owns a real (hidden) window +// and a message loop; touching it from tokio would post to a thread that never +// pumps. +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// The five menu labels, already translated by the Rust side (the same +// qbz_i18n keys Linux and macOS use, so the three menus stay identical). +// Must be called before qbz_win_tray_create; the strings are copied. +void qbz_win_tray_set_labels(const wchar_t *play_pause, const wchar_t *next, + const wchar_t *previous, const wchar_t *show_hide, + const wchar_t *quit); + +// Installed once, before create. Each maps 1:1 to a tray_qt dispatcher. +void qbz_win_tray_set_callbacks(void (*on_left_click)(), void (*on_play_pause)(), + void (*on_next)(), void (*on_previous)(), + void (*on_quit)()); + +// NIM_ADD + NIM_SETVERSION(4). False if the message window or the icon could +// not be created -- the caller must then leave the tray handle empty, or +// close-to-tray would hide the window with nothing to restore it from. +bool qbz_win_tray_create(const wchar_t *tooltip); + +void qbz_win_tray_set_tooltip(const wchar_t *tooltip); +void qbz_win_tray_set_playing(bool playing); +void qbz_win_tray_destroy(void); + +#ifdef __cplusplus +} +#endif diff --git a/crates/qbz-qt/data/about-sponsors.json b/crates/qbz-qt/data/about-sponsors.json new file mode 100644 index 000000000..12d5b9b2a --- /dev/null +++ b/crates/qbz-qt/data/about-sponsors.json @@ -0,0 +1,67 @@ +{ + "github": [ + "f5-cabbott", + "damienalexandre", + "cmayes", + "rubi-t8", + "Xameon42", + "MdeRhoter", + "houjjou", + "Em-gitt", + "frigaut", + "ejrichards", + "Torajirosan", + "DCMaxime", + "nkopas", + "Eirikr70" + ], + "ko_fi": [ + "Daniel", + "DBeristain", + "voytrekk", + "Peppe", + "Bennytek", + "Aboto", + "turboapapera", + "voxit", + "Tom", + "Bopi", + "Sylvain Van Hoof", + "Torbi", + "Chris Heino", + "Pep", + "Roger", + "Norin", + "Niko", + "Mauzify", + "Max", + "forte_hunter", + "Anthony Philibert", + "Zeitfalle", + "Ben", + "silverbeetles", + "Mat", + "CB", + "Salty Pringles", + "oujou", + "Darktime ahead", + "Nebu", + "Otto", + "Hyrbii", + "Guillaume Michaud", + "Thomas", + "James", + "lollopolve", + "Julien", + "@adavid", + "Carlo", + "M.Rodorodette", + "C.H.", + "CraigMyers", + "Mal", + "Wouter Eerdekens", + "Ian B", + "valfaun", + "Jan" + ] +} diff --git a/crates/qbz/icons/tray-color-22.png b/crates/qbz-qt/icons/tray-color-22.png similarity index 100% rename from crates/qbz/icons/tray-color-22.png rename to crates/qbz-qt/icons/tray-color-22.png diff --git a/crates/qbz/icons/tray-color-32.png b/crates/qbz-qt/icons/tray-color-32.png similarity index 100% rename from crates/qbz/icons/tray-color-32.png rename to crates/qbz-qt/icons/tray-color-32.png diff --git a/crates/qbz/icons/tray-color-44.png b/crates/qbz-qt/icons/tray-color-44.png similarity index 100% rename from crates/qbz/icons/tray-color-44.png rename to crates/qbz-qt/icons/tray-color-44.png diff --git a/crates/qbz/icons/tray-color-64.png b/crates/qbz-qt/icons/tray-color-64.png similarity index 100% rename from crates/qbz/icons/tray-color-64.png rename to crates/qbz-qt/icons/tray-color-64.png diff --git a/crates/qbz/icons/tray-dark-22.png b/crates/qbz-qt/icons/tray-dark-22.png similarity index 100% rename from crates/qbz/icons/tray-dark-22.png rename to crates/qbz-qt/icons/tray-dark-22.png diff --git a/crates/qbz/icons/tray-dark-32.png b/crates/qbz-qt/icons/tray-dark-32.png similarity index 100% rename from crates/qbz/icons/tray-dark-32.png rename to crates/qbz-qt/icons/tray-dark-32.png diff --git a/crates/qbz/icons/tray-dark-44.png b/crates/qbz-qt/icons/tray-dark-44.png similarity index 100% rename from crates/qbz/icons/tray-dark-44.png rename to crates/qbz-qt/icons/tray-dark-44.png diff --git a/crates/qbz/icons/tray-dark-64.png b/crates/qbz-qt/icons/tray-dark-64.png similarity index 100% rename from crates/qbz/icons/tray-dark-64.png rename to crates/qbz-qt/icons/tray-dark-64.png diff --git a/crates/qbz/icons/tray-light-22.png b/crates/qbz-qt/icons/tray-light-22.png similarity index 100% rename from crates/qbz/icons/tray-light-22.png rename to crates/qbz-qt/icons/tray-light-22.png diff --git a/crates/qbz/icons/tray-light-32.png b/crates/qbz-qt/icons/tray-light-32.png similarity index 100% rename from crates/qbz/icons/tray-light-32.png rename to crates/qbz-qt/icons/tray-light-32.png diff --git a/crates/qbz/icons/tray-light-44.png b/crates/qbz-qt/icons/tray-light-44.png similarity index 100% rename from crates/qbz/icons/tray-light-44.png rename to crates/qbz-qt/icons/tray-light-44.png diff --git a/crates/qbz/icons/tray-light-64.png b/crates/qbz-qt/icons/tray-light-64.png similarity index 100% rename from crates/qbz/icons/tray-light-64.png rename to crates/qbz-qt/icons/tray-light-64.png diff --git a/crates/qbz-qt/qml/FontPreload.qml b/crates/qbz-qt/qml/FontPreload.qml new file mode 100644 index 000000000..bb715d3fc --- /dev/null +++ b/crates/qbz-qt/qml/FontPreload.qml @@ -0,0 +1,56 @@ +// FontPreload — registers the SELECTED bundled typeface before any Text exists. +// +// WHY THIS FILE IS A SEPARATE DOCUMENT, loaded before Main.qml. +// +// A plain `Text` does NOT inherit a font from its parents, and it does NOT +// follow `ApplicationWindow.font` — that property propagates through Qt Quick +// CONTROLS only. Proven on this Qt build with a standalone scene: with a +// family set on the ApplicationWindow, a `Label` resolved to it and a `Text` +// sibling stayed on the APPLICATION font. This tree has 1082 plain `Text` +// items and 13 that name a family, so "the app's typeface" IS the application +// font, and nothing else. +// +// And the application font is only read when an item is CONSTRUCTED. Also +// proven, with PySide6 against this same Qt: calling QGuiApplication::setFont +// at runtime updated `QGuiApplication::font()` and left an already-built +// `Text` on its old family. So the font has to be set before the UI is built, +// which means the families have to be REGISTERED before that — and a family +// is only registered once something loads its file. +// +// Hence this document: `QQmlApplicationEngine::load` returns with its +// FontLoader already in the font database (verified: the family is present +// the instant load() returns), so main.rs can set the application font in the +// gap between this document and Main.qml. "System" leaves the Loader inactive: +// no empty URL is assigned and Qt keeps the operating-system default. +// +// It is deliberately NON-VISUAL — a QtObject, not a Window — so loading it +// adds a root object and nothing else. +// +// The four non-system faces are settings_qt::APP_FONT_VALUES[1..], in order. +// Noto Sans Devanagari is registered separately by font_qt as a script-only +// fallback; it is never an application-font selection. + +import QtQuick +import com.blitzfc.qbz + +QtObject { + id: root + readonly property string selectedSource: { + if (QbzShell.appFontFamily === "LINE Seed JP") + return "assets/fonts/LINESeedJP-Regular.ttf" + if (QbzShell.appFontFamily === "Montserrat") + return "assets/fonts/Montserrat-VariableFont_wght.ttf" + if (QbzShell.appFontFamily === "Noto Sans") + return "assets/fonts/NotoSans-VariableFont_wdth,wght.ttf" + if (QbzShell.appFontFamily === "Source Sans 3") + return "assets/fonts/SourceSans3-VariableFont_wght.ttf" + return "" + } + readonly property Component selectedComponent: Component { + FontLoader { source: root.selectedSource } + } + readonly property Loader selected: Loader { + active: root.selectedSource !== "" + sourceComponent: root.selectedComponent + } +} diff --git a/crates/qbz-qt/qml/LoginScreen.qml b/crates/qbz-qt/qml/LoginScreen.qml new file mode 100644 index 000000000..9c94e56a1 --- /dev/null +++ b/crates/qbz-qt/qml/LoginScreen.qml @@ -0,0 +1,454 @@ +// QBZ login screen — QML port of crates/qbz-ui/ui/login/LoginScreen.slint. +// +// Centered 720px dark card: logo, wordmark, ToS checkbox gating the +// primary button, system-browser sign-in (NO webview, NO email/password), +// phase narration, sign-in error box, offline-connectivity callout (with +// captive-portal line), session-restore error box, "Start offline" link, +// legal disclaimer. +// +// All user-visible strings go through QbzSession.tr() with the EXACT msgids +// of the Slint @tr() calls so the existing .po translations apply. +// State comes from the QbzBridge singleton (Slint's LoginState + +// OfflineState globals); actions call the bridge invokables. + +import QtQuick +import com.blitzfc.qbz +import "controls" +import "theme" + +Rectangle { + id: root + property bool kioskHost: false + color: theme.surfaceMain + // Square window corners (phase 12: opaque window; the compositor owns + // any rounding). + // Set by the Loader (custom chrome drag/maximize); unused here. + property var hostWindow: null + + // Slint: in-out property tos-accepted: true. + property bool tosAccepted: true + + QbzTheme { id: theme } + + Flickable { + id: loginScroll + anchors.fill: parent + contentWidth: width + contentHeight: root.kioskHost ? Math.max(height, card.height + 32) : height + interactive: root.kioskHost + clip: root.kioskHost + boundsBehavior: Flickable.StopAtBounds + Item { + width: loginScroll.width + height: loginScroll.contentHeight + // Faked drop shadow (blur 32, offset-y 8, #00000066): a translucent + // black rounded rect behind the card. + // SUPERSEDED (2026-07-29): the justification was "Qt5Compat DropShadow is + // not assumed to be installed on the target", of a piece with the retired + // "effects render nothing" doctrine. Both effect modules ARE installed here + // and this port runs on the GPU (OpenGL RHI, measured); the old note came + // from an offscreen session, which forces the software renderer by + // definition — theme/RoundedImage.qml now DETECTS the software path with + // `GraphicsInfo.api`. A real blurred shadow is a visual change owing its + // own parity pass, so the fake stays for now. + Rectangle { + anchors.horizontalCenter: card.horizontalCenter + y: card.y + 8 + width: card.width + height: card.height + radius: theme.radiusLg + color: theme.cardShadow + opacity: 0.5 + } + + Rectangle { + id: card + anchors.centerIn: parent + width: root.kioskHost ? Math.min(720, root.width - 32) : 720 + height: cardColumn.implicitHeight + 2 * theme.cardPadding + color: theme.surfaceCard + radius: theme.radiusLg + + Column { + id: cardColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: theme.cardPadding + spacing: 0 + + // --- Brand ------------------------------------------------ + Image { + anchors.horizontalCenter: parent.horizontalCenter + source: "assets/qbz-logo.png" + width: root.kioskHost ? 64 : 140 + height: root.kioskHost ? 64 : 140 + fillMode: Image.PreserveAspectFit + } + Item { width: 1; height: theme.spacingSm } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: QbzSession.tr("QBZ", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontWordmark + font.weight: theme.weightSemibold + font.letterSpacing: 8 + } + Item { width: 1; height: 2 } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: QbzSession.tr("QOBUZ™ PLAYER", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontSubtitle + font.letterSpacing: 4 + } + + Item { width: 1; height: root.kioskHost ? 12 : theme.spacingXl } + + // --- Terms of Service row -------------------------------- + Row { + spacing: theme.spacingSm + + // Minimal dark checkbox (Slint QbzCheckbox equivalent). + Rectangle { + id: tosCheckbox + width: root.kioskHost ? 44 : 18 + height: root.kioskHost ? 44 : 18 + anchors.verticalCenter: parent.verticalCenter + radius: 4 + color: root.tosAccepted ? theme.accent : "transparent" + border.color: theme.textMuted + border.width: root.tosAccepted ? 0 : 1.5 + + QbzIcon { + anchors.centerIn: parent + visible: root.tosAccepted + name: "check" + width: 12 + height: 12 + // On the accent fill -> the on-accent selector, same + // as the real controls/QbzCheckbox.qml this row + // hand-copies (primitives/QbzCheckbox.slint:24 says + // accent-text, and the selector returns accent-text + // on 34 of the 35 palettes; it only overrides where + // accent-text drops under 3:1 — rose-pine-dawn, + // 2.56:1). The hand-copy has to track the real + // control or the TOS check and the Settings checks + // diverge on exactly that theme. + tintName: theme.accentGlyphTint + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.tosAccepted = !root.tosAccepted + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("I have read the", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontBody + } + Text { + id: tosLink + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Qobuz™ Terms of Service", QbzSession.trRev) + color: theme.accent + font.pixelSize: theme.fontBody + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: QbzSession.openTos() + } + } + } + + Item { width: 1; height: root.kioskHost ? 12 : theme.spacingLg } + + // --- Sign in --------------------------------------------- + // Opens the user's default web browser (no embedded webview). + QbzPrimaryButton { + id: signInButton + btnHeight: root.kioskHost ? 64 : 48 + width: parent.width + property bool canSignIn: root.tosAccepted && QbzSession.loginPhase === 0 + label: QbzSession.tr("Sign in with your browser", QbzSession.trRev) + btnEnabled: signInButton.canSignIn + onClicked: QbzSession.signInViaBrowser() + } + + Item { width: 1; height: theme.spacingMd } + + // --- Browser-flow status --------------------------------- + // The browser may open in the background without stealing + // focus, and the code exchange takes a few seconds — the + // screen must narrate instead of sitting inert. + Column { + visible: QbzSession.loginPhase === 1 + width: parent.width + spacing: 4 + Text { + width: parent.width + text: QbzSession.tr("Continue in your web browser — QBZ is waiting for you to finish signing in.", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontBody + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + Text { + width: parent.width + text: QbzSession.tr("The browser may open in the background without taking focus — check your other windows.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + Item { width: 1; height: 4 } + Item { + anchors.horizontalCenter: parent.horizontalCenter + width: cancelText.implicitWidth + height: cancelText.implicitHeight + 3 + Text { + id: cancelText + text: QbzSession.tr("Cancel", QbzSession.trRev) + color: cancelArea.containsMouse ? theme.accent : theme.textMuted + font.pixelSize: theme.fontLink + } + Rectangle { + y: cancelText.implicitHeight + 1 + width: cancelText.implicitWidth + height: 1 + color: cancelArea.containsMouse ? theme.accent : theme.textMuted + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzSession.cancelLogin() + } + } + } + Text { + visible: QbzSession.loginPhase === 2 + width: parent.width + text: QbzSession.tr("Signing you in…", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontBody + horizontalAlignment: Text.AlignHCenter + } + // Sign-in failure box (phase 0 only). + Rectangle { + visible: QbzSession.loginPhase === 0 && QbzSession.loginError !== "" + width: parent.width + height: signInErrorColumn.implicitHeight + 24 + color: theme.surfaceElevated + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + Column { + id: signInErrorColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 4 + Text { + width: parent.width + text: QbzSession.tr("Sign-in failed", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + } + Text { + width: parent.width + text: QbzSession.loginError + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + } + Item { width: 1; height: 10 } + + // --- Offline-boot callout (spec §4.1) -------------------- + // Points at the Start-offline link right below. TWO audiences, and + // until 2026-08-22 it served only one of them: + // + // returning user, no network — the case it was written for. + // NEVER HAD AN ACCOUNT — the case it was invisible for. + // + // The old gate was `hasPreviousSession && connectivity === 2`, and + // `hasPreviousSession` is seeded from `load_last_user_id()` — None + // on a fresh profile, and only ever set true by a successful Qobuz + // login. So the one piece of UI that points at "you can use this + // without an account" was structurally unreachable for exactly the + // person who needs it. Verified by driving a fresh profile: the + // capability works, the signpost never appears. + Column { + visible: QbzSession.connectivity === 2 || !QbzSession.hasPreviousSession + width: parent.width + spacing: 2 + Rectangle { + width: parent.width + height: offlineColumn.implicitHeight + 24 + color: theme.surfaceElevated + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + Column { + id: offlineColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 4 + Text { + width: parent.width + // Say the RIGHT thing to each audience. Telling a + // first-run user with working internet that they + // have "no internet connection" is simply false, + // and it is what the screen used to imply the + // moment this box became visible to them. + text: QbzSession.connectivity === 2 + ? QbzSession.tr("No internet connection — you can start in offline mode with your local library and downloads", QbzSession.trRev) + : QbzSession.tr("No Qobuz account? You can still use QBZ as a player for your own music library", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontBody + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + Text { + // ALSO gated on connectivity: a captive-portal line + // under a "No Qobuz account?" heading would be a + // non-sequitur for a first-run user who is online. + visible: QbzSession.captivePortal && QbzSession.connectivity === 2 + width: parent.width + text: QbzSession.tr("A network sign-in page may be blocking the connection", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + } + // Caret pointing at the Start-offline link below. + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "▼" + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + + // Init-error variant of the same box (session restore failed + // for a non-connectivity reason). Hidden while the + // connectivity box shows — never two boxes at once. + Column { + visible: QbzSession.restoreError !== "" && QbzSession.connectivity !== 2 + width: parent.width + spacing: 0 + Rectangle { + width: parent.width + height: restoreColumn.implicitHeight + 24 + color: theme.surfaceElevated + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + Column { + id: restoreColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 4 + Text { + width: parent.width + text: QbzSession.tr("Could not restore your session", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + } + Text { + width: parent.width + text: QbzSession.restoreError + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + } + Item { width: 1; height: theme.spacingSm } + } + + // Always visible (#553): without a previous session this + // opens the GUEST profile (user 0) — Local Library only, + // adopted by the account on the first real login. + Item { + id: offlineButton + anchors.horizontalCenter: parent.horizontalCenter + width: offlineText.implicitWidth + height: root.kioskHost ? 64 : offlineText.implicitHeight + 3 + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: offlineText.text + Accessible.onPressAction: QbzSession.startOffline() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Return + || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space)) { + QbzSession.startOffline() + event.accepted = true + } + } + Text { + id: offlineText + text: QbzSession.tr("Start offline (no access to Qobuz™ services)", QbzSession.trRev) + color: offlineArea.containsMouse || offlineButton.activeFocus + ? theme.accent : theme.textMuted + font.pixelSize: theme.fontLink + } + Rectangle { + y: offlineText.implicitHeight + 1 + width: offlineText.implicitWidth + height: 1 + color: offlineArea.containsMouse || offlineButton.activeFocus + ? theme.accent : theme.textMuted + } + MouseArea { + id: offlineArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: offlineButton.forceActiveFocus() + onClicked: QbzSession.startOffline() + } + } + + Item { width: 1; height: root.kioskHost ? 12 : theme.spacingXl } + + // --- Legal disclaimer ------------------------------------ + Text { + width: parent.width + text: QbzSession.tr("QBZ requires a Qobuz™ account. Without an active subscription, playback is limited to 30-second previews; favorites, playlists and purchases still work. Your credentials are sent directly to Qobuz™.", QbzSession.trRev) + + "\n" + + QbzSession.tr("QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library).", QbzSession.trRev) + + "\n" + + QbzSession.tr("This application uses the Qobuz API but is not certified by Qobuz.", QbzSession.trRev) + + " " + + QbzSession.tr("Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml new file mode 100644 index 000000000..084251252 --- /dev/null +++ b/crates/qbz-qt/qml/Main.qml @@ -0,0 +1,1273 @@ +// Root window: dark background + a Loader keyed on the bridge's `screen` +// property ("splash" | "login" | "shell"). Kicks off the Rust boot +// sequence once the QML tree is complete (the bridge registers its Qt +// thread handle in that first invokable, so every async UI hop lands). +// +// Font: main.rs applies an explicitly selected bundled family before this +// document is built. With "System" it applies nothing, so both plain Text and +// Qt Quick Controls inherit the operating-system font chosen by Qt. + +import QtQuick +import QtQuick.Controls +import QtQuick.Window +import com.blitzfc.qbz +import "controls" +import "miniplayer" +import "theme" + +ApplicationWindow { + id: window + // Geometry is RESTORED, not hardcoded (crates/qbz/src/main.rs:8211-8282 is + // the Slint restore; :1399-1435 is its save). The bridge seeds these at + // CONSTRUCTION from the shared ui_prefs.json, so the very first frame is + // already the user's size — a post-boot push would arrive after layout. + // `windowWidth`/`windowHeight` already carry the never-saved fallback + // (1180x760: app.slint's preferred size), so there is no local default to + // disagree with Slint about. + // + // This was the title-truncation bug: at the old hardcoded 1280 the + // now-playing bar sat one responsive tier below the Slint build on the same + // machine (1280 < 1366 -> side fraction 0.39 instead of 0.30), so the + // Classic song card received ~446px instead of the larger mid-tier runway + // and elided titles early. The tier thresholds were right; the window was + // the wrong size. The card now also measures its live neighbouring controls, + // so restored geometry and in-bar allocation solve different parts of the + // same problem rather than relying on a fixed card cap. + // + // These stay plain bindings so the size is correct before the window maps. + // The WM's own resizes write `width`/`height` from C++ — that does not + // disturb the binding, and nothing ever re-sets the bridge properties, so + // the value never snaps back under the user. + // + // Slint's best-effort monitor clamp (main.rs:8242-8249) is folded INTO the + // binding rather than applied afterwards: a size saved on the big display + // must not strand the window on a smaller one, and the clamped number has + // to be the MAPPED number. Done imperatively in Component.onCompleted (as + // it was) it ran after componentComplete had already shown the window, so + // the window flashed at the oversized geometry and then snapped — and the + // JS assignment broke the binding and fired the save debounce, which is how + // a 2560px-wide session got permanently rewritten to 1920 after one launch + // on the laptop panel (for the Slint build too — same file). + // SHRINK ONLY, and only while the screen itself clears the app floor: + // Slint skips the clamp on exactly that condition instead of fighting the + // WM over an impossible minimum. A screen we cannot resolve degrades to + // "no clamp", like Slint's monitor query returning None. + width: window.bootScreenWidth >= window.minimumWidth + ? Math.min(QbzShell.windowWidth, window.bootScreenWidth) + : QbzShell.windowWidth + height: window.bootScreenHeight >= window.minimumHeight + ? Math.min(QbzShell.windowHeight, window.bootScreenHeight) + : QbzShell.windowHeight + // Screen metrics for that clamp, read from `window.screen` (the window's + // OWN screen; the attached `Screen` wants an Item). Frozen in + // Component.onCompleted — see the freeze there for why they must stop + // tracking. + property real bootScreenWidth: window.screen ? window.screen.width : 0 + property real bootScreenHeight: window.screen ? window.screen.height : 0 + // app.slint:52-53 (`min-width: 940px / UiScale.factor`), carried through the + // bridge. The old 800x600 let the window go below Slint's floor, which is + // exactly where the responsive tiers stop being comparable. + minimumWidth: QbzShell.kioskProfile ? 800 : QbzShell.windowMinWidth + minimumHeight: QbzShell.kioskProfile ? 480 : QbzShell.windowMinHeight + visible: true + // Last session's maximized state, applied DECLARATIVELY so it is part of + // the first mapped frame: QQuickWindowQmlImpl defers the show until + // componentComplete and then honours whatever `visibility` holds, whereas + // the old Component.onCompleted assignment landed AFTER the map and the + // window visibly jumped from the floating size to maximized. Slint restores + // before `window.run()` and never flashes (main.rs:8273-8281). + // One-shot by construction: `windowMaximized` is seeded at bridge + // construction and never rewritten, so this never re-evaluates, and + // HeaderBar's imperative `hostWindow.visibility = ...` + // (shell/HeaderBar.qml:76) removes the binding outright the first time the + // user toggles — which is the wanted behaviour, not a leak. + // Windowed (not AutomaticVisibility) on the false branch: a fresh window + // must land in its natural state, matching "only ever re-apply a true". + // The kiosk appliance arm comes FIRST and is one-shot by construction: + // `kioskFullscreenBoot` is seeded at bridge construction from + // `QBZ_PROFILE=kiosk` AND `QBZ_KIOSK_FULLSCREEN`, and nothing ever writes + // it, so this can never re-evaluate and trap a desktop session + // (incident 2026-07-11: the kiosk shell has no titlebar control and + // neither Esc nor F11 leave fullscreen). Declarative for the same reason + // the maximized arm is: QQuickWindowQmlImpl defers the show until + // componentComplete, so this is part of the first mapped frame instead of + // a visible jump afterwards. + visibility: QbzShell.kioskFullscreenBoot + ? Window.FullScreen + : (QbzShell.windowMaximized ? Window.Maximized : Window.Windowed) + // "Show track in window title" (Appearance). 1:1 with app.slint:44 — + // FIXED format, no template, and it falls back to the plain app name + // whenever the setting is off or nothing is loaded. Reactive: the + // binding re-evaluates on every track change and on the toggle itself + // (settings_qt pushes `windowTitleShow` live). + title: QbzShell.windowTitleShow && QbzPlayer.npHasTrack + ? QbzPlayer.npTitle + " - " + QbzPlayer.npArtist + " | qbz" + : "QBZ" + // Custom chrome (phase 7/12): frameless but OPAQUE — the phase-7 + // translucent window was a misread: the Slint MAIN window keeps an + // OPAQUE swapchain (only the miniplayer blends; crates/qbz/src/main.rs + // set_surface_prefers_transparent + the Cargo.toml patch comment), and + // the rounded corners in the Slint screenshots come from the + // COMPOSITOR, not the app — app.slint's root is opaque surface-main + // with square corners and a square 1px hairline frame. The system + // titlebar is the `use_system_title_bar` pref (ui_prefs.json; applied + // at startup, restart semantics like Slint). + // Three different windows, one per platform/mode: + // + // system title bar → a plain Qt.Window, the OS decorates it. + // macOS custom → Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint. + // Linux/Windows → frameless, and we draw the cluster ourselves. + // + // The macOS arm is FIRST-CLASS QT API (6.9+, and both machines run + // 6.11.1), not a hand-rolled AppKit poke: `ExpandedClientAreaHint` + // expands the client area into the region the titlebar controls occupy, + // `NoTitleBarBackgroundHint` drops the titlebar's background — and the + // native traffic lights STAY. Content then reaches y=0 and the QBZ header + // sits under them. + // + // The previous attempt set `titlebarAppearsTransparent` + + // `titleVisibility` + `NSWindowStyleMaskFullSizeContentView` on the + // NSWindow from Rust, and could not win: `QCocoaWindow::windowStyleMask()` + // RECOMPUTES the mask from Qt's own flags and reassigns it on + // `setWindowFlags()`, on fullscreen enter/exit, and via `setWindowState()` + // — preserving only the fullscreen and unified-toolbar bits, never + // FullSizeContentView. The `visibility` binding below is itself a + // `setWindowState()`, so the bit was wiped moments after being set. That + // is why the log kept saying "traffic lights centred" while the owner kept + // seeing a stock title bar. (Qt forum "Unable to get transparent title bar + // in macOS since Qt 6.4", QTBUG-134797.) + // + // Frameless is NOT an option on macOS: it removes the traffic lights with + // the frame, and the reference never draws its own there + // ("Linux only — macOS keeps the native traffic lights", + // WindowControls.slint:1-2). + // WINDOWS takes the macOS shape, not the Linux one, and the difference is + // the whole feature (W27). + // + // `Qt.FramelessWindowHint` removes WS_THICKFRAME, and THAT is what the + // shell keys tiling on: Win+Arrow and Aero Snap by dragging to an edge both + // stop, along with the DWM drop shadow and the Win11 rounded corners. So + // "hide title bar" was not making the app refuse the shortcuts -- it had + // stopped being a window the shell could tile. + // + // NOT restored, and it cannot be from flags alone: the Windows 11 Snap + // Layouts flyout that appears when hovering a maximise button. Windows asks + // for it by hit-testing that pixel as HTMAXBUTTON, and QBZ's maximise + // button is a QML item inside the client area, so it answers HTCLIENT. + // Wiring it would mean reporting HTMAXBUTTON over that one rect from the + // native filter -- deliberate future work, not something this arm does. + // + // Qt 6.9's Windows plugin already implements WM_NCCALCSIZE -> 0, + // DwmExtendFrameIntoClientArea, the eight resize edges and + // safeAreaMargins() for `ExpandedClientAreaHint` -- the same flags the + // macOS arm uses. `CustomizeWindowHint` with no button hints then + // suppresses Qt's own painted title cluster + // (qwindowswindow.cpp :3561/:3600/:3624/:3645) while the window KEEPS + // WS_CAPTION, so the shell still treats it as tileable. + // + // It also provides no HTCAPTION drag band of its own (:3332-3338: with + // both isDefaultTitleBar and isCustomized false the whole title block is + // skipped), which is why HeaderBar's QML `startSystemMove()` stays: it + // issues SC_MOVE, and SC_MOVE drags DO snap. The eight QML resize grips + // stay enabled too and do not fight the native edges -- Qt answers + // WM_NCHITTEST first, so a press on the border never reaches QML. + flags: QbzShell.systemTitleBar + ? Qt.Window + : (QbzShell.isMacos + ? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint) + : (QbzShell.isWindows + // Both halves were MEASURED on the box, and neither is + // optional: + // + // WITHOUT CustomizeWindowHint, Qt populates the default title + // hints and its customized-title path DRAWS minimise/maximise/ + // close over the QBZ header. + // + // WITH it, Qt answers WM_NCHITTEST with HTNOWHERE for every + // pixel -- centre, header, player bar and sidebar all returned + // 0 -- and Windows sends no mouse input to HTNOWHERE, so the + // whole app becomes unclickable while still running. + // + // No public flag combination gives no-buttons + clicks + + // the WS_THICKFRAME that tiling needs, so the hint stays and + // `win_shell::install_hittest_filter()` answers the hit test + // instead. What this arm must NOT do is drop the hint: that + // trades the buttons back in. + // + // Frameless is not the answer either: it removes + // WS_THICKFRAME, and THAT is what Win+Arrow and Aero Snap + // actually key on -- not WS_CAPTION, which this window does + // not have in either arrangement. + ? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint + | Qt.CustomizeWindowHint) + : (Qt.Window | Qt.FramelessWindowHint))) + + // THE OTHER HALF OF ExpandedClientAreaHint, and the piece that made the + // flag look broken when it was not. + // + // Since 6.9 "ApplicationWindow will automatically add padding to the + // contentItem for any safe area margins reported by the window", so that + // "the contentItem stays inside the safe area of the window, while the + // background item covers the entire window" (Qt Quick Controls docs). + // + // On macOS with the expanded client area that safe area IS the titlebar + // band. So the window background dutifully covered the whole frame while + // `screenLoader` — anchored to the contentItem — was pushed ~28pt down. + // The result reads on screen as a stock title bar with the QBZ header + // below it, which is precisely what the owner kept reporting while the + // AppKit side measured perfect (styleMask 0x800f, titlebar transparent, + // contentH == windowH == 1328). + // + // QBZ draws its own chrome edge to edge on every platform — the header at + // the top, the player bar at the bottom, the sidebar at the left — so it + // takes the safe area over from Qt entirely. `HeaderBar.chromeLeftInset` + // is where that responsibility is discharged: it reads + // `SafeArea.margins.left` to clear the traffic lights. + // The four Window paddings are Qt 6.9 API (SafeArea landed in 6.9). The + // Linux pin is Qt 6.8 LTS, where declaring them here refuses the whole + // component ("Cannot assign to non-existent property") — measured on the + // CI VM, 2026-08-25. They matter on BOTH ExpandedClientAreaHint arms: + // macOS AND Windows (the Windows arm arrived later, in 415cba57d, and + // this gate was not widened with it — the result was the caption-height + // safe-area band auto-padding the contentItem down, painted with the + // window's #1a1a1a background: a dark grey strip above the QBZ header, + // owner-reported 2026-09-01). Set imperatively from a Loader that never + // instantiates on Linux/6.8. + Loader { + active: QbzShell.isMacos || QbzShell.isWindows + sourceComponent: Component { + Item { + Component.onCompleted: { + window.topPadding = 0 + window.bottomPadding = 0 + window.leftPadding = 0 + window.rightPadding = 0 + } + } + } + } + color: "#1a1a1a" + + // Phase 23: every domain singleton boots (registers its Qt-thread + // hop; QbzSession.boot additionally fires the app boot sequence). + // ── The renderer probe ──────────────────────────────────────────────── + // The ONLY place that answers "what is actually drawing". QRhi decides it + // when the scene graph initialises, so `main::apply_renderer_preference()` + // — which runs before QGuiApplication — knows only what we ASKED for; a + // driver that refuses the request lands us somewhere else entirely. + // + // The enum is mapped to a NAME here, so Rust never depends on + // GraphicsInfo's numeric values (a Qt implementation detail). Rust ignores + // "unknown", the value reported before the graph resolves, so the report + // fires again for free once it does. + // + // This is the feature gate (shader scenes / dynamic background / + // reduce-motion), NOT the per-item drawing check: items that need to pick + // a draw path keep reading their own `GraphicsInfo.api`, which is per + // window and needs no round trip. See src/renderer_qt.rs. + Item { + id: rendererProbe + width: 0 + height: 0 + readonly property int api: GraphicsInfo.api + function report() { + var name = "unknown" + switch (rendererProbe.api) { + case GraphicsInfo.Software: name = "software"; break + case GraphicsInfo.OpenGL: name = "opengl"; break + case GraphicsInfo.Direct3D11: name = "d3d11"; break + case GraphicsInfo.Direct3D12: name = "d3d12"; break + case GraphicsInfo.Vulkan: name = "vulkan"; break + case GraphicsInfo.Metal: name = "metal"; break + case GraphicsInfo.Null: name = "null"; break + } + QbzShell.reportRendererApi(name) + } + onApiChanged: report() + Component.onCompleted: report() + } + + // ── The frame-liveness watchdog ─────────────────────────────────────── + // Disarms the startup sentinel that `apply_renderer_preference()` armed + // for a forced renderer. The proof it waits for is FRAMES, not a window: a + // backend can create a window and then never present, which from the + // outside is indistinguishable from a healthy start until the user is + // looking at a frozen pane — and that is exactly the state the sentinel + // must NOT accept as success. + // + // 10 s rather than the first swap: a renderer that dies two seconds in + // would otherwise have already been declared healthy, and the next launch + // would keep re-forcing it. The reference waits for real user input with a + // 30 s no-touch fallback (`disarm_renderer_sentinel_on_liveness`); frames + // are the same idea with a signal Qt gives us directly. + property int _framesSwapped: 0 + onFrameSwapped: window._framesSwapped++ + Timer { + interval: 10000 + repeat: false + running: true + onTriggered: QbzShell.reportFrameLiveness(window._framesSwapped) + } + + Component.onCompleted: { + // DIAGNOSTIC (macOS chrome): the Rust side reports what AppKit ended + // up with; this reports what QML asked for. Together they pin the + // failure to either "the request was wrong" or "Qt ignored it". + if (QbzShell.isMacos) { + console.log("[macos-chrome] QML flags=" + window.flags + + " systemTitleBar=" + QbzShell.systemTitleBar + + " expected=" + (Qt.Window | Qt.ExpandedClientAreaHint + | Qt.NoTitleBarBackgroundHint) + // What Qt reports as unsafe, so we know whether + // HeaderBar.chromeLeftInset is using Qt's own number or + // falling back to the reference's 78px floor. + + " safeArea(t/l/r/b)=" + window.SafeArea.margins.top + + "/" + window.SafeArea.margins.left + + "/" + window.SafeArea.margins.right + + "/" + window.SafeArea.margins.bottom) + } + QbzSession.boot() + // Qobuz Connect (2026-08-01 contract §2/§8). Booted right after + // QbzSession because QbzSession.boot fires the whole app boot + // sequence, whose shell entry runs the §11.5 service wiring — the + // badge/devices publishes it makes must find the Qt-thread hop + // already registered or they are dropped silently. + QbzQConnect.boot() + // Hotkeys layer (2026-08-03 hotkeys-port contract, block B1): boot + // after QbzSession like every domain singleton. The B2 dispatcher + // (AppShell-root Keys.onPressed) is the only other QML side. + QbzHotkeys.boot() + QbzLink.boot() + // Search (2026-08-03 cortinilla-parity contract, commit C0): the + // domain extracted from QbzBridge. Its position among the other + // domain singletons is not load-bearing — every boot() here runs + // before the first key or click — but the LINE is: boot() is what + // registers the Qt-thread hop, and without it `search_bridge::ui()` + // is a silent no-op, so the dropdown would never repaint and + // nothing would be logged (TRACK-RULES, singleton boot order). + QbzSearch.boot() + QbzShell.boot() + QbzPlayer.boot() + QbzQueue.boot() + QbzHome.boot() + QbzViz.boot() + // Immersive mode (2026-08-02 immersive-port contract, block B1). + // Booted right after QbzViz: the overlay's open funnel drives the + // viz two-source enable (§4.2), and ambient_qt publishes glow_color/ + // atmosphere_url to this singleton on every track change — a missing + // boot line would drop those publishes silently (see above). + QbzImmersive.boot() + // Shader scenes (2026-08-15 immersive-completion contract, block A1). + // Booted right after QbzImmersive: its open funnel resets the scene + // through this singleton's hop, and the viz drain publishes the audio + // pack here on every tick while immersive is open — a missing boot + // line makes both silent no-ops (TRACK-RULES, singleton boot order). + QbzShaderScene.boot() + // Immersive Suggestions (the same contract, block B4 §4.5) — booted + // WITH its bridge: every publish the suggestions loader makes rides + // this hop, so a missing line is the forever-"{}" silent no-op the + // comment below warns about (trap 2). + QbzSuggestions.boot() + QbzLocal.boot() + QbzLibrary.boot() + QbzAlbum.boot() + QbzArtist.boot() + QbzScene.boot() + QbzMusician.boot() + QbzLyrics.boot() + QbzCast.boot() + QbzBridge.boot() + // MyQBZ (two grids + detail + modals), the app-wide Add picker, the + // Artist-Collection builder, and the Blacklist manager. + // + // boot() is what registers each singleton's CxxQtThread hop, so a + // missing line here is a SILENT no-op forever: the view mounts, every + // `ui()` publish from Rust is dropped on the floor, the document stays + // at its "{}" default and NOTHING is logged on either side. QbzBlacklist + // is the loudest case — its `blacklistLoading` defaults to true, so the + // manager would spin forever. + QbzMyQbz.boot() + QbzMyQbzAdd.boot() + QbzDisco.boot() + QbzBlacklist.boot() + QbzPlaylistPicker.boot() + // The folder modals. Its boot() also publishes both closed documents, + // which is what puts the icon-preset and colour-swatch constants on + // the QML side BEFORE the first open rather than one turn after it. + QbzFolderEdit.boot() + // The SHARED playlist editor (rename · description · offline-only · + // delete), opened from the manager's three delegates, the sidebar row + // menu and the playlist detail header's pencil. Its boot() publishes + // the closed document so the modal's parse sees the full shape. + QbzPlaylistEdit.boot() + // Playlist Manager. Its boot() also seeds the default document and + // kicks the cache-independent folder read, so the SIDEBAR's folder + // consumers have a list before the manager view has ever been opened. + QbzPlaylistManager.boot() + // Playlist Importer. Nothing to seed — the modal is closed until a + // sidebar row opens it — but WITHOUT this line every publish the + // importer makes (the whole progress panel, the log, the summary) is + // dropped on the floor with nothing logged on either side. + QbzPlaylistImport.boot() + // "Find available version" (2026-08-17 unavailable-tracks contract §6). + // Nothing to seed — the modal is closed until a playlist row's context + // menu opens it — but without this line every publish the controller + // makes is dropped on the floor: the modal would open empty and keep + // spinning, since its `loading` flag is only ever taken down by a + // publish. + QbzTrackReplace.boot() + // The disc-metadata modal. Same story: closed until the pane's search + // button opens it, and without this line the search would spin + // forever, because only a publish takes `searching` back down. + QbzDiscMeta.boot() + // Local album metadata editor. Its controller publishes from worker + // threads and the app-wide modal survives the album republish on save. + QbzTagEditor.boot() + // HiFi Wizard. Nothing to seed — the modal is closed until the + // Settings > Audio row opens it — but without this line every publish + // the wizard makes is dropped on the floor: the health verdict, the + // enumerated DACs, the generated config and the live read-back would + // all stay at their defaults, and nothing would be logged on either + // side. The wizard is the loudest case after QbzBlacklist, because its + // first step ends on "Checking your audio stack…" forever. + // Linux only: the wizard's row is hidden elsewhere (AudioSettings.qml), + // so booting it would spin up an audio-stack probe nothing can reach. + if (QbzShell.isLinux) + QbzDacWizard.boot() + // Offline Cache Manager. Nothing to seed — the view is unmounted until + // the Settings row opens it — but without this line the manager's very + // first publish is dropped and the view sits on its spinner forever: + // `managerLoading` DEFAULTS to true precisely so the empty state does + // not flash, which turns a missing boot() into a permanent spinner + // with nothing logged on either side. + QbzOffline.boot() + // Miniplayer (2026-08-03 miniplayer/tray contract, block B1). Booted + // like every other domain singleton, after QbzSession: `boot()` is what + // registers the Qt-thread hop, and without it `mini_bridge::ui()` is a + // SILENT no-op — the mini queue document would never reach QML and + // nothing would be logged on either side. + QbzMini.boot() + // Same reason, and it bites harder here: the tray's clicks reach QML + // ONLY through this bridge's signals, so an unbooted QbzTray is a tray + // whose every click does nothing, with zero evidence in the log + // (contract §15 trap 27). The Connections that answer those four + // signals are below, beside the window verbs they call. + QbzTray.boot() + // About / What's New. Its four invokables self-register the hop, so + // today's menu-driven paths work without this line — but the FIRST + // publish that is not preceded by an invokable (an avatar prefetch at + // startup, an auto-show, a "new version" badge) would be a silent + // no-op. Booting it here keeps it in the same contract as the other 34. + QbzAbout.boot() + // Purchases. Unlike QbzAbout above, this one has NO self-registering + // invokable to fall back on: `purchases_bridge::ui()` is a silent no-op + // until the Qt-thread hop exists, so without this line every publish is + // dropped and both purchase screens stay permanently empty with nothing + // logged. Position is free (only QbzSession and QbzQConnect are ordered). + QbzPurchases.boot() + + // Seed the maximized latch ONCE, imperatively — see its declaration for + // why it must not be a binding on the bridge property. + window.maximizedLatch = QbzShell.windowMaximized + + // Freeze the clamp latch. The width/height bindings above read these, + // and `window.screen` CHANGES when the user drags the window to another + // monitor — a live dependency would re-run those bindings and snap the + // window back to the restored size under the user's hands (the bindings + // survive WM resizes, which come from C++ and do not clear them). + // Assigning a property from imperative JS removes its binding, so + // after these two lines the latch is a dead number and the geometry + // bindings can never re-evaluate. Via locals, not self-assignment, so + // it cannot be mistaken for a no-op. + var latchedW = window.bootScreenWidth + var latchedH = window.bootScreenHeight + window.bootScreenWidth = latchedW + window.bootScreenHeight = latchedH + } + + // --- macOS chrome ---------------------------------------------------- + // The overlay attributes + centring the native traffic lights against the + // 42px header. NOT in Component.onCompleted: it reaches the NSWindow + // through `-[NSApplication mainWindow]`, which is nil until AppKit has + // actually shown and keyed the window, and completion runs before that. + // The first rendered frame is the earliest point where it is guaranteed. + // + // RETRIED UNTIL IT REPORTS SUCCESS. The first version latched "applied" + // before calling, on `afterRendering`. That looked safe — the Rust side is + // idempotent, so latching only avoided per-frame work — but AppKit has no + // `mainWindow` yet at the first rendered frame, so the one attempt failed, + // the latch stuck, and the chrome never applied. On the owner's Mac the + // whole thing degraded to "stock title bar, plus a 78px inset reserved for + // traffic lights that were in another bar", and the log said it exactly + // once: `[macos-chrome] no main window yet`. + // + // A Timer rather than `afterRendering`: this must keep trying for a + // moment, and the frame signal would run the AppKit maths at the display + // rate to do it (the "changes less often than the frame rate" mistake this + // port already paid for in VizSettle). ~5 s of 200 ms attempts is far more + // than a window needs to become key, and it stops on the first success. + Timer { + id: macChromeTimer + interval: 200 + repeat: true + running: QbzShell.isMacos + property int attempts: 0 + onTriggered: { + attempts++ + if (QbzShell.applyMacChrome()) { + running = false + // NO geometry nudge here. An earlier version did a 1px + // height round trip to force Qt to re-derive its content + // rect — obsolete now that the expanded client area comes + // from Qt's own flags, and actively wrong: `height` is a + // BINDING (the restore clamp above), and assigning it from + // imperative JS destroys that binding. This file warns about + // exactly that trap a few lines further down, about the + // maximized latch. + } else if (attempts >= 25) { + running = false + console.warn("[macos-chrome] gave up after " + attempts + " attempts") + } + } + } + + // --- Geometry persistence ------------------------------------------- + // Slint saves from the winit Resized/Moved handlers (main.rs:1399-1454) and + // relies on a change guard to survive the no-op events a WM emits. QML has + // no equivalent of "the WM settled", so the debounce stands in for it: a + // drag fires widthChanged on every frame and only the last one is worth a + // file write. The floating-only rule, the app minimum and the >0.5px dirty + // check all live Rust-side in settings_qt::save_window_geometry, so every + // call site here can stay a single unconditional line. + // + // What CANNOT be left to Rust is the maximized flag itself. Qt's + // `visibility` is ONE enum in which Minimized REPLACES Maximized, while + // winit — what Slint reads through `is_maximized()` (main.rs:1404-1408) — + // keeps the two orthogonal and still reports maximized while iconified. + // Deriving the flag from the live visibility therefore persisted + // `window_maximized: false` on every minimize, and since the restore only + // ever re-applies a true (main.rs:8273-8281) BOTH frontends then opened + // un-maximized. So the flag is LATCHED from the last non-transient + // visibility, and the transient states persist nothing at all. + // Seeded imperatively in Component.onCompleted, NOT bound to + // QbzShell.windowMaximized. cxx-qt generates a NOTIFY for every + // #[qproperty], so a binding here would be a live reactive edge into the + // latch: the day anything pushes window_maximized back to QML (syncing the + // header icon after a WM-side maximize is the obvious future reason), the + // binding re-fires on any session where the user has not yet toggled + // maximize and quietly resets the latch to the BOOT value — re-creating + // exactly the bug below, with no grep signature. Nothing sets it today + // (`grep set_window_maximized src/` is empty), which is precisely why the + // invariant belongs here as one local assignment instead of as a + // cross-file contract nobody will remember. + property bool maximizedLatch: false + property bool fullScreenLatch: false + // A FUNCTION, not a derived property, and that is load-bearing. A + // `readonly property` computed from `visibility` is dirtied by the very + // notify signal `onVisibilityChanged` is connected to, and the handler + // connection runs FIRST, so a read of that property INSIDE the handler is + // one transition stale. Measured under Qt 6.11 (offscreen, real + // transitions): the minimize was seen as "not transient" — clearing the + // latch, the one thing the transient guard exists to prevent — and the + // following restore as "transient", so the handler returned and never put + // it back. A maximized session then closed as `window_maximized: false` + // with the 2556x1436 maximized footprint written into the FLOATING + // window_width/height (the Rust size gate lets it through precisely + // because neither flag is set) — Slint #618, the regression + // settings_qt::save_window_geometry's doc comment says must never happen. + // A function has no cached value that can be stale: it computes from the + // enum it is handed, at call time. + function transientVisibility(vis) { + return vis === Window.Minimized || vis === Window.Hidden + } + + // Last size seen in a REAL windowed frame. The shutdown paths need it + // because a close from the taskbar happens while MINIMIZED: `window.width` + // is then whatever the WM left behind, and the debounce may still be + // holding an unsaved drag that has nowhere left to be deferred to. Only + // captured once armed, so the boot clamp can never enter it. Zero means + // "never saw one this session" — below the app floor, so Rust drops the + // size and still records the flags, which is the honest outcome. + property real lastFloatingWidth: 0 + property real lastFloatingHeight: 0 + function captureFloatingSize() { + if (!window.geometryArmed || window.visibility !== Window.Windowed) + return + // The enum alone is not enough. An un-maximize delivers the state + // change and the geometry change in whichever order the platform + // plugin happens to emit them (xdg_toplevel configure carries both), so + // on a state-first compositor this runs with `visibility` already + // Windowed while `width`/`height` are still the MAXIMIZED footprint — + // and the cache would then hand the shutdown path a screen-sized + // "floating" size, which Rust accepts because neither flag is set. + // Reject any frame that is still the full screen; the real floating + // frame arrives a moment later through onWidthChanged. + var scr = window.screen + if (scr && window.width >= scr.width - 1 && window.height >= scr.height - 1) + return + window.lastFloatingWidth = window.width + window.lastFloatingHeight = window.height + } + + // Persistence is ARMED late, on purpose. The restore clamp can hand the + // window a SMALLER size than the file holds (a 2560px session opened on a + // 1920px panel) and the WM may adjust the first frames too; persisting + // those settling frames would lose the big-monitor size after one launch on + // the small one — for the Slint build as well, since the file is shared. + // Slint re-saves the clamped size by design ("the Resized handler re-saves + // the result", main.rs:8208); this is the one place the Qt port + // deliberately improves on it instead of matching it. No real user resize + // happens inside the first 1.2s of a cold start, and anything the user does + // after that persists normally. + property bool geometryArmed: false + Timer { + id: geometryArmTimer + interval: 1200 + running: true + repeat: false + onTriggered: window.geometryArmed = true + } + + function persistWindowGeometry() { + if (!window.geometryArmed) + return + // Transient: FLUSH the cached floating size, do not drop it and do not + // defer it. Dropping was the original bug (a drag that debounced into a + // minimize was lost; Slint writes synchronously from the winit Resized + // handler and cannot lose it). Re-arming the timer instead was the + // over-correction: the re-arm is itself what keeps the save + // outstanding, so a window left minimized to the tray polls at 2.5 Hz + // forever — measured at 15 wakeups in 6 s, i.e. ~72,000 over a workday. + // Flushing the cache ends both problems in one line: `window.width` is + // untrustworthy while iconified, but `lastFloatingWidth` is the last + // REAL windowed frame, and Rust's floating-only + app-minimum + >0.5px + // dirty gate turns a zero or stale cache into a no-op by itself. + if (window.transientVisibility(window.visibility)) { + QbzShell.saveWindowGeometry(window.lastFloatingWidth, + window.lastFloatingHeight, + window.maximizedLatch, + window.fullScreenLatch) + return + } + QbzShell.saveWindowGeometry(window.width, + window.height, + window.maximizedLatch, + window.fullScreenLatch) + } + + // Shutdown variant. Nothing can be deferred once the app is going away, so + // instead of skipping the write, a transient window persists the last + // floating size we actually saw. The flags travel either way — they are + // what the restore reads (main.rs:8273-8281). + function persistWindowGeometryOnExit() { + // The 1.2s arming window guards the SIZE against boot-settling frames. + // It must not swallow the FLAGS: a boot clamp never changes visibility, + // so a maximize/restore inside the first 1.2s is a genuine user action, + // and it is the flag — not the size — that both frontends read back + // (main.rs:8273-8281). Persist the flags with a 0x0 size, which is + // below the app floor, so Rust records them and drops the size. + if (!window.geometryArmed) { + QbzShell.saveWindowGeometry(0, 0, + window.maximizedLatch, + window.fullScreenLatch) + return + } + if (!window.transientVisibility(window.visibility)) { + window.persistWindowGeometry() + return + } + QbzShell.saveWindowGeometry(window.lastFloatingWidth, + window.lastFloatingHeight, + window.maximizedLatch, + window.fullScreenLatch) + } + + Timer { + id: geometrySaveTimer + interval: 400 + repeat: false + onTriggered: window.persistWindowGeometry() + } + + onWidthChanged: { window.captureFloatingSize(); geometrySaveTimer.restart() } + onHeightChanged: { window.captureFloatingSize(); geometrySaveTimer.restart() } + // Maximize/restore/fullscreen: the flag itself is the thing that changed, + // and the sizes that come with it are deliberately not persisted. A + // minimize or a hide is NOT a state change to record — it is the same + // window in a transient mode, so the latch keeps its last real value and + // the debounce is not even restarted. + onVisibilityChanged: { + // Read the enum ONCE into a local and decide everything from it — see + // `transientVisibility` for why nothing here may consult a property + // derived from `visibility`. + var vis = window.visibility + if (window.transientVisibility(vis)) + return + // AppKit RE-LAYS OUT the titlebar on fullscreen enter/exit and on the + // maximize round trip, re-parking the traffic lights at their stock + // 28pt-bar position. The reference records that drift and lives with + // it ("the centre can drift until the next launch", + // crates/qbz/src/macos_chrome.rs); Qt hands us this edge for free, so + // re-centre instead of shipping it. Placed AFTER the transient + // early-return on purpose — a minimize is not a re-layout, and while + // the window is hidden AppKit has no main/key window, so the retry + // loop would spin for its full 5s finding nothing. + // + // `applyMacChrome` is idempotent: already-centred lights return on the + // half-point test without touching a frame. + if (QbzShell.isMacos && !QbzShell.systemTitleBar) { + macChromeTimer.attempts = 0 + macChromeTimer.restart() + } + // FLUSH BEFORE LATCHING. Leaving Windowed for Maximized/FullScreen with + // a drag still in the debounce used to lose that drag outright: the + // handler latched the new state and restarted the SAME timer, so when + // it fired it handed Rust the post-transition size with maximized true + // — and the floating-only gate refuses a size on exactly that + // condition. The exit path could not rescue it either, since it passes + // the same latches. Proven by effect: resize 1180->1600, maximize + // 157ms later, quit — the file kept 1180x760 while Slint, for the same + // event stream, kept 1600x900. Commit the floating frame we already + // hold before the latches stop describing it. + if (window.geometryArmed && geometrySaveTimer.running + && !window.maximizedLatch && !window.fullScreenLatch + && vis !== Window.Windowed) { + geometrySaveTimer.stop() + QbzShell.saveWindowGeometry(window.lastFloatingWidth, + window.lastFloatingHeight, + false, false) + } + // Maximized and fullscreen are ORTHOGONAL in winit, which is what + // Slint persists. ASSUMED, NOT MEASURED: on X11 + // `_NET_WM_STATE_MAXIMIZED_*` is expected to survive a fullscreen + // transition under Mutter/KWin, so `is_maximized()` + // (main.rs:1404-1408) would keep reporting true from fullscreen. On + // Wayland a compositor may instead send a states array carrying only + // `fullscreen`, in which case winit reports false and the two + // frontends would write OPPOSITE values into the shared + // window_maximized. Settling this costs one measurement, not an + // argument: run the Slint build, go maximized -> WM fullscreen, and log + // `w.window().is_maximized()` from the Resized handler. If it reports + // false, clear the maximized latch in the FullScreen branch too. Qt + // collapses both into one enum, so the maximized latch is cleared ONLY + // by an explicit return to Windowed — never by entering fullscreen. + // FullScreen arrives from the WM keybind OR from the immersive + // chrome (immersive/ImmersiveHeader.qml fs toggle — the first + // FullScreen write in this frontend, 2026-08-02 §5.2); quitting from + // there must not un-maximize the next launch of either frontend. + if (vis === Window.Maximized) { + window.maximizedLatch = true + window.fullScreenLatch = false + } else if (vis === Window.FullScreen) { + window.fullScreenLatch = true + } else if (vis === Window.Windowed) { + window.maximizedLatch = false + window.fullScreenLatch = false + } + // An un-maximize delivers its size change and its visibility change in + // either order; capturing here as well as in onWidthChanged means the + // floating cache is right whichever arrives second. + window.captureFloatingSize() + geometrySaveTimer.restart() + } + + // The WM close / Alt+F4 exit, routed into the ONE close choreography + // (A-26, below). It is the ONLY exit that delivers a close EVENT, and + // `closeOrHide` refuses that event before hiding — §3.1 measured that + // QGuiApplicationPrivate::maybeLastWindowClosed is reached from exactly two + // sites, both inside QWindow::event and both gated on the accepted flag, so + // hiding never arms Qt's quit check but an ACCEPTED close does. + // + // `onAboutToQuit` is still not a duplicate of it: neither `Qt.exit(0)` + // (what every quit arm calls since 2026-08-04) nor `Qt.quit()` raises + // `closing` (§15 trap 26), so the quit arm of `closeOrHide`, the tray's + // `quit_requested` handler and any teardown that skips QML reach the + // geometry flush only through here. Where both do run the second write is + // free (Rust's >0.5px dirty check drops it), and either way a resize in the + // last 400ms still lands, including the taskbar close of a minimized window + // (that is what the exit variant and the floating cache are for). + onClosing: function (close) { window.closeOrHide(close) } + Connections { + target: Qt.application + function onAboutToQuit() { window.persistWindowGeometryOnExit() } + } + + // --- Close-to-tray, and the ONE visibility owner (contract B6) --------- + // + // THE INVARIANT (§5.8.1, §13-D18, AC-24, stage-0 S0-4): the main window is + // hidden and shown ONLY by these two functions, and they are the ONLY + // callers of QbzTray.setWindowShown. The reference has a real desync + // exactly here — the miniplayer's enter()/exit() call m.hide()/m.show() + // directly (crates/qbz/src/miniplayer.rs:186, :207) instead of + // tray::hide_window/show_window, so WINDOW_SHOWN goes stale and, while the + // mini is up, the first tray left-click hides an already-hidden window and + // the second brings the MAIN window back beside the live mini (#559/#618). + // A bare window.hide()/window.show() anywhere in this file re-opens that + // hole; the miniplayer handler below calls these verbs for that reason. + // + // ⚠ ONE WRITER THE S0-4 GREP CANNOT SEE, named here so a green grep is not + // read as more than it proves: the drawn MINIMIZE buttons call + // `hostWindow.showMinimized()` (shell/HeaderBar.qml, shell/KioskShell.qml) + // and deliberately do NOT report. That is correct — a minimized window is + // still SHOWN, not hidden-to-tray, and owner ruling K5 keeps that button an + // unconditional WM minimize. The consequence is real and inherited from the + // reference, which tracks the same flag the same way: with the window + // ICONIFIED, `WINDOW_SHOWN` is still true, so the next tray left-click + // takes the hide arm rather than restoring. `showFromTray()` below at least + // un-iconifies, so the SECOND click genuinely brings it back. + // The un-hide POSITION (owner report 2026-08-31). Qt keeps the window's + // SIZE across hide()/show(), but a remap is a fresh mapping to the WM and + // KWin's placement policy re-places it (observed: horizontally centred). + // The persisted geometry cannot help — saveWindowGeometry carries only + // width/height + flags, no x/y — so the position is captured at the hide + // and re-applied around the show. Session-local on purpose: across + // launches the WM's placement remains the authority, this only makes + // hide -> restore a round trip. On Wayland the x/y writes are no-ops and + // the behavior simply stays what it was. + property real trayRestoreX: 0 + property real trayRestoreY: 0 + property bool trayRestoreValid: false + + function hideToTray() { + // Only a real WINDOWED frame is worth restoring: a maximized/ + // fullscreen window is re-placed by its latch, and an iconified + // window's x/y are whatever the WM left behind. + window.trayRestoreValid = window.visibility === Window.Windowed + window.trayRestoreX = window.x + window.trayRestoreY = window.y + // Both reference close arms flush the session BEFORE hiding, "even when + // only hiding — the process may be killed from the tray / shell without + // a real quit afterwards" (crates/qbz/src/main.rs:23255-23256). The Qt + // session persistence itself is flushed by the Rust exit path; this + // early geometry flush is still owed before a hide because the + // process may later be killed without a QML close event. On a + // miniplayer enter it is harmless: it writes the size the exit is + // about to restore. + window.persistWindowGeometryOnExit() + window.hide() + QbzTray.setWindowShown(false) + } + // Re-assert shortly after the map: setting x/y before show() seeds the + // position hint, but a placement-policy WM can still apply its own frame + // ON the map, after which one late write wins. One-shot, user-action + // driven — not a continuous clock (repaint-pulse contract untouched). + Timer { + id: trayRestoreTimer + interval: 50 + repeat: false + onTriggered: { + if (window.trayRestoreValid + && window.visibility === Window.Windowed) { + window.x = window.trayRestoreX + window.y = window.trayRestoreY + } + } + } + function showFromTray() { + if (window.trayRestoreValid) { + window.x = window.trayRestoreX + window.y = window.trayRestoreY + } + window.show() + // Un-iconify. Qt's hide() preserves windowStates, unlike the + // reference's surface destruction (crates/qbz/src/tray/mod.rs:253-265), + // so a window that was MINIMIZED before it was hidden comes back + // minimized — visible in the taskbar, invisible on screen, which reads + // as "the tray did nothing". Restoring to the maximized latch rather + // than to Windowed keeps a maximized session maximized. + if (window.visibility === Window.Minimized) + window.visibility = window.maximizedLatch ? Window.Maximized + : Window.Windowed + // raise() + requestActivate() is §13-D12, carried by the VERB so no + // call site has to remember it: the reference's tray::show_window + // focuses (crates/qbz/src/tray/mod.rs:226-229) while its miniplayer + // exit() does not, and the port focuses on both paths. + window.raise() + window.requestActivate() + if (window.trayRestoreValid) + trayRestoreTimer.restart() + QbzTray.setWindowShown(true) + } + + // THE ONE close choreography (A-26, §5.7). Five exits reach it: this + // window's onClosing above, the drawn X and the app-menu Close row in + // shell/HeaderBar.qml, the kiosk shell's own drawn X in + // shell/KioskShell.qml, and the miniplayer's closeApp through the + // QbzMini.closeAppRequested handler below. + // + // The predicate is BOTH conditions, ported verbatim from + // crates/qbz/src/main.rs:23253 — `tray_settings::get().close_to_tray && + // tray::handle().is_some()`. TRAY OFF => CLOSE QUITS: `handle()` is None + // when the tray is disabled, suppressed under gamescope or failed to + // initialise, and gating on the setting alone would strand the process with + // no window and no way back on a desktop with no SNI host (§15 trap 22). + // + // `closeEvent` is the QQuickCloseEvent on the one path that carries one and + // null on the other four. + function closeOrHide(closeEvent) { + var hide = QbzTray.trayLive && QbzTray.closeToTray + // The evidence line (2026-08-04): logged RUST-side so it lands in + // qbz.log — console.log only reaches stderr, which owner reports + // never carry. It prints both operands and the arm taken, which is + // the whole diagnosis of any future "Close to tray does nothing". + QbzTray.closeDecision(hide) + if (hide) { + if (closeEvent) + closeEvent.accepted = false + window.hideToTray() + return + } + // QUIT — via Qt.exit(0), NEVER Qt.quit(). Qt.quit() delivers + // QEvent::Quit, and QGuiApplication's handler first closes every + // top-level window, silently CANCELING the whole quit if any window + // refuses its close — this very function's hide arm refuses whenever + // the tray is live with close-to-tray on, and MiniWindow.qml's + // onClosing refuses unconditionally (its close means "exit the + // mini"). That veto is how "Quit QBZ" hid the window and left the + // process running (owner-reported three times; reproduced under VNC + // 2026-08-04: quit requested + Qt.quit() both logged, event loop + // still alive 25s later). Qt.exit() stops the event loops directly: + // no close events, no veto. Geometry is flushed explicitly below, + // and onAboutToQuit still runs when exec() returns. + if (closeEvent) + closeEvent.accepted = true + QbzTray.armQuitWatchdog() + window.persistWindowGeometryOnExit() + Qt.exit(0) + } + + // The tray's four signals (src/tray_bridge.rs:87-100). Rust owns no window + // (§2.3), so every tray click hops ksni thread -> tray_qt verb -> + // tray_bridge::ui() -> qsignal -> here, and this block is where it finally + // reaches one. + // Each handler logs ONE line, and that is not debug litter: this hop is the + // only part of the tray that Rust cannot see. `tray_qt` logs when it SENDS + // (e.g. "[tray] quit requested"), so without a line on this side a report of + // "the tray does nothing" cannot distinguish "the click never left ksni" + // from "the signal arrived and QML did nothing with it" — which is exactly + // the ambiguity that cost a diagnosis on 2026-08-04. + Connections { + target: QbzTray + function onWindowShowRequested() { + console.log("[qml] tray -> showFromTray") + window.showFromTray() + } + function onWindowHideRequested() { + console.log("[qml] tray -> hideToTray") + window.hideToTray() + } + // The tray's left-click degenerates to present() while the mini is open + // (§5.8.2, §13-D21): raise the MINI, and do NOT bring the main window up + // beside it. No setWindowShown — the mini is not the main window, and + // the flag still honestly says the main one is hidden. + function onMiniPresentRequested() { + var mini = window.ensureMiniWindow() + if (mini !== null) { + mini.show() + mini.raise() + mini.requestActivate() + } + } + function onQuitRequested() { + console.log("[qml] tray -> quit: persisting geometry") + window.persistWindowGeometryOnExit() + console.log("[qml] tray -> quit: calling Qt.exit(0)") + // Qt.exit(0), NEVER Qt.quit() — see closeOrHide's quit arm: a + // Qt.quit() here is vetoable by any window's onClosing (the + // close-to-tray arm, the mini's unconditional refuse), and that + // veto is exactly how the tray Quit hid the window and left the + // process alive (2026-08-04, three owner reports). The hard-exit + // watchdog for this path was armed Rust-side in tray_qt::quit(), + // before the signal was even emitted. + Qt.exit(0) + } + } + + // --- The miniplayer window (2026-08-03 miniplayer/tray contract, B2) --- + // + // LAZY, with a STRONG reference. Declaring the mini inline would build its + // whole subtree at startup; a Component + createObject(null) is the Qt + // shape of the reference's ensure_window() (crates/qbz/src/miniplayer.rs: + // 121-151), and `miniWindow` is its MINI_STRONG thread-local (:40 "Strong + // handle keeps the window alive", set :149, NEVER cleared — exit() and + // close_app() only hide()). A null-parent createObject with no strong + // reference is JS-GC eligible (contract §16 U-3), and this property is what + // makes it not. + // + // Created on first ENTER, never on boot: the QML tree of a window that has + // never been opened costs nothing. + property var miniWindow: null + Component { + id: miniWindowComponent + MiniWindow {} + } + function ensureMiniWindow() { + if (window.miniWindow === null) + window.miniWindow = miniWindowComponent.createObject(null) + return window.miniWindow + } + + // `QbzMini.open` is the ONE mini lifecycle flag (contract A-2): Rust's + // enter/exit funnel writes it, stores the AtomicBool mirror the hotkeys + // pipeline reads, and THEN notifies — so this handler is the QML half of + // §4.6's step ordering and the only place either window is shown or hidden + // for the miniplayer. + // + // Step 7 of enter() is "hide the main window only after the mini is visible + // (avoid a blank flash)" (crates/qbz/src/miniplayer.rs:185-187), so + // hideToTray() runs LAST here and showFromTray() runs after the mini is + // down. They are the A-25 verbs, never a bare hide()/show(): the mini IS + // where the reference's WINDOW_SHOWN desync comes from, and reporting + // shown=false/true from these two paths is what makes the tray toggle + // honest (§13-D18, stage-0 S0-4). + // + // Re-entering while already open forces an openChanged (mini_bridge.rs's + // enter_now), so this runs again and raises the mini; hideToTray() on an + // already-hidden window is a no-op plus one geometry write Rust's dirty + // check drops. + Connections { + target: QbzMini + function onOpenChanged() { + if (QbzMini.open) { + var mini = window.ensureMiniWindow() + if (mini !== null) { + mini.show() + mini.raise() + mini.requestActivate() + } + window.hideToTray() + } else if (window.miniWindow !== null) { + window.miniWindow.hide() + window.showFromTray() + } + } + // The mini's own close verb (A-12). QbzMini.closeApp() exits the mini + // FIRST — which runs the handler above and brings the main window back + // — and only then emits this, so the app's ONE close choreography + // decides between hide-to-tray and quit. The brief show-then-hide is + // 1:1 with the reference: miniplayer.rs:235-251 hides the mini, shows + // the main window and restores its geometry before calling + // invoke_close_app, because "the window becomes user-visible when + // close-to-tray keeps the app alive" (:244-246). + function onCloseAppRequested() { window.closeOrHide(null) } + } + + // The immersive toggle Shortcut is GONE (2026-08-03 hotkeys-port §1.3): + // Shift+I now fires as the REBINDABLE ui.focusMode action through the + // AppShell dispatcher (QbzHotkeys.keyPressed), whose central text-input + // gate replaces the `enabled:` expression this block carried. + // NavGestureLayer.qml owns mouse Back/Forward; no other Shortcut item + // exists in the tree (trap 11). + + Loader { + id: screenLoader + anchors.fill: parent + active: QbzSession.screen !== "splash" + // "kiosk" is a SIBLING of "shell", not a mode inside it — the same + // shape as the reference (app.slint:38's AppScreen enum, with the two + // shells as sibling mounts at :145 and :205). Both read the same + // bridges, so the live toggle swaps them with nothing torn down. + // An unknown id falls through to "" and the Loader goes blank, which + // is the documented failure mode for a missing route (nav_qt.rs:14-16) + // and is what a missing KioskShell.qml would look like. + source: QbzSession.screen === "login" + ? (QbzShell.kioskProfile ? "" : "LoginScreen.qml") + : (QbzSession.screen === "shell" ? "shell/AppShell.qml" + : (QbzSession.screen === "kiosk" ? "shell/KioskShell.qml" : "")) + // Hand the host window down for drag/maximize/resize (custom chrome). + onLoaded: if (screenLoader.item) screenLoader.item.hostWindow = window + } + + // The appliance login opts into compact scrollable layout at construction. + Loader { + anchors.fill: parent + active: QbzSession.screen === "login" && QbzShell.kioskProfile + sourceComponent: LoginScreen { kioskHost: true; hostWindow: window } + } + + // Compact per-track metadata editor. It lives above the routed shell so a + // successful save can republish Local Library without destroying the + // modal halfway through its own transaction. + Loader { + anchors.fill: parent + active: QbzTagEditor.trackEditorOpen + source: "controls/TrackMetadataModal.qml" + z: 4000 + } + + // Local media info is a GLOBAL action target: rows can open it from the + // library tabs, an album detail, search, or any other routed page. Keeping + // the modal above the screen Loader also means a route change cannot be + // required to mount it (or destroy it while it is resolving metadata). + LocalMediaInfoModal { } + + // Frameless hairline (app.slint's no-frame 1px edge) — paints at the + // very rim, over everything. SQUARE (the app draws no corner rounding). + Rectangle { + anchors.fill: parent + color: "transparent" + border.width: 1 + border.color: "#14ffffff" + visible: !QbzShell.systemTitleBar + && window.visibility !== Window.Maximized && window.visibility !== Window.FullScreen + } + + // Edge/corner resize grips (custom chrome — the compositor draws no + // border). 6px, invisible. + MouseArea { + anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 6 + cursorShape: Qt.SizeHorCursor + enabled: !QbzShell.systemTitleBar + onPressed: window.startSystemResize(Qt.LeftEdge) + } + MouseArea { + anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 6 + cursorShape: Qt.SizeHorCursor + enabled: !QbzShell.systemTitleBar + onPressed: window.startSystemResize(Qt.RightEdge) + } + MouseArea { + anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; height: 6 + cursorShape: Qt.SizeVerCursor + enabled: !QbzShell.systemTitleBar + onPressed: window.startSystemResize(Qt.TopEdge) + } + MouseArea { + anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 6 + cursorShape: Qt.SizeVerCursor + enabled: !QbzShell.systemTitleBar + onPressed: window.startSystemResize(Qt.BottomEdge) + } + MouseArea { + anchors.left: parent.left; anchors.top: parent.top; width: 12; height: 12 + cursorShape: Qt.SizeFDiagCursor + enabled: !QbzShell.systemTitleBar + onPressed: window.startSystemResize(Qt.TopEdge | Qt.LeftEdge) + } + MouseArea { + anchors.right: parent.right; anchors.top: parent.top; width: 12; height: 12 + cursorShape: Qt.SizeBDiagCursor + enabled: !QbzShell.systemTitleBar + onPressed: window.startSystemResize(Qt.TopEdge | Qt.RightEdge) + } + MouseArea { + anchors.left: parent.left; anchors.bottom: parent.bottom; width: 12; height: 12 + cursorShape: Qt.SizeBDiagCursor + enabled: !QbzShell.systemTitleBar + onPressed: window.startSystemResize(Qt.BottomEdge | Qt.LeftEdge) + } + MouseArea { + anchors.right: parent.right; anchors.bottom: parent.bottom; width: 12; height: 12 + cursorShape: Qt.SizeFDiagCursor + enabled: !QbzShell.systemTitleBar + onPressed: window.startSystemResize(Qt.BottomEdge | Qt.RightEdge) + } + + // Splash (SplashScreen.slint): the same 720px dark card as the login + // screen while the silent session restore resolves. + // + // PARITY PASS (2026-08-19). Three things this block had lost against the + // reference, all of them user-visible: + // * the LEGAL DISCLAIMER, which SplashScreen.slint:75-86 pins to the + // bottom of the SCREEN (not inside the card — Tauri put it there and + // the Slint port kept that on purpose). It was simply absent here, so + // the one screen a first-run user stares at said nothing about the + // app not being certified by Qobuz. + // * the strings, which were bare QML literals. Every other screen goes + // through QbzSession.tr() with the EXACT msgid of the Slint @tr() + // call so the existing .po catalogues apply; these three did not, so + // the splash was English in all eight locales. + // * the colours and metrics, which were hardcoded hex and magic numbers + // (#0f0f0f / #1a1a1a / #ffffff / #888888, 52, 104, 8, 32). They happen + // to match the DEFAULT dark palette and nothing else: on any of the + // other 34 themes the splash was a black card the rest of the app had + // stopped being. Tokens now, like LoginScreen.qml. + Rectangle { + anchors.fill: parent + color: splashTheme.surfaceMain + visible: QbzSession.screen === "splash" + + QbzTheme { id: splashTheme } + + // Faked drop shadow (blur 32, offset-y 8) — the same hand-copy + // LoginScreen.qml:41-49 carries, for the same reason and with the same + // caveat written there. The reference gives BOTH cards the shadow + // (SplashScreen.slint:23-25); only the login one had it here. + Rectangle { + anchors.horizontalCenter: splashCard.horizontalCenter + y: splashCard.y + 8 + width: splashCard.width + height: splashCard.height + radius: splashTheme.radiusLg + color: splashTheme.cardShadow + opacity: 0.5 + } + + Rectangle { + id: splashCard + anchors.centerIn: parent + width: 720 + height: splashColumn.height + 2 * splashTheme.cardPadding + radius: splashTheme.radiusLg + color: splashTheme.surfaceCard + + Column { + id: splashColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: splashTheme.cardPadding + spacing: 0 + Image { + anchors.horizontalCenter: parent.horizontalCenter + source: "assets/qbz-logo.png" + width: 140 + height: 140 + fillMode: Image.PreserveAspectFit + } + Item { width: 1; height: splashTheme.spacingSm } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: QbzSession.tr("QBZ", QbzSession.trRev) + color: splashTheme.textPrimary + font.pixelSize: splashTheme.fontWordmark + font.weight: splashTheme.weightSemibold + font.letterSpacing: 8 + } + Item { width: 1; height: 2 } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: QbzSession.tr("QOBUZ™ PLAYER", QbzSession.trRev) + color: splashTheme.textMuted + font.pixelSize: splashTheme.fontSubtitle + font.letterSpacing: 4 + } + Item { width: 1; height: splashTheme.spacingXl } + QbzSpinner { + anchors.horizontalCenter: parent.horizontalCenter + size: 30 + } + } + } + + // Legal disclaimer — pinned to the BOTTOM OF THE SCREEN, not inside + // the card. That placement is the reference's, and its comment says it + // is Tauri's too (SplashScreen.slint:73-74). Two msgids concatenated + // with a space, exactly as the reference concatenates them, so both + // reuse the catalogue entries the login screen already uses. + Text { + width: Math.min(parent.width - 80, 720) + x: Math.round((parent.width - width) / 2) + y: parent.height - height - 24 + text: QbzSession.tr("This application uses the Qobuz API but is not certified by Qobuz.", QbzSession.trRev) + + " " + + QbzSession.tr("Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz.", QbzSession.trRev) + color: splashTheme.textMuted + // 10px LITERAL, not fontLegal: the reference hardcodes 10px here + // (SplashScreen.slint:80) where its login twin uses Typography.legal. + font.pixelSize: 10 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } +} diff --git a/crates/qbz-ui/ui/assets/icons/apple-music-logo.svg b/crates/qbz-qt/qml/assets/brand/apple-music-logo.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/apple-music-logo.svg rename to crates/qbz-qt/qml/assets/brand/apple-music-logo.svg diff --git a/crates/qbz-ui/ui/assets/icons/brand-discogs.svg b/crates/qbz-qt/qml/assets/brand/brand-discogs.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/brand-discogs.svg rename to crates/qbz-qt/qml/assets/brand/brand-discogs.svg diff --git a/crates/qbz-ui/ui/assets/icons/brand-lastfm.svg b/crates/qbz-qt/qml/assets/brand/brand-lastfm.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/brand-lastfm.svg rename to crates/qbz-qt/qml/assets/brand/brand-lastfm.svg diff --git a/crates/qbz-ui/ui/assets/icons/brand-musicbrainz.svg b/crates/qbz-qt/qml/assets/brand/brand-musicbrainz.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/brand-musicbrainz.svg rename to crates/qbz-qt/qml/assets/brand/brand-musicbrainz.svg diff --git a/crates/qbz-ui/ui/assets/icons/deezer-logo.svg b/crates/qbz-qt/qml/assets/brand/deezer-logo.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/deezer-logo.svg rename to crates/qbz-qt/qml/assets/brand/deezer-logo.svg diff --git a/crates/qbz-qt/qml/assets/brand/jellyfin-logo.svg b/crates/qbz-qt/qml/assets/brand/jellyfin-logo.svg new file mode 100644 index 000000000..101f9dce9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/brand/jellyfin-logo.svg @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/brand/navidrome-logo.svg b/crates/qbz-qt/qml/assets/brand/navidrome-logo.svg new file mode 100644 index 000000000..c94c86194 --- /dev/null +++ b/crates/qbz-qt/qml/assets/brand/navidrome-logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/icons/plex-logo.svg b/crates/qbz-qt/qml/assets/brand/plex-logo.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/plex-logo.svg rename to crates/qbz-qt/qml/assets/brand/plex-logo.svg diff --git a/crates/qbz-ui/ui/assets/icons/qobuz-logo-filled.svg b/crates/qbz-qt/qml/assets/brand/qobuz-logo-filled.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/qobuz-logo-filled.svg rename to crates/qbz-qt/qml/assets/brand/qobuz-logo-filled.svg diff --git a/crates/qbz-ui/ui/assets/icons/spotify-logo.svg b/crates/qbz-qt/qml/assets/brand/spotify-logo.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/spotify-logo.svg rename to crates/qbz-qt/qml/assets/brand/spotify-logo.svg diff --git a/crates/qbz-qt/qml/assets/cd.svg b/crates/qbz-qt/qml/assets/cd.svg new file mode 100644 index 000000000..ff5bd1053 --- /dev/null +++ b/crates/qbz-qt/qml/assets/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/dsd-dark.svg b/crates/qbz-qt/qml/assets/dsd-dark.svg new file mode 100644 index 000000000..1ffa33251 --- /dev/null +++ b/crates/qbz-qt/qml/assets/dsd-dark.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/dsd-light.svg b/crates/qbz-qt/qml/assets/dsd-light.svg new file mode 100644 index 000000000..aeccf568c --- /dev/null +++ b/crates/qbz-qt/qml/assets/dsd-light.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/flags/ac.svg b/crates/qbz-qt/qml/assets/flags/ac.svg new file mode 100644 index 000000000..5d5914549 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ac.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ad.svg b/crates/qbz-qt/qml/assets/flags/ad.svg new file mode 100644 index 000000000..8e9d368f8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ad.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ae.svg b/crates/qbz-qt/qml/assets/flags/ae.svg new file mode 100644 index 000000000..779b9f50b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ae.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/af.svg b/crates/qbz-qt/qml/assets/flags/af.svg new file mode 100644 index 000000000..de13403b6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/af.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ag.svg b/crates/qbz-qt/qml/assets/flags/ag.svg new file mode 100644 index 000000000..8ab60fe83 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ai.svg b/crates/qbz-qt/qml/assets/flags/ai.svg new file mode 100644 index 000000000..5530eb65c --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/al.svg b/crates/qbz-qt/qml/assets/flags/al.svg new file mode 100644 index 000000000..5bdcf48ba --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/al.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/am.svg b/crates/qbz-qt/qml/assets/flags/am.svg new file mode 100644 index 000000000..6263bd2d4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/am.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/an.svg b/crates/qbz-qt/qml/assets/flags/an.svg new file mode 100644 index 000000000..cad94ecca --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/an.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ao.svg b/crates/qbz-qt/qml/assets/flags/ao.svg new file mode 100644 index 000000000..7bfef4cf2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ao.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/aq.svg b/crates/qbz-qt/qml/assets/flags/aq.svg new file mode 100644 index 000000000..e09d43f20 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/aq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ar.svg b/crates/qbz-qt/qml/assets/flags/ar.svg new file mode 100644 index 000000000..ce7316579 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/as.svg b/crates/qbz-qt/qml/assets/flags/as.svg new file mode 100644 index 000000000..0c5870530 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/as.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/at.svg b/crates/qbz-qt/qml/assets/flags/at.svg new file mode 100644 index 000000000..73a87c887 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/at.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/au.svg b/crates/qbz-qt/qml/assets/flags/au.svg new file mode 100644 index 000000000..bccaab301 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/au.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/aw.svg b/crates/qbz-qt/qml/assets/flags/aw.svg new file mode 100644 index 000000000..872f466b6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/aw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ax.svg b/crates/qbz-qt/qml/assets/flags/ax.svg new file mode 100644 index 000000000..f30a7ec4f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ax.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/az.svg b/crates/qbz-qt/qml/assets/flags/az.svg new file mode 100644 index 000000000..71ec65d61 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/az.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ba.svg b/crates/qbz-qt/qml/assets/flags/ba.svg new file mode 100644 index 000000000..62babc7a9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ba.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bb.svg b/crates/qbz-qt/qml/assets/flags/bb.svg new file mode 100644 index 000000000..d31472b93 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bd.svg b/crates/qbz-qt/qml/assets/flags/bd.svg new file mode 100644 index 000000000..e2a36bbe3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/be.svg b/crates/qbz-qt/qml/assets/flags/be.svg new file mode 100644 index 000000000..382568194 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/be.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bf.svg b/crates/qbz-qt/qml/assets/flags/bf.svg new file mode 100644 index 000000000..641867b59 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bg.svg b/crates/qbz-qt/qml/assets/flags/bg.svg new file mode 100644 index 000000000..43c143c00 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bh.svg b/crates/qbz-qt/qml/assets/flags/bh.svg new file mode 100644 index 000000000..aa05f5e96 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bi.svg b/crates/qbz-qt/qml/assets/flags/bi.svg new file mode 100644 index 000000000..0abd98bd7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bj.svg b/crates/qbz-qt/qml/assets/flags/bj.svg new file mode 100644 index 000000000..7a18b52d2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bl.svg b/crates/qbz-qt/qml/assets/flags/bl.svg new file mode 100644 index 000000000..e03311749 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bm.svg b/crates/qbz-qt/qml/assets/flags/bm.svg new file mode 100644 index 000000000..d726a1fa0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bn.svg b/crates/qbz-qt/qml/assets/flags/bn.svg new file mode 100644 index 000000000..bd3fcbd77 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bo.svg b/crates/qbz-qt/qml/assets/flags/bo.svg new file mode 100644 index 000000000..3247e6bb8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bq.svg b/crates/qbz-qt/qml/assets/flags/bq.svg new file mode 100644 index 000000000..367ed4581 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/br.svg b/crates/qbz-qt/qml/assets/flags/br.svg new file mode 100644 index 000000000..44aacbd43 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/br.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bs.svg b/crates/qbz-qt/qml/assets/flags/bs.svg new file mode 100644 index 000000000..3c3f9aa3b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bt.svg b/crates/qbz-qt/qml/assets/flags/bt.svg new file mode 100644 index 000000000..be25d68c7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bv.svg b/crates/qbz-qt/qml/assets/flags/bv.svg new file mode 100644 index 000000000..bc702a397 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bw.svg b/crates/qbz-qt/qml/assets/flags/bw.svg new file mode 100644 index 000000000..6b63835e3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/by.svg b/crates/qbz-qt/qml/assets/flags/by.svg new file mode 100644 index 000000000..4d2cc0756 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/by.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/bz.svg b/crates/qbz-qt/qml/assets/flags/bz.svg new file mode 100644 index 000000000..ece2b9f44 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/bz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ca.svg b/crates/qbz-qt/qml/assets/flags/ca.svg new file mode 100644 index 000000000..3348f9eea --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ca.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cc.svg b/crates/qbz-qt/qml/assets/flags/cc.svg new file mode 100644 index 000000000..772f29d81 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cd.svg b/crates/qbz-qt/qml/assets/flags/cd.svg new file mode 100644 index 000000000..17ef2e275 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cf.svg b/crates/qbz-qt/qml/assets/flags/cf.svg new file mode 100644 index 000000000..dd94895e0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cg.svg b/crates/qbz-qt/qml/assets/flags/cg.svg new file mode 100644 index 000000000..620c50ad1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ch.svg b/crates/qbz-qt/qml/assets/flags/ch.svg new file mode 100644 index 000000000..3943b1fbb --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ci.svg b/crates/qbz-qt/qml/assets/flags/ci.svg new file mode 100644 index 000000000..1d21f102e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ci.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ck.svg b/crates/qbz-qt/qml/assets/flags/ck.svg new file mode 100644 index 000000000..5ad7aeeaf --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cl.svg b/crates/qbz-qt/qml/assets/flags/cl.svg new file mode 100644 index 000000000..a7a4f8998 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cm.svg b/crates/qbz-qt/qml/assets/flags/cm.svg new file mode 100644 index 000000000..fad286c5d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cn.svg b/crates/qbz-qt/qml/assets/flags/cn.svg new file mode 100644 index 000000000..a61d24ca4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/co.svg b/crates/qbz-qt/qml/assets/flags/co.svg new file mode 100644 index 000000000..ac562e38d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/co.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cp.svg b/crates/qbz-qt/qml/assets/flags/cp.svg new file mode 100644 index 000000000..54000084f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cq.svg b/crates/qbz-qt/qml/assets/flags/cq.svg new file mode 100644 index 000000000..90c23bd7c --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cr.svg b/crates/qbz-qt/qml/assets/flags/cr.svg new file mode 100644 index 000000000..ed16d798c --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cu.svg b/crates/qbz-qt/qml/assets/flags/cu.svg new file mode 100644 index 000000000..63758bd6f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cv.svg b/crates/qbz-qt/qml/assets/flags/cv.svg new file mode 100644 index 000000000..a49dc5400 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cw.svg b/crates/qbz-qt/qml/assets/flags/cw.svg new file mode 100644 index 000000000..5e8772d85 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cx.svg b/crates/qbz-qt/qml/assets/flags/cx.svg new file mode 100644 index 000000000..574ac205a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cy.svg b/crates/qbz-qt/qml/assets/flags/cy.svg new file mode 100644 index 000000000..126b94db2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/cz.svg b/crates/qbz-qt/qml/assets/flags/cz.svg new file mode 100644 index 000000000..70668af64 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/cz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/de.svg b/crates/qbz-qt/qml/assets/flags/de.svg new file mode 100644 index 000000000..efde34193 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/de.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/dg.svg b/crates/qbz-qt/qml/assets/flags/dg.svg new file mode 100644 index 000000000..34dc359d2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/dg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/dj.svg b/crates/qbz-qt/qml/assets/flags/dj.svg new file mode 100644 index 000000000..ac41c5d91 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/dj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/dk.svg b/crates/qbz-qt/qml/assets/flags/dk.svg new file mode 100644 index 000000000..9a0cb4c0f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/dk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/dm.svg b/crates/qbz-qt/qml/assets/flags/dm.svg new file mode 100644 index 000000000..c716af8df --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/dm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/do.svg b/crates/qbz-qt/qml/assets/flags/do.svg new file mode 100644 index 000000000..582001507 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/do.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/dz.svg b/crates/qbz-qt/qml/assets/flags/dz.svg new file mode 100644 index 000000000..491617584 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/dz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ea.svg b/crates/qbz-qt/qml/assets/flags/ea.svg new file mode 100644 index 000000000..715a783f1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ea.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ec.svg b/crates/qbz-qt/qml/assets/flags/ec.svg new file mode 100644 index 000000000..c0a0b225b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ec.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ee.svg b/crates/qbz-qt/qml/assets/flags/ee.svg new file mode 100644 index 000000000..05e9d26d5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/eg.svg b/crates/qbz-qt/qml/assets/flags/eg.svg new file mode 100644 index 000000000..7c00ce5c2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/eg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/eh.svg b/crates/qbz-qt/qml/assets/flags/eh.svg new file mode 100644 index 000000000..2bc578f59 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/eh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/er.svg b/crates/qbz-qt/qml/assets/flags/er.svg new file mode 100644 index 000000000..80fb3abf6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/er.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/es.svg b/crates/qbz-qt/qml/assets/flags/es.svg new file mode 100644 index 000000000..55d3ab504 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/es.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/et.svg b/crates/qbz-qt/qml/assets/flags/et.svg new file mode 100644 index 000000000..6bb850a8e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/et.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/eu.svg b/crates/qbz-qt/qml/assets/flags/eu.svg new file mode 100644 index 000000000..6f584056a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/eu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/fi.svg b/crates/qbz-qt/qml/assets/flags/fi.svg new file mode 100644 index 000000000..cac8507ca --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/fj.svg b/crates/qbz-qt/qml/assets/flags/fj.svg new file mode 100644 index 000000000..21ba9aaaf --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/fj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/fk.svg b/crates/qbz-qt/qml/assets/flags/fk.svg new file mode 100644 index 000000000..2ef39fa5d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/fk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/fm.svg b/crates/qbz-qt/qml/assets/flags/fm.svg new file mode 100644 index 000000000..f0b311800 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/fm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/fo.svg b/crates/qbz-qt/qml/assets/flags/fo.svg new file mode 100644 index 000000000..27abfe72a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/fo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/fr.svg b/crates/qbz-qt/qml/assets/flags/fr.svg new file mode 100644 index 000000000..0fe5619a7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/fr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/fx.svg b/crates/qbz-qt/qml/assets/flags/fx.svg new file mode 100644 index 000000000..0fe5619a7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/fx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ga.svg b/crates/qbz-qt/qml/assets/flags/ga.svg new file mode 100644 index 000000000..b7ca0305c --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ga.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gb.svg b/crates/qbz-qt/qml/assets/flags/gb.svg new file mode 100644 index 000000000..4dca1e782 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gd.svg b/crates/qbz-qt/qml/assets/flags/gd.svg new file mode 100644 index 000000000..ac39ac20b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ge.svg b/crates/qbz-qt/qml/assets/flags/ge.svg new file mode 100644 index 000000000..199569aae --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gf.svg b/crates/qbz-qt/qml/assets/flags/gf.svg new file mode 100644 index 000000000..6b822a857 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gg.svg b/crates/qbz-qt/qml/assets/flags/gg.svg new file mode 100644 index 000000000..d948f912d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gh.svg b/crates/qbz-qt/qml/assets/flags/gh.svg new file mode 100644 index 000000000..fed2c37e7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gi.svg b/crates/qbz-qt/qml/assets/flags/gi.svg new file mode 100644 index 000000000..4327dacdc --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gl.svg b/crates/qbz-qt/qml/assets/flags/gl.svg new file mode 100644 index 000000000..cf8e3c800 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gm.svg b/crates/qbz-qt/qml/assets/flags/gm.svg new file mode 100644 index 000000000..0ce0dc360 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gn.svg b/crates/qbz-qt/qml/assets/flags/gn.svg new file mode 100644 index 000000000..bc62f79a4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gp.svg b/crates/qbz-qt/qml/assets/flags/gp.svg new file mode 100644 index 000000000..6ab4bd630 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gq.svg b/crates/qbz-qt/qml/assets/flags/gq.svg new file mode 100644 index 000000000..3f2050f99 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gr.svg b/crates/qbz-qt/qml/assets/flags/gr.svg new file mode 100644 index 000000000..5e991bc75 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gs.svg b/crates/qbz-qt/qml/assets/flags/gs.svg new file mode 100644 index 000000000..ed468e07a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gt.svg b/crates/qbz-qt/qml/assets/flags/gt.svg new file mode 100644 index 000000000..deaefb828 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gu.svg b/crates/qbz-qt/qml/assets/flags/gu.svg new file mode 100644 index 000000000..9cc1a885c --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gw.svg b/crates/qbz-qt/qml/assets/flags/gw.svg new file mode 100644 index 000000000..862416e5e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/gy.svg b/crates/qbz-qt/qml/assets/flags/gy.svg new file mode 100644 index 000000000..c43af53e5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/gy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/hk.svg b/crates/qbz-qt/qml/assets/flags/hk.svg new file mode 100644 index 000000000..7b0510111 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/hk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/hm.svg b/crates/qbz-qt/qml/assets/flags/hm.svg new file mode 100644 index 000000000..bccaab301 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/hm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/hn.svg b/crates/qbz-qt/qml/assets/flags/hn.svg new file mode 100644 index 000000000..21f8b1a95 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/hn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/hr.svg b/crates/qbz-qt/qml/assets/flags/hr.svg new file mode 100644 index 000000000..5ddd7188b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/hr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ht.svg b/crates/qbz-qt/qml/assets/flags/ht.svg new file mode 100644 index 000000000..5423c4e2a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ht.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/hu.svg b/crates/qbz-qt/qml/assets/flags/hu.svg new file mode 100644 index 000000000..a0a71b05e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/hu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ic.svg b/crates/qbz-qt/qml/assets/flags/ic.svg new file mode 100644 index 000000000..a9d443375 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/id.svg b/crates/qbz-qt/qml/assets/flags/id.svg new file mode 100644 index 000000000..7795a4548 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/id.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ie.svg b/crates/qbz-qt/qml/assets/flags/ie.svg new file mode 100644 index 000000000..a680a7f2d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/il.svg b/crates/qbz-qt/qml/assets/flags/il.svg new file mode 100644 index 000000000..35c11936e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/il.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/im.svg b/crates/qbz-qt/qml/assets/flags/im.svg new file mode 100644 index 000000000..96f8f6d35 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/im.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/in.svg b/crates/qbz-qt/qml/assets/flags/in.svg new file mode 100644 index 000000000..2f070cbd6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/in.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/io.svg b/crates/qbz-qt/qml/assets/flags/io.svg new file mode 100644 index 000000000..34dc359d2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/io.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/iq.svg b/crates/qbz-qt/qml/assets/flags/iq.svg new file mode 100644 index 000000000..63ee33ea1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/iq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ir.svg b/crates/qbz-qt/qml/assets/flags/ir.svg new file mode 100644 index 000000000..883fe5c45 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ir.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/is.svg b/crates/qbz-qt/qml/assets/flags/is.svg new file mode 100644 index 000000000..5a2706c0d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/is.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/it.svg b/crates/qbz-qt/qml/assets/flags/it.svg new file mode 100644 index 000000000..56b4fb300 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/it.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/je.svg b/crates/qbz-qt/qml/assets/flags/je.svg new file mode 100644 index 000000000..6198cafcb --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/je.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/jm.svg b/crates/qbz-qt/qml/assets/flags/jm.svg new file mode 100644 index 000000000..410bf0308 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/jm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/jo.svg b/crates/qbz-qt/qml/assets/flags/jo.svg new file mode 100644 index 000000000..e725de0b4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/jo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/jp.svg b/crates/qbz-qt/qml/assets/flags/jp.svg new file mode 100644 index 000000000..31f064f5d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/jp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ke.svg b/crates/qbz-qt/qml/assets/flags/ke.svg new file mode 100644 index 000000000..d2e7f4375 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ke.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/kg.svg b/crates/qbz-qt/qml/assets/flags/kg.svg new file mode 100644 index 000000000..11103bcc5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/kg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/kh.svg b/crates/qbz-qt/qml/assets/flags/kh.svg new file mode 100644 index 000000000..8277797c0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/kh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ki.svg b/crates/qbz-qt/qml/assets/flags/ki.svg new file mode 100644 index 000000000..3d4d5c839 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ki.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/km.svg b/crates/qbz-qt/qml/assets/flags/km.svg new file mode 100644 index 000000000..c012bc306 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/km.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/kn.svg b/crates/qbz-qt/qml/assets/flags/kn.svg new file mode 100644 index 000000000..1aa7f1520 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/kn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/kp.svg b/crates/qbz-qt/qml/assets/flags/kp.svg new file mode 100644 index 000000000..83a1ca552 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/kp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/kr.svg b/crates/qbz-qt/qml/assets/flags/kr.svg new file mode 100644 index 000000000..105610733 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/kr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/kw.svg b/crates/qbz-qt/qml/assets/flags/kw.svg new file mode 100644 index 000000000..6b14a167e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/kw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ky.svg b/crates/qbz-qt/qml/assets/flags/ky.svg new file mode 100644 index 000000000..0042a7f79 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ky.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/kz.svg b/crates/qbz-qt/qml/assets/flags/kz.svg new file mode 100644 index 000000000..9fd8b6d11 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/kz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/la.svg b/crates/qbz-qt/qml/assets/flags/la.svg new file mode 100644 index 000000000..aaa058973 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/la.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/lb.svg b/crates/qbz-qt/qml/assets/flags/lb.svg new file mode 100644 index 000000000..e30e72219 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/lb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/lc.svg b/crates/qbz-qt/qml/assets/flags/lc.svg new file mode 100644 index 000000000..8a1fff150 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/lc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/li.svg b/crates/qbz-qt/qml/assets/flags/li.svg new file mode 100644 index 000000000..495815f55 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/li.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/lk.svg b/crates/qbz-qt/qml/assets/flags/lk.svg new file mode 100644 index 000000000..2fb7256ec --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/lk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/lr.svg b/crates/qbz-qt/qml/assets/flags/lr.svg new file mode 100644 index 000000000..37e8e2878 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/lr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ls.svg b/crates/qbz-qt/qml/assets/flags/ls.svg new file mode 100644 index 000000000..99024b528 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ls.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/lt.svg b/crates/qbz-qt/qml/assets/flags/lt.svg new file mode 100644 index 000000000..3d5ff126d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/lt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/lu.svg b/crates/qbz-qt/qml/assets/flags/lu.svg new file mode 100644 index 000000000..5e226fe95 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/lu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/lv.svg b/crates/qbz-qt/qml/assets/flags/lv.svg new file mode 100644 index 000000000..9152e7a00 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/lv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ly.svg b/crates/qbz-qt/qml/assets/flags/ly.svg new file mode 100644 index 000000000..89ccd3c80 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ma.svg b/crates/qbz-qt/qml/assets/flags/ma.svg new file mode 100644 index 000000000..30cd031c9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ma.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mc.svg b/crates/qbz-qt/qml/assets/flags/mc.svg new file mode 100644 index 000000000..3d90fe1ba --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/md.svg b/crates/qbz-qt/qml/assets/flags/md.svg new file mode 100644 index 000000000..d5e76979a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/md.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/me.svg b/crates/qbz-qt/qml/assets/flags/me.svg new file mode 100644 index 000000000..2c03aaf3e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/me.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mf.svg b/crates/qbz-qt/qml/assets/flags/mf.svg new file mode 100644 index 000000000..671933589 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mg.svg b/crates/qbz-qt/qml/assets/flags/mg.svg new file mode 100644 index 000000000..a9fa058f1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mh.svg b/crates/qbz-qt/qml/assets/flags/mh.svg new file mode 100644 index 000000000..ccade87c7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mk.svg b/crates/qbz-qt/qml/assets/flags/mk.svg new file mode 100644 index 000000000..4a9057374 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ml.svg b/crates/qbz-qt/qml/assets/flags/ml.svg new file mode 100644 index 000000000..181215e70 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ml.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mm.svg b/crates/qbz-qt/qml/assets/flags/mm.svg new file mode 100644 index 000000000..115d013a2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mn.svg b/crates/qbz-qt/qml/assets/flags/mn.svg new file mode 100644 index 000000000..265557ecf --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mo.svg b/crates/qbz-qt/qml/assets/flags/mo.svg new file mode 100644 index 000000000..1b49023db --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mp.svg b/crates/qbz-qt/qml/assets/flags/mp.svg new file mode 100644 index 000000000..f54f65752 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mq.svg b/crates/qbz-qt/qml/assets/flags/mq.svg new file mode 100644 index 000000000..537736c44 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mr.svg b/crates/qbz-qt/qml/assets/flags/mr.svg new file mode 100644 index 000000000..84a14342b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ms.svg b/crates/qbz-qt/qml/assets/flags/ms.svg new file mode 100644 index 000000000..e56e9b45e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ms.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mt.svg b/crates/qbz-qt/qml/assets/flags/mt.svg new file mode 100644 index 000000000..41b7cd6ef --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mu.svg b/crates/qbz-qt/qml/assets/flags/mu.svg new file mode 100644 index 000000000..bc37ac4b4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mv.svg b/crates/qbz-qt/qml/assets/flags/mv.svg new file mode 100644 index 000000000..a01de4554 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mw.svg b/crates/qbz-qt/qml/assets/flags/mw.svg new file mode 100644 index 000000000..76ed981cc --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mx.svg b/crates/qbz-qt/qml/assets/flags/mx.svg new file mode 100644 index 000000000..2671a86e7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/my.svg b/crates/qbz-qt/qml/assets/flags/my.svg new file mode 100644 index 000000000..5f482a6f2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/my.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/mz.svg b/crates/qbz-qt/qml/assets/flags/mz.svg new file mode 100644 index 000000000..6d8d035c8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/mz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/na.svg b/crates/qbz-qt/qml/assets/flags/na.svg new file mode 100644 index 000000000..0df1f168a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/na.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/nc.svg b/crates/qbz-qt/qml/assets/flags/nc.svg new file mode 100644 index 000000000..88488700b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/nc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ne.svg b/crates/qbz-qt/qml/assets/flags/ne.svg new file mode 100644 index 000000000..89e18a822 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ne.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/nf.svg b/crates/qbz-qt/qml/assets/flags/nf.svg new file mode 100644 index 000000000..4c9ef6221 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/nf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ng.svg b/crates/qbz-qt/qml/assets/flags/ng.svg new file mode 100644 index 000000000..ee20b59a7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ng.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ni.svg b/crates/qbz-qt/qml/assets/flags/ni.svg new file mode 100644 index 000000000..89fcd2597 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ni.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/nl.svg b/crates/qbz-qt/qml/assets/flags/nl.svg new file mode 100644 index 000000000..25b6c9100 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/nl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/no.svg b/crates/qbz-qt/qml/assets/flags/no.svg new file mode 100644 index 000000000..bc702a397 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/no.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/np.svg b/crates/qbz-qt/qml/assets/flags/np.svg new file mode 100644 index 000000000..50b1e3dba --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/np.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/nr.svg b/crates/qbz-qt/qml/assets/flags/nr.svg new file mode 100644 index 000000000..b3c755f4f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/nr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/nu.svg b/crates/qbz-qt/qml/assets/flags/nu.svg new file mode 100644 index 000000000..83d663b83 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/nu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/nz.svg b/crates/qbz-qt/qml/assets/flags/nz.svg new file mode 100644 index 000000000..688bb9d93 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/nz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/om.svg b/crates/qbz-qt/qml/assets/flags/om.svg new file mode 100644 index 000000000..79c4bf23f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/om.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pa.svg b/crates/qbz-qt/qml/assets/flags/pa.svg new file mode 100644 index 000000000..c29cedd1b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pe.svg b/crates/qbz-qt/qml/assets/flags/pe.svg new file mode 100644 index 000000000..07a719098 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pf.svg b/crates/qbz-qt/qml/assets/flags/pf.svg new file mode 100644 index 000000000..7d08fbf2a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pg.svg b/crates/qbz-qt/qml/assets/flags/pg.svg new file mode 100644 index 000000000..214d94126 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ph.svg b/crates/qbz-qt/qml/assets/flags/ph.svg new file mode 100644 index 000000000..a5fc56927 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pk.svg b/crates/qbz-qt/qml/assets/flags/pk.svg new file mode 100644 index 000000000..457a0518a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pl.svg b/crates/qbz-qt/qml/assets/flags/pl.svg new file mode 100644 index 000000000..cf3574a18 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pm.svg b/crates/qbz-qt/qml/assets/flags/pm.svg new file mode 100644 index 000000000..5f5586fab --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pn.svg b/crates/qbz-qt/qml/assets/flags/pn.svg new file mode 100644 index 000000000..ded89b52b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pr.svg b/crates/qbz-qt/qml/assets/flags/pr.svg new file mode 100644 index 000000000..25403954b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ps.svg b/crates/qbz-qt/qml/assets/flags/ps.svg new file mode 100644 index 000000000..0b3657b07 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ps.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pt.svg b/crates/qbz-qt/qml/assets/flags/pt.svg new file mode 100644 index 000000000..32ce13f2f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/pw.svg b/crates/qbz-qt/qml/assets/flags/pw.svg new file mode 100644 index 000000000..67be6e211 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/pw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/py.svg b/crates/qbz-qt/qml/assets/flags/py.svg new file mode 100644 index 000000000..537af58ca --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/py.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/qa.svg b/crates/qbz-qt/qml/assets/flags/qa.svg new file mode 100644 index 000000000..7ccf5bd28 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/qa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/re.svg b/crates/qbz-qt/qml/assets/flags/re.svg new file mode 100644 index 000000000..22fcc98f7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ro.svg b/crates/qbz-qt/qml/assets/flags/ro.svg new file mode 100644 index 000000000..2d3bfd9c5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/rs.svg b/crates/qbz-qt/qml/assets/flags/rs.svg new file mode 100644 index 000000000..96a0717c5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/rs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ru.svg b/crates/qbz-qt/qml/assets/flags/ru.svg new file mode 100644 index 000000000..b49e8cc7e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ru.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/rw.svg b/crates/qbz-qt/qml/assets/flags/rw.svg new file mode 100644 index 000000000..ae6fc4769 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/rw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sa.svg b/crates/qbz-qt/qml/assets/flags/sa.svg new file mode 100644 index 000000000..87c4d198f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sb.svg b/crates/qbz-qt/qml/assets/flags/sb.svg new file mode 100644 index 000000000..3db190a4d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sc.svg b/crates/qbz-qt/qml/assets/flags/sc.svg new file mode 100644 index 000000000..f2eb4437e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sd.svg b/crates/qbz-qt/qml/assets/flags/sd.svg new file mode 100644 index 000000000..a2605fba7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/se.svg b/crates/qbz-qt/qml/assets/flags/se.svg new file mode 100644 index 000000000..c8520bcd2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/se.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sg.svg b/crates/qbz-qt/qml/assets/flags/sg.svg new file mode 100644 index 000000000..363610028 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sh.svg b/crates/qbz-qt/qml/assets/flags/sh.svg new file mode 100644 index 000000000..7234ea657 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/si.svg b/crates/qbz-qt/qml/assets/flags/si.svg new file mode 100644 index 000000000..520693c54 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/si.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sj.svg b/crates/qbz-qt/qml/assets/flags/sj.svg new file mode 100644 index 000000000..bc702a397 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sk.svg b/crates/qbz-qt/qml/assets/flags/sk.svg new file mode 100644 index 000000000..ee0e1292b --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sl.svg b/crates/qbz-qt/qml/assets/flags/sl.svg new file mode 100644 index 000000000..58dc96b92 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sm.svg b/crates/qbz-qt/qml/assets/flags/sm.svg new file mode 100644 index 000000000..69f842700 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sn.svg b/crates/qbz-qt/qml/assets/flags/sn.svg new file mode 100644 index 000000000..f634dca72 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/so.svg b/crates/qbz-qt/qml/assets/flags/so.svg new file mode 100644 index 000000000..76a599c53 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/so.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sr.svg b/crates/qbz-qt/qml/assets/flags/sr.svg new file mode 100644 index 000000000..2cce22acf --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ss.svg b/crates/qbz-qt/qml/assets/flags/ss.svg new file mode 100644 index 000000000..d8cc2db9f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/st.svg b/crates/qbz-qt/qml/assets/flags/st.svg new file mode 100644 index 000000000..798731ba2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/st.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/su.svg b/crates/qbz-qt/qml/assets/flags/su.svg new file mode 100644 index 000000000..d3be990a6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/su.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sv.svg b/crates/qbz-qt/qml/assets/flags/sv.svg new file mode 100644 index 000000000..c3b8db7b6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sx.svg b/crates/qbz-qt/qml/assets/flags/sx.svg new file mode 100644 index 000000000..a3d87c283 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sy.svg b/crates/qbz-qt/qml/assets/flags/sy.svg new file mode 100644 index 000000000..dc15065bd --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/sz.svg b/crates/qbz-qt/qml/assets/flags/sz.svg new file mode 100644 index 000000000..62ce5c787 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/sz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ta.svg b/crates/qbz-qt/qml/assets/flags/ta.svg new file mode 100644 index 000000000..dd1d031c1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ta.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tc.svg b/crates/qbz-qt/qml/assets/flags/tc.svg new file mode 100644 index 000000000..764155ef6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/td.svg b/crates/qbz-qt/qml/assets/flags/td.svg new file mode 100644 index 000000000..bb49f985c --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/td.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tf.svg b/crates/qbz-qt/qml/assets/flags/tf.svg new file mode 100644 index 000000000..d2c44abef --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tg.svg b/crates/qbz-qt/qml/assets/flags/tg.svg new file mode 100644 index 000000000..f5b7c8775 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/th.svg b/crates/qbz-qt/qml/assets/flags/th.svg new file mode 100644 index 000000000..680994bcf --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/th.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tj.svg b/crates/qbz-qt/qml/assets/flags/tj.svg new file mode 100644 index 000000000..509da9ff8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tj.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tk.svg b/crates/qbz-qt/qml/assets/flags/tk.svg new file mode 100644 index 000000000..60ab627b7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tl.svg b/crates/qbz-qt/qml/assets/flags/tl.svg new file mode 100644 index 000000000..d5ba3dc87 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tm.svg b/crates/qbz-qt/qml/assets/flags/tm.svg new file mode 100644 index 000000000..5fcdf1ad6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tn.svg b/crates/qbz-qt/qml/assets/flags/tn.svg new file mode 100644 index 000000000..56d5f3a6e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/to.svg b/crates/qbz-qt/qml/assets/flags/to.svg new file mode 100644 index 000000000..9a72ea06d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/to.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tr.svg b/crates/qbz-qt/qml/assets/flags/tr.svg new file mode 100644 index 000000000..6d22c9b3c --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tt.svg b/crates/qbz-qt/qml/assets/flags/tt.svg new file mode 100644 index 000000000..5de43d929 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tv.svg b/crates/qbz-qt/qml/assets/flags/tv.svg new file mode 100644 index 000000000..184185788 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tw.svg b/crates/qbz-qt/qml/assets/flags/tw.svg new file mode 100644 index 000000000..a0a538728 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/tz.svg b/crates/qbz-qt/qml/assets/flags/tz.svg new file mode 100644 index 000000000..b9e9eb19a --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/tz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ua.svg b/crates/qbz-qt/qml/assets/flags/ua.svg new file mode 100644 index 000000000..ccd0a810d --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ua.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ug.svg b/crates/qbz-qt/qml/assets/flags/ug.svg new file mode 100644 index 000000000..2394c76c8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/uk.svg b/crates/qbz-qt/qml/assets/flags/uk.svg new file mode 100644 index 000000000..4dca1e782 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/uk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/um.svg b/crates/qbz-qt/qml/assets/flags/um.svg new file mode 100644 index 000000000..e3560c6a5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/um.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/un.svg b/crates/qbz-qt/qml/assets/flags/un.svg new file mode 100644 index 000000000..08cfa04f0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/un.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/us.svg b/crates/qbz-qt/qml/assets/flags/us.svg new file mode 100644 index 000000000..e3560c6a5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/us.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/uy.svg b/crates/qbz-qt/qml/assets/flags/uy.svg new file mode 100644 index 000000000..93a40d369 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/uy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/uz.svg b/crates/qbz-qt/qml/assets/flags/uz.svg new file mode 100644 index 000000000..5607ade8f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/uz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/va.svg b/crates/qbz-qt/qml/assets/flags/va.svg new file mode 100644 index 000000000..efbaba8b2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/va.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/vc.svg b/crates/qbz-qt/qml/assets/flags/vc.svg new file mode 100644 index 000000000..2ebd66483 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/vc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ve.svg b/crates/qbz-qt/qml/assets/flags/ve.svg new file mode 100644 index 000000000..261ba0508 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ve.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/vg.svg b/crates/qbz-qt/qml/assets/flags/vg.svg new file mode 100644 index 000000000..cbb0147df --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/vg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/vi.svg b/crates/qbz-qt/qml/assets/flags/vi.svg new file mode 100644 index 000000000..404cf7eb7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/vi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/vn.svg b/crates/qbz-qt/qml/assets/flags/vn.svg new file mode 100644 index 000000000..742759826 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/vn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/vu.svg b/crates/qbz-qt/qml/assets/flags/vu.svg new file mode 100644 index 000000000..02c60f164 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/vu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/wf.svg b/crates/qbz-qt/qml/assets/flags/wf.svg new file mode 100644 index 000000000..7d43e6868 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/wf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ws.svg b/crates/qbz-qt/qml/assets/flags/ws.svg new file mode 100644 index 000000000..ddd1d2d28 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ws.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/xk.svg b/crates/qbz-qt/qml/assets/flags/xk.svg new file mode 100644 index 000000000..9a02990fd --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/xk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/xx.svg b/crates/qbz-qt/qml/assets/flags/xx.svg new file mode 100644 index 000000000..1e6c6a53f --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/xx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/ye.svg b/crates/qbz-qt/qml/assets/flags/ye.svg new file mode 100644 index 000000000..d17ea3b37 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/ye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/yt.svg b/crates/qbz-qt/qml/assets/flags/yt.svg new file mode 100644 index 000000000..c785b1660 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/yt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/yu.svg b/crates/qbz-qt/qml/assets/flags/yu.svg new file mode 100644 index 000000000..93c3d8c2e --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/yu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/za.svg b/crates/qbz-qt/qml/assets/flags/za.svg new file mode 100644 index 000000000..4bb7bd5fb --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/za.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/zm.svg b/crates/qbz-qt/qml/assets/flags/zm.svg new file mode 100644 index 000000000..31682dc47 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/zm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/flags/zw.svg b/crates/qbz-qt/qml/assets/flags/zw.svg new file mode 100644 index 000000000..eb997c949 --- /dev/null +++ b/crates/qbz-qt/qml/assets/flags/zw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/fonts/Inter_18pt-Bold.ttf b/crates/qbz-qt/qml/assets/fonts/Inter_18pt-Bold.ttf similarity index 100% rename from crates/qbz-ui/ui/assets/fonts/Inter_18pt-Bold.ttf rename to crates/qbz-qt/qml/assets/fonts/Inter_18pt-Bold.ttf diff --git a/crates/qbz-ui/ui/assets/fonts/Inter_18pt-Medium.ttf b/crates/qbz-qt/qml/assets/fonts/Inter_18pt-Medium.ttf similarity index 100% rename from crates/qbz-ui/ui/assets/fonts/Inter_18pt-Medium.ttf rename to crates/qbz-qt/qml/assets/fonts/Inter_18pt-Medium.ttf diff --git a/crates/qbz-ui/ui/assets/fonts/Inter_18pt-Regular.ttf b/crates/qbz-qt/qml/assets/fonts/Inter_18pt-Regular.ttf similarity index 100% rename from crates/qbz-ui/ui/assets/fonts/Inter_18pt-Regular.ttf rename to crates/qbz-qt/qml/assets/fonts/Inter_18pt-Regular.ttf diff --git a/crates/qbz-ui/ui/assets/fonts/Inter_18pt-SemiBold.ttf b/crates/qbz-qt/qml/assets/fonts/Inter_18pt-SemiBold.ttf similarity index 100% rename from crates/qbz-ui/ui/assets/fonts/Inter_18pt-SemiBold.ttf rename to crates/qbz-qt/qml/assets/fonts/Inter_18pt-SemiBold.ttf diff --git a/crates/qbz-qt/qml/assets/fonts/LINESeedJP-Regular.ttf b/crates/qbz-qt/qml/assets/fonts/LINESeedJP-Regular.ttf new file mode 100644 index 000000000..655c30d55 Binary files /dev/null and b/crates/qbz-qt/qml/assets/fonts/LINESeedJP-Regular.ttf differ diff --git a/crates/qbz-qt/qml/assets/fonts/Montserrat-VariableFont_wght.ttf b/crates/qbz-qt/qml/assets/fonts/Montserrat-VariableFont_wght.ttf new file mode 100644 index 000000000..451e69288 Binary files /dev/null and b/crates/qbz-qt/qml/assets/fonts/Montserrat-VariableFont_wght.ttf differ diff --git a/crates/qbz-qt/qml/assets/fonts/NotoSans-VariableFont_wdth,wght.ttf b/crates/qbz-qt/qml/assets/fonts/NotoSans-VariableFont_wdth,wght.ttf new file mode 100644 index 000000000..9530d84d5 Binary files /dev/null and b/crates/qbz-qt/qml/assets/fonts/NotoSans-VariableFont_wdth,wght.ttf differ diff --git a/crates/qbz-qt/qml/assets/fonts/NotoSansDevanagari-VariableFont_wght.ttf b/crates/qbz-qt/qml/assets/fonts/NotoSansDevanagari-VariableFont_wght.ttf new file mode 100644 index 000000000..447a104d9 Binary files /dev/null and b/crates/qbz-qt/qml/assets/fonts/NotoSansDevanagari-VariableFont_wght.ttf differ diff --git a/crates/qbz-qt/qml/assets/fonts/SourceSans3-VariableFont_wght.ttf b/crates/qbz-qt/qml/assets/fonts/SourceSans3-VariableFont_wght.ttf new file mode 100644 index 000000000..5b7248b8c Binary files /dev/null and b/crates/qbz-qt/qml/assets/fonts/SourceSans3-VariableFont_wght.ttf differ diff --git a/crates/qbz-ui/ui/assets/hi-res.svg b/crates/qbz-qt/qml/assets/hi-res.svg similarity index 100% rename from crates/qbz-ui/ui/assets/hi-res.svg rename to crates/qbz-qt/qml/assets/hi-res.svg diff --git a/crates/qbz-qt/qml/assets/icons/accent/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/accent/add-to-list.svg new file mode 100644 index 000000000..7a1cb7674 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/add-to-list.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/accent/align-start-vertical.svg b/crates/qbz-qt/qml/assets/icons/accent/align-start-vertical.svg new file mode 100644 index 000000000..2148083c6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/align-start-vertical.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/accent/arrow-up-down.svg new file mode 100644 index 000000000..d78f70456 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/arrow-up-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/audio-lines.svg b/crates/qbz-qt/qml/assets/icons/accent/audio-lines.svg new file mode 100644 index 000000000..e40bdf885 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/audio-lines.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/award.svg b/crates/qbz-qt/qml/assets/icons/accent/award.svg new file mode 100644 index 000000000..66894269a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/award.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/accent/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/accent/blind-eye.svg new file mode 100644 index 000000000..01dc9a4df --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/blind-eye.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/book-open.svg b/crates/qbz-qt/qml/assets/icons/accent/book-open.svg new file mode 100644 index 000000000..694f3fe0d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/book-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/bug.svg b/crates/qbz-qt/qml/assets/icons/accent/bug.svg new file mode 100644 index 000000000..569ac2bf5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/cassette-tape.svg b/crates/qbz-qt/qml/assets/icons/accent/cassette-tape.svg new file mode 100644 index 000000000..a5bdec74b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/cassette-tape.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/cast.svg b/crates/qbz-qt/qml/assets/icons/accent/cast.svg new file mode 100644 index 000000000..e1f6a6239 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/cast.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/cd.svg b/crates/qbz-qt/qml/assets/icons/accent/cd.svg new file mode 100644 index 000000000..9c0a53843 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/accent/chart-no-axes-column.svg b/crates/qbz-qt/qml/assets/icons/accent/chart-no-axes-column.svg new file mode 100644 index 000000000..e705f4fae --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/chart-no-axes-column.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/check.svg b/crates/qbz-qt/qml/assets/icons/accent/check.svg new file mode 100644 index 000000000..c502fe542 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/chevron-down.svg b/crates/qbz-qt/qml/assets/icons/accent/chevron-down.svg new file mode 100644 index 000000000..1e051367c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/chevron-left.svg b/crates/qbz-qt/qml/assets/icons/accent/chevron-left.svg new file mode 100644 index 000000000..862acff3e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/chevron-right.svg b/crates/qbz-qt/qml/assets/icons/accent/chevron-right.svg new file mode 100644 index 000000000..f59f0aaf4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/accent/chevron-up.svg new file mode 100644 index 000000000..cd9310d01 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/circle-alert.svg b/crates/qbz-qt/qml/assets/icons/accent/circle-alert.svg new file mode 100644 index 000000000..863548afb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/circle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/circle-check-big.svg b/crates/qbz-qt/qml/assets/icons/accent/circle-check-big.svg new file mode 100644 index 000000000..97394ba78 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/circle-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/circle-stop.svg b/crates/qbz-qt/qml/assets/icons/accent/circle-stop.svg new file mode 100644 index 000000000..06e6405c1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/circle-stop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/clipboard.svg b/crates/qbz-qt/qml/assets/icons/accent/clipboard.svg new file mode 100644 index 000000000..e1155a901 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/clipboard.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/accent/clock.svg b/crates/qbz-qt/qml/assets/icons/accent/clock.svg new file mode 100644 index 000000000..e29a759f6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/cloud-download.svg b/crates/qbz-qt/qml/assets/icons/accent/cloud-download.svg new file mode 100644 index 000000000..570cc9397 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/cloud-download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/cloud-off.svg b/crates/qbz-qt/qml/assets/icons/accent/cloud-off.svg new file mode 100644 index 000000000..ee7f8e355 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/cloud-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/cloud-sync.svg b/crates/qbz-qt/qml/assets/icons/accent/cloud-sync.svg new file mode 100644 index 000000000..90267645f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/cloud-sync.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/accent/cloud-upload.svg new file mode 100644 index 000000000..8d78b0dc6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/compass.svg b/crates/qbz-qt/qml/assets/icons/accent/compass.svg new file mode 100644 index 000000000..25c73464a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/compass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/copy.svg b/crates/qbz-qt/qml/assets/icons/accent/copy.svg new file mode 100644 index 000000000..bf49ac2ef --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/copy.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/accent/disc-3.svg b/crates/qbz-qt/qml/assets/icons/accent/disc-3.svg new file mode 100644 index 000000000..d51de4317 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/disc-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/disc-folder.svg b/crates/qbz-qt/qml/assets/icons/accent/disc-folder.svg new file mode 100644 index 000000000..48b06f0a3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/disc-folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/disc.svg b/crates/qbz-qt/qml/assets/icons/accent/disc.svg new file mode 100644 index 000000000..1ebe5b661 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/disc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/element-connect.svg b/crates/qbz-qt/qml/assets/icons/accent/element-connect.svg new file mode 100644 index 000000000..61ed0dfb0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/element-connect.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/accent/ellipsis.svg b/crates/qbz-qt/qml/assets/icons/accent/ellipsis.svg new file mode 100644 index 000000000..fb5e39ffb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/external-link.svg b/crates/qbz-qt/qml/assets/icons/accent/external-link.svg new file mode 100644 index 000000000..a5b76a6d3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/external-link.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/accent/eye-off.svg b/crates/qbz-qt/qml/assets/icons/accent/eye-off.svg new file mode 100644 index 000000000..2a52d466a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/eye.svg b/crates/qbz-qt/qml/assets/icons/accent/eye.svg new file mode 100644 index 000000000..8d55592ca --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/folder-open.svg b/crates/qbz-qt/qml/assets/icons/accent/folder-open.svg new file mode 100644 index 000000000..5d1773f89 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/folder-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/accent/folder-plus.svg new file mode 100644 index 000000000..a768b8e88 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/folder-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/folder.svg b/crates/qbz-qt/qml/assets/icons/accent/folder.svg new file mode 100644 index 000000000..e8bb637da --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/gandalf.svg b/crates/qbz-qt/qml/assets/icons/accent/gandalf.svg new file mode 100644 index 000000000..89ad5bbf2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/globe.svg b/crates/qbz-qt/qml/assets/icons/accent/globe.svg new file mode 100644 index 000000000..f7abf3e51 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/globe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/grip-vertical.svg b/crates/qbz-qt/qml/assets/icons/accent/grip-vertical.svg new file mode 100644 index 000000000..e4302eaa1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/grip-vertical.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/hard-drive.svg b/crates/qbz-qt/qml/assets/icons/accent/hard-drive.svg new file mode 100644 index 000000000..10dcef26f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/hard-drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/accent/heart-filled.svg new file mode 100644 index 000000000..9a5fd689a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/heart-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/heart.svg b/crates/qbz-qt/qml/assets/icons/accent/heart.svg new file mode 100644 index 000000000..64887d653 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/home-gear.svg b/crates/qbz-qt/qml/assets/icons/accent/home-gear.svg new file mode 100644 index 000000000..29f75a7f1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/image-plus.svg b/crates/qbz-qt/qml/assets/icons/accent/image-plus.svg new file mode 100644 index 000000000..bb43bdf56 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/image-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/image.svg b/crates/qbz-qt/qml/assets/icons/accent/image.svg new file mode 100644 index 000000000..55b3dbad2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/image.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/accent/import.svg b/crates/qbz-qt/qml/assets/icons/accent/import.svg new file mode 100644 index 000000000..0d65555ff --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/infinity.svg b/crates/qbz-qt/qml/assets/icons/accent/infinity.svg new file mode 100644 index 000000000..a54d2b686 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/info.svg b/crates/qbz-qt/qml/assets/icons/accent/info.svg new file mode 100644 index 000000000..a2580f3d0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/keyboard.svg b/crates/qbz-qt/qml/assets/icons/accent/keyboard.svg new file mode 100644 index 000000000..c495ea0e5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/keyboard.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/layers.svg b/crates/qbz-qt/qml/assets/icons/accent/layers.svg new file mode 100644 index 000000000..23099081e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/layout-grid.svg b/crates/qbz-qt/qml/assets/icons/accent/layout-grid.svg new file mode 100644 index 000000000..72cf1de43 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/layout-grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/library-big.svg b/crates/qbz-qt/qml/assets/icons/accent/library-big.svg new file mode 100644 index 000000000..889609263 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/library-big.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/library.svg b/crates/qbz-qt/qml/assets/icons/accent/library.svg new file mode 100644 index 000000000..07141e416 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/library.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/link.svg b/crates/qbz-qt/qml/assets/icons/accent/link.svg new file mode 100644 index 000000000..02f406173 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/link.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-end.svg b/crates/qbz-qt/qml/assets/icons/accent/list-end.svg new file mode 100644 index 000000000..3f8d400b1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/list-end.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-filter.svg b/crates/qbz-qt/qml/assets/icons/accent/list-filter.svg new file mode 100644 index 000000000..c8e0931ac --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-music.svg b/crates/qbz-qt/qml/assets/icons/accent/list-music.svg new file mode 100644 index 000000000..e93948b0d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/list-music.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-ordered.svg b/crates/qbz-qt/qml/assets/icons/accent/list-ordered.svg new file mode 100644 index 000000000..584e70204 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/list-ordered.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-plus.svg b/crates/qbz-qt/qml/assets/icons/accent/list-plus.svg new file mode 100644 index 000000000..12866c593 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/list-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-start.svg b/crates/qbz-qt/qml/assets/icons/accent/list-start.svg new file mode 100644 index 000000000..9de34ad14 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/list-start.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-x.svg b/crates/qbz-qt/qml/assets/icons/accent/list-x.svg new file mode 100644 index 000000000..78f10e002 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/list-x.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/list.svg b/crates/qbz-qt/qml/assets/icons/accent/list.svg new file mode 100644 index 000000000..0a2aeaca3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/loader-circle.svg b/crates/qbz-qt/qml/assets/icons/accent/loader-circle.svg new file mode 100644 index 000000000..13cf2bc98 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/loader-circle.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/accent/log-out.svg b/crates/qbz-qt/qml/assets/icons/accent/log-out.svg new file mode 100644 index 000000000..d9f3a5766 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/log-out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/map-pin.svg b/crates/qbz-qt/qml/assets/icons/accent/map-pin.svg new file mode 100644 index 000000000..21bb8257d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/map-pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/accent/maximize-2.svg new file mode 100644 index 000000000..fc6e5b56f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/maximize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/menu.svg b/crates/qbz-qt/qml/assets/icons/accent/menu.svg new file mode 100644 index 000000000..1f413343f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/mic-vocal.svg b/crates/qbz-qt/qml/assets/icons/accent/mic-vocal.svg new file mode 100644 index 000000000..8fac64b05 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/mic-vocal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/accent/minimize-2.svg new file mode 100644 index 000000000..abc245a2f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/minimize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/minus.svg b/crates/qbz-qt/qml/assets/icons/accent/minus.svg new file mode 100644 index 000000000..a7f2e7371 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/monitor-speaker.svg b/crates/qbz-qt/qml/assets/icons/accent/monitor-speaker.svg new file mode 100644 index 000000000..68f3e04af --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/monitor-speaker.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/monitor.svg b/crates/qbz-qt/qml/assets/icons/accent/monitor.svg new file mode 100644 index 000000000..6d36bbf5b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/monitor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/moon.svg b/crates/qbz-qt/qml/assets/icons/accent/moon.svg new file mode 100644 index 000000000..8a01e822a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/move.svg b/crates/qbz-qt/qml/assets/icons/accent/move.svg new file mode 100644 index 000000000..7026adf2c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/move.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/mp3.svg b/crates/qbz-qt/qml/assets/icons/accent/mp3.svg new file mode 100644 index 000000000..41cf5e207 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/mp3.svg @@ -0,0 +1,2 @@ + +MP3 \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/accent/music-library-2.svg b/crates/qbz-qt/qml/assets/icons/accent/music-library-2.svg new file mode 100644 index 000000000..a02b6bb2b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/music-library-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/accent/music-note-slider.svg b/crates/qbz-qt/qml/assets/icons/accent/music-note-slider.svg new file mode 100644 index 000000000..e8327ea88 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/music-note-slider.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/music.svg b/crates/qbz-qt/qml/assets/icons/accent/music.svg new file mode 100644 index 000000000..06cef38f8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/music.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/network.svg b/crates/qbz-qt/qml/assets/icons/accent/network.svg new file mode 100644 index 000000000..f0a06d1ed --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/network.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/panel-left.svg b/crates/qbz-qt/qml/assets/icons/accent/panel-left.svg new file mode 100644 index 000000000..0b35d6e26 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/panel-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/panel-right-close.svg b/crates/qbz-qt/qml/assets/icons/accent/panel-right-close.svg new file mode 100644 index 000000000..946a2b413 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/panel-right-close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/accent/panel-right-open.svg new file mode 100644 index 000000000..e65ada05d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/panel-right-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/pause.svg b/crates/qbz-qt/qml/assets/icons/accent/pause.svg new file mode 100644 index 000000000..ecc3fadb7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/pen-line.svg b/crates/qbz-qt/qml/assets/icons/accent/pen-line.svg new file mode 100644 index 000000000..5d64a2047 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/pen-line.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/accent/picture-in-picture-2.svg new file mode 100644 index 000000000..7a8988b0b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/picture-in-picture-2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/accent/pin-filled.svg new file mode 100644 index 000000000..5c537c470 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/pin-filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/pin.svg b/crates/qbz-qt/qml/assets/icons/accent/pin.svg new file mode 100644 index 000000000..b23b4bfe2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/pin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/play-fill.svg b/crates/qbz-qt/qml/assets/icons/accent/play-fill.svg new file mode 100644 index 000000000..8d263ca6f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/play-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/plus.svg b/crates/qbz-qt/qml/assets/icons/accent/plus.svg new file mode 100644 index 000000000..448c941ee --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/qbz-symbolic.svg b/crates/qbz-qt/qml/assets/icons/accent/qbz-symbolic.svg new file mode 100644 index 000000000..da62c4d71 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/qbz-symbolic.svg @@ -0,0 +1,32 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/radio.svg b/crates/qbz-qt/qml/assets/icons/accent/radio.svg new file mode 100644 index 000000000..b0086293e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/radio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/accent/refresh-cw.svg new file mode 100644 index 000000000..15e97e61b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/refresh-cw.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/repeat-1.svg b/crates/qbz-qt/qml/assets/icons/accent/repeat-1.svg new file mode 100644 index 000000000..e9b8cb215 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/repeat-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/repeat.svg b/crates/qbz-qt/qml/assets/icons/accent/repeat.svg new file mode 100644 index 000000000..9ee26f139 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/accent/rotate-ccw.svg new file mode 100644 index 000000000..397678549 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/rows-3.svg b/crates/qbz-qt/qml/assets/icons/accent/rows-3.svg new file mode 100644 index 000000000..98c14810d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/rows-3.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/scissors.svg b/crates/qbz-qt/qml/assets/icons/accent/scissors.svg new file mode 100644 index 000000000..e6b10d0a5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/scissors.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/accent/search.svg b/crates/qbz-qt/qml/assets/icons/accent/search.svg new file mode 100644 index 000000000..b73839337 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/settings-2.svg b/crates/qbz-qt/qml/assets/icons/accent/settings-2.svg new file mode 100644 index 000000000..6256f951d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/settings-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/accent/shopping-bag.svg new file mode 100644 index 000000000..318e6e5f1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/shopping-bag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/shuffle.svg b/crates/qbz-qt/qml/assets/icons/accent/shuffle.svg new file mode 100644 index 000000000..0f65d8897 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/shuffle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/skip-back.svg b/crates/qbz-qt/qml/assets/icons/accent/skip-back.svg new file mode 100644 index 000000000..df4d334b8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/skip-back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/skip-forward.svg b/crates/qbz-qt/qml/assets/icons/accent/skip-forward.svg new file mode 100644 index 000000000..2b1ddce85 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/skip-forward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/accent/sliders-horizontal.svg new file mode 100644 index 000000000..b1079723e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/sliders-horizontal.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/smartphone.svg b/crates/qbz-qt/qml/assets/icons/accent/smartphone.svg new file mode 100644 index 000000000..2f064dc07 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/smartphone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/speaker.svg b/crates/qbz-qt/qml/assets/icons/accent/speaker.svg new file mode 100644 index 000000000..3995eeb69 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/speaker.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/square-check-big.svg b/crates/qbz-qt/qml/assets/icons/accent/square-check-big.svg new file mode 100644 index 000000000..394e7142c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/square-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/square.svg b/crates/qbz-qt/qml/assets/icons/accent/square.svg new file mode 100644 index 000000000..877ef45b8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/square.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/star.svg b/crates/qbz-qt/qml/assets/icons/accent/star.svg new file mode 100644 index 000000000..41ded5321 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/accent/sun-moon.svg new file mode 100644 index 000000000..73781c42f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/sun.svg b/crates/qbz-qt/qml/assets/icons/accent/sun.svg new file mode 100644 index 000000000..cfc614b37 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/tags.svg b/crates/qbz-qt/qml/assets/icons/accent/tags.svg new file mode 100644 index 000000000..dd9de2480 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/tags.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/thumbs-down.svg b/crates/qbz-qt/qml/assets/icons/accent/thumbs-down.svg new file mode 100644 index 000000000..7499678a9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/thumbs-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/ticket-check.svg b/crates/qbz-qt/qml/assets/icons/accent/ticket-check.svg new file mode 100644 index 000000000..a991434e7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/ticket-check.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/tidal-tidal.svg b/crates/qbz-qt/qml/assets/icons/accent/tidal-tidal.svg new file mode 100644 index 000000000..27add95be --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/tidal-tidal.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/translate.svg b/crates/qbz-qt/qml/assets/icons/accent/translate.svg new file mode 100644 index 000000000..7da5966f3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/translate.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/accent/trash-2.svg b/crates/qbz-qt/qml/assets/icons/accent/trash-2.svg new file mode 100644 index 000000000..6666b0977 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/trash-2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/trash-list.svg b/crates/qbz-qt/qml/assets/icons/accent/trash-list.svg new file mode 100644 index 000000000..b3633d789 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/trash-list.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/accent/triangle-alert.svg b/crates/qbz-qt/qml/assets/icons/accent/triangle-alert.svg new file mode 100644 index 000000000..a388a3296 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/triangle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/turntable.svg b/crates/qbz-qt/qml/assets/icons/accent/turntable.svg new file mode 100644 index 000000000..7188fcf4e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/turntable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/user-plus.svg b/crates/qbz-qt/qml/assets/icons/accent/user-plus.svg new file mode 100644 index 000000000..39c253602 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/user-plus.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/accent/user.svg b/crates/qbz-qt/qml/assets/icons/accent/user.svg new file mode 100644 index 000000000..00d433a24 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/volume-2.svg b/crates/qbz-qt/qml/assets/icons/accent/volume-2.svg new file mode 100644 index 000000000..47dc85e04 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/volume-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/volume-x.svg b/crates/qbz-qt/qml/assets/icons/accent/volume-x.svg new file mode 100644 index 000000000..3921f3e0e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/volume-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/accent/wand-sparkles.svg new file mode 100644 index 000000000..41056ab8d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/wifi.svg b/crates/qbz-qt/qml/assets/icons/accent/wifi.svg new file mode 100644 index 000000000..24cad2f71 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/wifi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/x.svg b/crates/qbz-qt/qml/assets/icons/accent/x.svg new file mode 100644 index 000000000..4f07b1ee3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/amber/circle-alert.svg b/crates/qbz-qt/qml/assets/icons/amber/circle-alert.svg new file mode 100644 index 000000000..88b46facb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/amber/circle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/amber/circle-check-big.svg b/crates/qbz-qt/qml/assets/icons/amber/circle-check-big.svg new file mode 100644 index 000000000..2a2612a4f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/amber/circle-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/amber/image.svg b/crates/qbz-qt/qml/assets/icons/amber/image.svg new file mode 100644 index 000000000..c738062bc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/amber/image.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/amber/monitor-speaker.svg b/crates/qbz-qt/qml/assets/icons/amber/monitor-speaker.svg new file mode 100644 index 000000000..6a79ef1b1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/amber/monitor-speaker.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/amber/triangle-alert.svg b/crates/qbz-qt/qml/assets/icons/amber/triangle-alert.svg new file mode 100644 index 000000000..fbc1783db --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/amber/triangle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/amber/turntable.svg b/crates/qbz-qt/qml/assets/icons/amber/turntable.svg new file mode 100644 index 000000000..99b7e6070 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/amber/turntable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/black/add-to-list.svg new file mode 100644 index 000000000..05f434b1e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/add-to-list.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/align-start-vertical.svg b/crates/qbz-qt/qml/assets/icons/black/align-start-vertical.svg new file mode 100644 index 000000000..e8ebd3c80 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/align-start-vertical.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/black/arrow-up-down.svg new file mode 100644 index 000000000..4dfa4f3bf --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/arrow-up-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/audio-lines.svg b/crates/qbz-qt/qml/assets/icons/black/audio-lines.svg new file mode 100644 index 000000000..7af4b7db0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/audio-lines.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/award.svg b/crates/qbz-qt/qml/assets/icons/black/award.svg new file mode 100644 index 000000000..ccb55cbd6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/award.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/black/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/black/blind-eye.svg new file mode 100644 index 000000000..5a0afdbd5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/blind-eye.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/qbz-qt/qml/assets/icons/black/book-open.svg b/crates/qbz-qt/qml/assets/icons/black/book-open.svg new file mode 100644 index 000000000..9bea3130e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/book-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/bug.svg b/crates/qbz-qt/qml/assets/icons/black/bug.svg new file mode 100644 index 000000000..c94d68413 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cassette-tape.svg b/crates/qbz-qt/qml/assets/icons/black/cassette-tape.svg new file mode 100644 index 000000000..9ef5b8d6b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cassette-tape.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cast.svg b/crates/qbz-qt/qml/assets/icons/black/cast.svg new file mode 100644 index 000000000..3cd4aed5a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cast.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cd.svg b/crates/qbz-qt/qml/assets/icons/black/cd.svg new file mode 100644 index 000000000..73c45a78c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/chart-no-axes-column.svg b/crates/qbz-qt/qml/assets/icons/black/chart-no-axes-column.svg new file mode 100644 index 000000000..e9426016b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/chart-no-axes-column.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/check.svg b/crates/qbz-qt/qml/assets/icons/black/check.svg new file mode 100644 index 000000000..83467d3d9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/chevron-down.svg b/crates/qbz-qt/qml/assets/icons/black/chevron-down.svg new file mode 100644 index 000000000..a40fd3add --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/chevron-left.svg b/crates/qbz-qt/qml/assets/icons/black/chevron-left.svg new file mode 100644 index 000000000..dc8f533f2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/chevron-right.svg b/crates/qbz-qt/qml/assets/icons/black/chevron-right.svg new file mode 100644 index 000000000..d7de71e8a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/black/chevron-up.svg new file mode 100644 index 000000000..35c3160df --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/circle-alert.svg b/crates/qbz-qt/qml/assets/icons/black/circle-alert.svg new file mode 100644 index 000000000..78b7c3d41 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/circle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/circle-check-big.svg b/crates/qbz-qt/qml/assets/icons/black/circle-check-big.svg new file mode 100644 index 000000000..c56ff6a64 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/circle-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/circle-stop.svg b/crates/qbz-qt/qml/assets/icons/black/circle-stop.svg new file mode 100644 index 000000000..de4fbe53c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/circle-stop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/clipboard.svg b/crates/qbz-qt/qml/assets/icons/black/clipboard.svg new file mode 100644 index 000000000..50c987209 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/clipboard.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/black/clock.svg b/crates/qbz-qt/qml/assets/icons/black/clock.svg new file mode 100644 index 000000000..092727ac8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cloud-download.svg b/crates/qbz-qt/qml/assets/icons/black/cloud-download.svg new file mode 100644 index 000000000..4f1f8bf20 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cloud-download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cloud-off.svg b/crates/qbz-qt/qml/assets/icons/black/cloud-off.svg new file mode 100644 index 000000000..7577b4682 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cloud-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cloud-sync.svg b/crates/qbz-qt/qml/assets/icons/black/cloud-sync.svg new file mode 100644 index 000000000..4155406fb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cloud-sync.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/black/cloud-upload.svg new file mode 100644 index 000000000..8d4488a68 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/compass.svg b/crates/qbz-qt/qml/assets/icons/black/compass.svg new file mode 100644 index 000000000..71b9c879c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/compass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/copy.svg b/crates/qbz-qt/qml/assets/icons/black/copy.svg new file mode 100644 index 000000000..e501da475 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/copy.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/black/disc-3.svg b/crates/qbz-qt/qml/assets/icons/black/disc-3.svg new file mode 100644 index 000000000..e39686d99 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/disc-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/disc-folder.svg b/crates/qbz-qt/qml/assets/icons/black/disc-folder.svg new file mode 100644 index 000000000..276a8e45d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/disc-folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/disc.svg b/crates/qbz-qt/qml/assets/icons/black/disc.svg new file mode 100644 index 000000000..f4eb55adc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/disc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/element-connect.svg b/crates/qbz-qt/qml/assets/icons/black/element-connect.svg new file mode 100644 index 000000000..3029ac315 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/element-connect.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/ellipsis.svg b/crates/qbz-qt/qml/assets/icons/black/ellipsis.svg new file mode 100644 index 000000000..156bcca96 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/external-link.svg b/crates/qbz-qt/qml/assets/icons/black/external-link.svg new file mode 100644 index 000000000..0f499974b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/external-link.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/eye-off.svg b/crates/qbz-qt/qml/assets/icons/black/eye-off.svg new file mode 100644 index 000000000..01ef53f46 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/eye.svg b/crates/qbz-qt/qml/assets/icons/black/eye.svg new file mode 100644 index 000000000..1b2c2f6f0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/folder-open.svg b/crates/qbz-qt/qml/assets/icons/black/folder-open.svg new file mode 100644 index 000000000..b58074688 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/folder-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/black/folder-plus.svg new file mode 100644 index 000000000..b8b5f59ce --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/folder-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/folder.svg b/crates/qbz-qt/qml/assets/icons/black/folder.svg new file mode 100644 index 000000000..b87243acc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-ui/ui/assets/icons/gandalf.svg b/crates/qbz-qt/qml/assets/icons/black/gandalf.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/gandalf.svg rename to crates/qbz-qt/qml/assets/icons/black/gandalf.svg diff --git a/crates/qbz-qt/qml/assets/icons/black/globe.svg b/crates/qbz-qt/qml/assets/icons/black/globe.svg new file mode 100644 index 000000000..141880397 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/globe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/grip-vertical.svg b/crates/qbz-qt/qml/assets/icons/black/grip-vertical.svg new file mode 100644 index 000000000..909bcac32 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/grip-vertical.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/hard-drive.svg b/crates/qbz-qt/qml/assets/icons/black/hard-drive.svg new file mode 100644 index 000000000..2e8cfc19e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/hard-drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/black/heart-filled.svg new file mode 100644 index 000000000..fbad8f5cd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/heart-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/heart.svg b/crates/qbz-qt/qml/assets/icons/black/heart.svg new file mode 100644 index 000000000..75887618a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/home-gear.svg b/crates/qbz-qt/qml/assets/icons/black/home-gear.svg new file mode 100644 index 000000000..c1eab56e4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/image-plus.svg b/crates/qbz-qt/qml/assets/icons/black/image-plus.svg new file mode 100644 index 000000000..c21ae3ee1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/image-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/image.svg b/crates/qbz-qt/qml/assets/icons/black/image.svg new file mode 100644 index 000000000..1c602585a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/image.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/black/import.svg b/crates/qbz-qt/qml/assets/icons/black/import.svg new file mode 100644 index 000000000..39801d73c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/infinity.svg b/crates/qbz-qt/qml/assets/icons/black/infinity.svg new file mode 100644 index 000000000..0d530ec92 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/info.svg b/crates/qbz-qt/qml/assets/icons/black/info.svg new file mode 100644 index 000000000..d9d7e6c35 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/keyboard.svg b/crates/qbz-qt/qml/assets/icons/black/keyboard.svg new file mode 100644 index 000000000..5d90aaad5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/keyboard.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/layers.svg b/crates/qbz-qt/qml/assets/icons/black/layers.svg new file mode 100644 index 000000000..154ca2d7f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/layout-grid.svg b/crates/qbz-qt/qml/assets/icons/black/layout-grid.svg new file mode 100644 index 000000000..574511f69 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/layout-grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/library-big.svg b/crates/qbz-qt/qml/assets/icons/black/library-big.svg new file mode 100644 index 000000000..3ae3336b0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/library-big.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/library.svg b/crates/qbz-qt/qml/assets/icons/black/library.svg new file mode 100644 index 000000000..709901dbe --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/library.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/link.svg b/crates/qbz-qt/qml/assets/icons/black/link.svg new file mode 100644 index 000000000..901bf9c0e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/link.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-end.svg b/crates/qbz-qt/qml/assets/icons/black/list-end.svg new file mode 100644 index 000000000..6fd160fcc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-end.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-filter.svg b/crates/qbz-qt/qml/assets/icons/black/list-filter.svg new file mode 100644 index 000000000..a47dc734f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-music.svg b/crates/qbz-qt/qml/assets/icons/black/list-music.svg new file mode 100644 index 000000000..8e8023f3e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-music.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-ordered.svg b/crates/qbz-qt/qml/assets/icons/black/list-ordered.svg new file mode 100644 index 000000000..776e11c07 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-ordered.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-plus.svg b/crates/qbz-qt/qml/assets/icons/black/list-plus.svg new file mode 100644 index 000000000..3b5056194 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-start.svg b/crates/qbz-qt/qml/assets/icons/black/list-start.svg new file mode 100644 index 000000000..fd5bc8bac --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-start.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-x.svg b/crates/qbz-qt/qml/assets/icons/black/list-x.svg new file mode 100644 index 000000000..90fee2d0f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-x.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list.svg b/crates/qbz-qt/qml/assets/icons/black/list.svg new file mode 100644 index 000000000..5b75857c2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/loader-circle.svg b/crates/qbz-qt/qml/assets/icons/black/loader-circle.svg new file mode 100644 index 000000000..bffa07507 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/loader-circle.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/black/log-out.svg b/crates/qbz-qt/qml/assets/icons/black/log-out.svg new file mode 100644 index 000000000..3598e00c2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/log-out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/map-pin.svg b/crates/qbz-qt/qml/assets/icons/black/map-pin.svg new file mode 100644 index 000000000..fc7cb914e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/map-pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/black/maximize-2.svg new file mode 100644 index 000000000..97da55589 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/maximize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/menu.svg b/crates/qbz-qt/qml/assets/icons/black/menu.svg new file mode 100644 index 000000000..5d9ae7588 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/mic-vocal.svg b/crates/qbz-qt/qml/assets/icons/black/mic-vocal.svg new file mode 100644 index 000000000..98442325e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/mic-vocal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/black/minimize-2.svg new file mode 100644 index 000000000..518339019 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/minimize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/minus.svg b/crates/qbz-qt/qml/assets/icons/black/minus.svg new file mode 100644 index 000000000..a514b9e89 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/monitor-speaker.svg b/crates/qbz-qt/qml/assets/icons/black/monitor-speaker.svg new file mode 100644 index 000000000..f50878830 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/monitor-speaker.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/monitor.svg b/crates/qbz-qt/qml/assets/icons/black/monitor.svg new file mode 100644 index 000000000..2e0337692 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/monitor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/moon.svg b/crates/qbz-qt/qml/assets/icons/black/moon.svg new file mode 100644 index 000000000..bab1e0aa6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/move.svg b/crates/qbz-qt/qml/assets/icons/black/move.svg new file mode 100644 index 000000000..b9c6a5df8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/move.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/mp3.svg b/crates/qbz-qt/qml/assets/icons/black/mp3.svg new file mode 100644 index 000000000..0fa6e5de9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/mp3.svg @@ -0,0 +1,2 @@ + +MP3 \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/music-library-2.svg b/crates/qbz-qt/qml/assets/icons/black/music-library-2.svg new file mode 100644 index 000000000..9282eb0ab --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/music-library-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/music-note-slider.svg b/crates/qbz-qt/qml/assets/icons/black/music-note-slider.svg new file mode 100644 index 000000000..81aa10578 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/music-note-slider.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/music.svg b/crates/qbz-qt/qml/assets/icons/black/music.svg new file mode 100644 index 000000000..d70ea33cb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/music.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/network.svg b/crates/qbz-qt/qml/assets/icons/black/network.svg new file mode 100644 index 000000000..c7b6b260b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/network.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/panel-left.svg b/crates/qbz-qt/qml/assets/icons/black/panel-left.svg new file mode 100644 index 000000000..a0c2e0266 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/panel-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/panel-right-close.svg b/crates/qbz-qt/qml/assets/icons/black/panel-right-close.svg new file mode 100644 index 000000000..0e36d247d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/panel-right-close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/black/panel-right-open.svg new file mode 100644 index 000000000..01701ac77 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/panel-right-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/pause.svg b/crates/qbz-qt/qml/assets/icons/black/pause.svg new file mode 100644 index 000000000..300f7563f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/pen-line.svg b/crates/qbz-qt/qml/assets/icons/black/pen-line.svg new file mode 100644 index 000000000..c54b7173a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/pen-line.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/black/picture-in-picture-2.svg new file mode 100644 index 000000000..63f2a5ddd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/picture-in-picture-2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/black/pin-filled.svg new file mode 100644 index 000000000..8b0bf1b62 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/pin-filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/pin.svg b/crates/qbz-qt/qml/assets/icons/black/pin.svg new file mode 100644 index 000000000..c9fbfaa1a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/pin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/play-fill.svg b/crates/qbz-qt/qml/assets/icons/black/play-fill.svg new file mode 100644 index 000000000..1b2effd14 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/play-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/plus.svg b/crates/qbz-qt/qml/assets/icons/black/plus.svg new file mode 100644 index 000000000..585404be2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/qbz-symbolic.svg b/crates/qbz-qt/qml/assets/icons/black/qbz-symbolic.svg new file mode 100644 index 000000000..88efd84e4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/qbz-symbolic.svg @@ -0,0 +1,32 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/radio.svg b/crates/qbz-qt/qml/assets/icons/black/radio.svg new file mode 100644 index 000000000..146eed027 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/radio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/black/refresh-cw.svg new file mode 100644 index 000000000..094c7fd38 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/refresh-cw.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/repeat-1.svg b/crates/qbz-qt/qml/assets/icons/black/repeat-1.svg new file mode 100644 index 000000000..ac7e8282d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/repeat-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/repeat.svg b/crates/qbz-qt/qml/assets/icons/black/repeat.svg new file mode 100644 index 000000000..5f50e3d6b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/black/rotate-ccw.svg new file mode 100644 index 000000000..9497e0a4f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/rows-3.svg b/crates/qbz-qt/qml/assets/icons/black/rows-3.svg new file mode 100644 index 000000000..b04b41040 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/rows-3.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/scissors.svg b/crates/qbz-qt/qml/assets/icons/black/scissors.svg new file mode 100644 index 000000000..8bc97a13a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/scissors.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/black/search.svg b/crates/qbz-qt/qml/assets/icons/black/search.svg new file mode 100644 index 000000000..7f681bac7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/settings-2.svg b/crates/qbz-qt/qml/assets/icons/black/settings-2.svg new file mode 100644 index 000000000..e78787e1b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/settings-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/black/shopping-bag.svg new file mode 100644 index 000000000..d946a956e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/shopping-bag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/shuffle.svg b/crates/qbz-qt/qml/assets/icons/black/shuffle.svg new file mode 100644 index 000000000..04e34e13b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/shuffle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/skip-back.svg b/crates/qbz-qt/qml/assets/icons/black/skip-back.svg new file mode 100644 index 000000000..dce186b54 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/skip-back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/skip-forward.svg b/crates/qbz-qt/qml/assets/icons/black/skip-forward.svg new file mode 100644 index 000000000..1945b93ca --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/skip-forward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/black/sliders-horizontal.svg new file mode 100644 index 000000000..b64ec2728 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/sliders-horizontal.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/smartphone.svg b/crates/qbz-qt/qml/assets/icons/black/smartphone.svg new file mode 100644 index 000000000..848d7e676 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/smartphone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/speaker.svg b/crates/qbz-qt/qml/assets/icons/black/speaker.svg new file mode 100644 index 000000000..efaa590cd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/speaker.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/square-check-big.svg b/crates/qbz-qt/qml/assets/icons/black/square-check-big.svg new file mode 100644 index 000000000..1bede4d4a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/square-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/square.svg b/crates/qbz-qt/qml/assets/icons/black/square.svg new file mode 100644 index 000000000..e2282fe4c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/square.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/star.svg b/crates/qbz-qt/qml/assets/icons/black/star.svg new file mode 100644 index 000000000..c879c1c5a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/black/sun-moon.svg new file mode 100644 index 000000000..dbf7ac64b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/sun.svg b/crates/qbz-qt/qml/assets/icons/black/sun.svg new file mode 100644 index 000000000..9c25a8a62 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/tags.svg b/crates/qbz-qt/qml/assets/icons/black/tags.svg new file mode 100644 index 000000000..114460c62 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/tags.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/thumbs-down.svg b/crates/qbz-qt/qml/assets/icons/black/thumbs-down.svg new file mode 100644 index 000000000..3b4e39a40 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/thumbs-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/ticket-check.svg b/crates/qbz-qt/qml/assets/icons/black/ticket-check.svg new file mode 100644 index 000000000..0d38db6de --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/ticket-check.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/tidal-tidal.svg b/crates/qbz-qt/qml/assets/icons/black/tidal-tidal.svg new file mode 100644 index 000000000..585b32303 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/tidal-tidal.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/translate.svg b/crates/qbz-qt/qml/assets/icons/black/translate.svg new file mode 100644 index 000000000..318510725 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/translate.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/trash-2.svg b/crates/qbz-qt/qml/assets/icons/black/trash-2.svg new file mode 100644 index 000000000..f176b5de6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/trash-2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/trash-list.svg b/crates/qbz-qt/qml/assets/icons/black/trash-list.svg new file mode 100644 index 000000000..0faef23aa --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/trash-list.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/triangle-alert.svg b/crates/qbz-qt/qml/assets/icons/black/triangle-alert.svg new file mode 100644 index 000000000..df77fe7b9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/triangle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/turntable.svg b/crates/qbz-qt/qml/assets/icons/black/turntable.svg new file mode 100644 index 000000000..57d2a1885 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/turntable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/user-plus.svg b/crates/qbz-qt/qml/assets/icons/black/user-plus.svg new file mode 100644 index 000000000..04fd10180 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/user-plus.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/black/user.svg b/crates/qbz-qt/qml/assets/icons/black/user.svg new file mode 100644 index 000000000..98a86d799 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/volume-2.svg b/crates/qbz-qt/qml/assets/icons/black/volume-2.svg new file mode 100644 index 000000000..56014277b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/volume-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/volume-x.svg b/crates/qbz-qt/qml/assets/icons/black/volume-x.svg new file mode 100644 index 000000000..48faaff4d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/volume-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/black/wand-sparkles.svg new file mode 100644 index 000000000..be4e2f83b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/wifi.svg b/crates/qbz-qt/qml/assets/icons/black/wifi.svg new file mode 100644 index 000000000..0f685cf50 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/wifi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/x.svg b/crates/qbz-qt/qml/assets/icons/black/x.svg new file mode 100644 index 000000000..c9b0cfd95 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/favorite/add-to-list.svg new file mode 100644 index 000000000..6dab9461e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/add-to-list.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/favorite/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/favorite/arrow-up-down.svg new file mode 100644 index 000000000..ed4a291ee --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/arrow-up-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/award.svg b/crates/qbz-qt/qml/assets/icons/favorite/award.svg new file mode 100644 index 000000000..2c677f108 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/award.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/favorite/blind-eye.svg new file mode 100644 index 000000000..a36b4aa35 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/blind-eye.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/book-open.svg b/crates/qbz-qt/qml/assets/icons/favorite/book-open.svg new file mode 100644 index 000000000..f420e1994 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/book-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/bug.svg b/crates/qbz-qt/qml/assets/icons/favorite/bug.svg new file mode 100644 index 000000000..d9e30b854 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/cassette-tape.svg b/crates/qbz-qt/qml/assets/icons/favorite/cassette-tape.svg new file mode 100644 index 000000000..9935b9046 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/cassette-tape.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/circle-alert.svg b/crates/qbz-qt/qml/assets/icons/favorite/circle-alert.svg new file mode 100644 index 000000000..f9f72fd1c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/circle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/circle-check-big.svg b/crates/qbz-qt/qml/assets/icons/favorite/circle-check-big.svg new file mode 100644 index 000000000..c11b622a8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/circle-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/circle-stop.svg b/crates/qbz-qt/qml/assets/icons/favorite/circle-stop.svg new file mode 100644 index 000000000..8121beafb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/circle-stop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/clock.svg b/crates/qbz-qt/qml/assets/icons/favorite/clock.svg new file mode 100644 index 000000000..ab8bead23 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/cloud-download.svg b/crates/qbz-qt/qml/assets/icons/favorite/cloud-download.svg new file mode 100644 index 000000000..ef2a8c016 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/cloud-download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/favorite/cloud-upload.svg new file mode 100644 index 000000000..4c117fd2b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/copy.svg b/crates/qbz-qt/qml/assets/icons/favorite/copy.svg new file mode 100644 index 000000000..f2f48af03 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/copy.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/disc-3.svg b/crates/qbz-qt/qml/assets/icons/favorite/disc-3.svg new file mode 100644 index 000000000..569ad5852 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/disc-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/disc.svg b/crates/qbz-qt/qml/assets/icons/favorite/disc.svg new file mode 100644 index 000000000..567d50982 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/disc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/element-connect.svg b/crates/qbz-qt/qml/assets/icons/favorite/element-connect.svg new file mode 100644 index 000000000..c76306d5d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/element-connect.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/favorite/external-link.svg b/crates/qbz-qt/qml/assets/icons/favorite/external-link.svg new file mode 100644 index 000000000..fdd791ddb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/external-link.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/favorite/eye-off.svg b/crates/qbz-qt/qml/assets/icons/favorite/eye-off.svg new file mode 100644 index 000000000..17978ec86 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/eye.svg b/crates/qbz-qt/qml/assets/icons/favorite/eye.svg new file mode 100644 index 000000000..1383405bd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/folder-open.svg b/crates/qbz-qt/qml/assets/icons/favorite/folder-open.svg new file mode 100644 index 000000000..a66d670f9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/folder-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/favorite/folder-plus.svg new file mode 100644 index 000000000..87ee01281 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/folder-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/folder.svg b/crates/qbz-qt/qml/assets/icons/favorite/folder.svg new file mode 100644 index 000000000..e835c675c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/favorite/heart-filled.svg new file mode 100644 index 000000000..a85714daa --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/heart-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/heart.svg b/crates/qbz-qt/qml/assets/icons/favorite/heart.svg new file mode 100644 index 000000000..f2c4e410f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/import.svg b/crates/qbz-qt/qml/assets/icons/favorite/import.svg new file mode 100644 index 000000000..0566ee95e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/infinity.svg b/crates/qbz-qt/qml/assets/icons/favorite/infinity.svg new file mode 100644 index 000000000..5011a10f3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/layers.svg b/crates/qbz-qt/qml/assets/icons/favorite/layers.svg new file mode 100644 index 000000000..6cc9846d3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/library-big.svg b/crates/qbz-qt/qml/assets/icons/favorite/library-big.svg new file mode 100644 index 000000000..a9d0adeee --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/library-big.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/link.svg b/crates/qbz-qt/qml/assets/icons/favorite/link.svg new file mode 100644 index 000000000..346a7ec08 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/link.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/list-end.svg b/crates/qbz-qt/qml/assets/icons/favorite/list-end.svg new file mode 100644 index 000000000..4b0f8e0f4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/list-end.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/list-plus.svg b/crates/qbz-qt/qml/assets/icons/favorite/list-plus.svg new file mode 100644 index 000000000..df7e3dc97 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/list-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/list-start.svg b/crates/qbz-qt/qml/assets/icons/favorite/list-start.svg new file mode 100644 index 000000000..635d89909 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/list-start.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/list-x.svg b/crates/qbz-qt/qml/assets/icons/favorite/list-x.svg new file mode 100644 index 000000000..87d1f611f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/list-x.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/map-pin.svg b/crates/qbz-qt/qml/assets/icons/favorite/map-pin.svg new file mode 100644 index 000000000..676d9fc96 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/map-pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/favorite/maximize-2.svg new file mode 100644 index 000000000..350608cf8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/maximize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/mic-vocal.svg b/crates/qbz-qt/qml/assets/icons/favorite/mic-vocal.svg new file mode 100644 index 000000000..1d59233cf --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/mic-vocal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/favorite/minimize-2.svg new file mode 100644 index 000000000..e12e22d32 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/minimize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/minus.svg b/crates/qbz-qt/qml/assets/icons/favorite/minus.svg new file mode 100644 index 000000000..d0aca1af1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/moon.svg b/crates/qbz-qt/qml/assets/icons/favorite/moon.svg new file mode 100644 index 000000000..fdf796e56 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/panel-right-close.svg b/crates/qbz-qt/qml/assets/icons/favorite/panel-right-close.svg new file mode 100644 index 000000000..f53b6ea80 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/panel-right-close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/favorite/panel-right-open.svg new file mode 100644 index 000000000..64cc0c58b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/panel-right-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/pen-line.svg b/crates/qbz-qt/qml/assets/icons/favorite/pen-line.svg new file mode 100644 index 000000000..11b574f42 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/pen-line.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/favorite/picture-in-picture-2.svg new file mode 100644 index 000000000..96e9cfc5d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/picture-in-picture-2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/favorite/pin-filled.svg new file mode 100644 index 000000000..1ad852226 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/pin-filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/pin.svg b/crates/qbz-qt/qml/assets/icons/favorite/pin.svg new file mode 100644 index 000000000..2155fd497 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/pin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/radio.svg b/crates/qbz-qt/qml/assets/icons/favorite/radio.svg new file mode 100644 index 000000000..31140dae4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/radio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/favorite/refresh-cw.svg new file mode 100644 index 000000000..1799f33ff --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/refresh-cw.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/favorite/rotate-ccw.svg new file mode 100644 index 000000000..d971b4a28 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/rows-3.svg b/crates/qbz-qt/qml/assets/icons/favorite/rows-3.svg new file mode 100644 index 000000000..077ed6462 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/rows-3.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/favorite/sliders-horizontal.svg new file mode 100644 index 000000000..e64e2e719 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/sliders-horizontal.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/square-check-big.svg b/crates/qbz-qt/qml/assets/icons/favorite/square-check-big.svg new file mode 100644 index 000000000..404db21ff --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/square-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/favorite/sun-moon.svg new file mode 100644 index 000000000..45afea114 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/sun.svg b/crates/qbz-qt/qml/assets/icons/favorite/sun.svg new file mode 100644 index 000000000..66f99ac0c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/thumbs-down.svg b/crates/qbz-qt/qml/assets/icons/favorite/thumbs-down.svg new file mode 100644 index 000000000..0c2553aba --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/thumbs-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/trash-2.svg b/crates/qbz-qt/qml/assets/icons/favorite/trash-2.svg new file mode 100644 index 000000000..4e44df1d8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/trash-2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/trash-list.svg b/crates/qbz-qt/qml/assets/icons/favorite/trash-list.svg new file mode 100644 index 000000000..bf5a7e7e1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/trash-list.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/favorite/triangle-alert.svg b/crates/qbz-qt/qml/assets/icons/favorite/triangle-alert.svg new file mode 100644 index 000000000..7532dab26 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/triangle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/x.svg b/crates/qbz-qt/qml/assets/icons/favorite/x.svg new file mode 100644 index 000000000..1cfecd508 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/green/wifi.svg b/crates/qbz-qt/qml/assets/icons/green/wifi.svg new file mode 100644 index 000000000..95b8bf44b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/green/wifi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/muted/add-to-list.svg new file mode 100644 index 000000000..e6513867c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/add-to-list.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/muted/align-start-vertical.svg b/crates/qbz-qt/qml/assets/icons/muted/align-start-vertical.svg new file mode 100644 index 000000000..4c5375ec9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/align-start-vertical.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/muted/arrow-up-down.svg new file mode 100644 index 000000000..fad490b37 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/arrow-up-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/audio-lines.svg b/crates/qbz-qt/qml/assets/icons/muted/audio-lines.svg new file mode 100644 index 000000000..0e58f854d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/audio-lines.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/award.svg b/crates/qbz-qt/qml/assets/icons/muted/award.svg new file mode 100644 index 000000000..8ae415431 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/award.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/muted/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/muted/blind-eye.svg new file mode 100644 index 000000000..4fb0897ce --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/blind-eye.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/book-open.svg b/crates/qbz-qt/qml/assets/icons/muted/book-open.svg new file mode 100644 index 000000000..942da6ebc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/book-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/bug.svg b/crates/qbz-qt/qml/assets/icons/muted/bug.svg new file mode 100644 index 000000000..fa20f8ac6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/cassette-tape.svg b/crates/qbz-qt/qml/assets/icons/muted/cassette-tape.svg new file mode 100644 index 000000000..a93f9e320 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/cassette-tape.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/cast.svg b/crates/qbz-qt/qml/assets/icons/muted/cast.svg new file mode 100644 index 000000000..a1595fa9d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/cast.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/cd.svg b/crates/qbz-qt/qml/assets/icons/muted/cd.svg new file mode 100644 index 000000000..a81e9ee8b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/muted/chart-no-axes-column.svg b/crates/qbz-qt/qml/assets/icons/muted/chart-no-axes-column.svg new file mode 100644 index 000000000..3591b73ab --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/chart-no-axes-column.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/check.svg b/crates/qbz-qt/qml/assets/icons/muted/check.svg new file mode 100644 index 000000000..f23e0f500 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/chevron-down.svg b/crates/qbz-qt/qml/assets/icons/muted/chevron-down.svg new file mode 100644 index 000000000..90743a853 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/chevron-left.svg b/crates/qbz-qt/qml/assets/icons/muted/chevron-left.svg new file mode 100644 index 000000000..3693afb0f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/chevron-right.svg b/crates/qbz-qt/qml/assets/icons/muted/chevron-right.svg new file mode 100644 index 000000000..608b0e216 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/muted/chevron-up.svg new file mode 100644 index 000000000..97e6757ca --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/circle-alert.svg b/crates/qbz-qt/qml/assets/icons/muted/circle-alert.svg new file mode 100644 index 000000000..2b82da317 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/circle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/circle-check-big.svg b/crates/qbz-qt/qml/assets/icons/muted/circle-check-big.svg new file mode 100644 index 000000000..503908d1d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/circle-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/circle-stop.svg b/crates/qbz-qt/qml/assets/icons/muted/circle-stop.svg new file mode 100644 index 000000000..0c28702a8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/circle-stop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/clipboard.svg b/crates/qbz-qt/qml/assets/icons/muted/clipboard.svg new file mode 100644 index 000000000..a523aebdc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/clipboard.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/muted/clock.svg b/crates/qbz-qt/qml/assets/icons/muted/clock.svg new file mode 100644 index 000000000..c08815417 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/cloud-download.svg b/crates/qbz-qt/qml/assets/icons/muted/cloud-download.svg new file mode 100644 index 000000000..d871e60d9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/cloud-download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/cloud-off.svg b/crates/qbz-qt/qml/assets/icons/muted/cloud-off.svg new file mode 100644 index 000000000..11d01fe80 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/cloud-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/cloud-sync.svg b/crates/qbz-qt/qml/assets/icons/muted/cloud-sync.svg new file mode 100644 index 000000000..b026e335b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/cloud-sync.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/muted/cloud-upload.svg new file mode 100644 index 000000000..af1b9c416 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/compass.svg b/crates/qbz-qt/qml/assets/icons/muted/compass.svg new file mode 100644 index 000000000..db9dc2b3c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/compass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/copy.svg b/crates/qbz-qt/qml/assets/icons/muted/copy.svg new file mode 100644 index 000000000..cf83895cd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/copy.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/muted/disc-3.svg b/crates/qbz-qt/qml/assets/icons/muted/disc-3.svg new file mode 100644 index 000000000..55fc6ad98 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/disc-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/disc-folder.svg b/crates/qbz-qt/qml/assets/icons/muted/disc-folder.svg new file mode 100644 index 000000000..a18f98873 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/disc-folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/disc.svg b/crates/qbz-qt/qml/assets/icons/muted/disc.svg new file mode 100644 index 000000000..6560545f8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/disc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/element-connect.svg b/crates/qbz-qt/qml/assets/icons/muted/element-connect.svg new file mode 100644 index 000000000..d1e473fae --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/element-connect.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/muted/ellipsis.svg b/crates/qbz-qt/qml/assets/icons/muted/ellipsis.svg new file mode 100644 index 000000000..0b480cf03 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/external-link.svg b/crates/qbz-qt/qml/assets/icons/muted/external-link.svg new file mode 100644 index 000000000..4748f5016 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/external-link.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/muted/eye-off.svg b/crates/qbz-qt/qml/assets/icons/muted/eye-off.svg new file mode 100644 index 000000000..fe0615790 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/eye.svg b/crates/qbz-qt/qml/assets/icons/muted/eye.svg new file mode 100644 index 000000000..43a1184b5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/folder-open.svg b/crates/qbz-qt/qml/assets/icons/muted/folder-open.svg new file mode 100644 index 000000000..ac6eebef4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/folder-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/muted/folder-plus.svg new file mode 100644 index 000000000..ec5800a50 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/folder-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/folder.svg b/crates/qbz-qt/qml/assets/icons/muted/folder.svg new file mode 100644 index 000000000..5c87dd816 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/gandalf.svg b/crates/qbz-qt/qml/assets/icons/muted/gandalf.svg new file mode 100644 index 000000000..2cdae4ddd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/globe.svg b/crates/qbz-qt/qml/assets/icons/muted/globe.svg new file mode 100644 index 000000000..4476d0e72 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/globe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/grip-vertical.svg b/crates/qbz-qt/qml/assets/icons/muted/grip-vertical.svg new file mode 100644 index 000000000..f53a21a8b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/grip-vertical.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/hard-drive.svg b/crates/qbz-qt/qml/assets/icons/muted/hard-drive.svg new file mode 100644 index 000000000..fa4d2048b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/hard-drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/muted/heart-filled.svg new file mode 100644 index 000000000..0ad849226 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/heart-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/heart.svg b/crates/qbz-qt/qml/assets/icons/muted/heart.svg new file mode 100644 index 000000000..98bdede6b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/home-gear.svg b/crates/qbz-qt/qml/assets/icons/muted/home-gear.svg new file mode 100644 index 000000000..5e8aea530 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/image-plus.svg b/crates/qbz-qt/qml/assets/icons/muted/image-plus.svg new file mode 100644 index 000000000..366968bee --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/image-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/image.svg b/crates/qbz-qt/qml/assets/icons/muted/image.svg new file mode 100644 index 000000000..eafe83bbd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/image.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/muted/import.svg b/crates/qbz-qt/qml/assets/icons/muted/import.svg new file mode 100644 index 000000000..5cff3a89d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/infinity.svg b/crates/qbz-qt/qml/assets/icons/muted/infinity.svg new file mode 100644 index 000000000..11d5a4447 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/info.svg b/crates/qbz-qt/qml/assets/icons/muted/info.svg new file mode 100644 index 000000000..c5e48d4fa --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/keyboard.svg b/crates/qbz-qt/qml/assets/icons/muted/keyboard.svg new file mode 100644 index 000000000..20ee493d0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/keyboard.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/layers.svg b/crates/qbz-qt/qml/assets/icons/muted/layers.svg new file mode 100644 index 000000000..68c853b50 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/layout-grid.svg b/crates/qbz-qt/qml/assets/icons/muted/layout-grid.svg new file mode 100644 index 000000000..980b9db64 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/layout-grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/library-big.svg b/crates/qbz-qt/qml/assets/icons/muted/library-big.svg new file mode 100644 index 000000000..d1e286076 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/library-big.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/library.svg b/crates/qbz-qt/qml/assets/icons/muted/library.svg new file mode 100644 index 000000000..8b1845467 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/library.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/link.svg b/crates/qbz-qt/qml/assets/icons/muted/link.svg new file mode 100644 index 000000000..ec229bc13 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/link.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-end.svg b/crates/qbz-qt/qml/assets/icons/muted/list-end.svg new file mode 100644 index 000000000..377eadafe --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/list-end.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-filter.svg b/crates/qbz-qt/qml/assets/icons/muted/list-filter.svg new file mode 100644 index 000000000..2be8e7027 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-music.svg b/crates/qbz-qt/qml/assets/icons/muted/list-music.svg new file mode 100644 index 000000000..8cb0e5034 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/list-music.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-ordered.svg b/crates/qbz-qt/qml/assets/icons/muted/list-ordered.svg new file mode 100644 index 000000000..79d37304b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/list-ordered.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-plus.svg b/crates/qbz-qt/qml/assets/icons/muted/list-plus.svg new file mode 100644 index 000000000..35a7adde0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/list-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-start.svg b/crates/qbz-qt/qml/assets/icons/muted/list-start.svg new file mode 100644 index 000000000..db628b039 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/list-start.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-x.svg b/crates/qbz-qt/qml/assets/icons/muted/list-x.svg new file mode 100644 index 000000000..9c0729751 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/list-x.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/list.svg b/crates/qbz-qt/qml/assets/icons/muted/list.svg new file mode 100644 index 000000000..12155a099 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/loader-circle.svg b/crates/qbz-qt/qml/assets/icons/muted/loader-circle.svg new file mode 100644 index 000000000..2caf244e0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/loader-circle.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/muted/log-out.svg b/crates/qbz-qt/qml/assets/icons/muted/log-out.svg new file mode 100644 index 000000000..244601f67 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/log-out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/map-pin.svg b/crates/qbz-qt/qml/assets/icons/muted/map-pin.svg new file mode 100644 index 000000000..76862ad48 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/map-pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/muted/maximize-2.svg new file mode 100644 index 000000000..3c41e535c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/maximize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/menu.svg b/crates/qbz-qt/qml/assets/icons/muted/menu.svg new file mode 100644 index 000000000..8f8f066b8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/mic-vocal.svg b/crates/qbz-qt/qml/assets/icons/muted/mic-vocal.svg new file mode 100644 index 000000000..eb45adbad --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/mic-vocal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/muted/minimize-2.svg new file mode 100644 index 000000000..7190905d6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/minimize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/minus.svg b/crates/qbz-qt/qml/assets/icons/muted/minus.svg new file mode 100644 index 000000000..abd3f85d3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/monitor-speaker.svg b/crates/qbz-qt/qml/assets/icons/muted/monitor-speaker.svg new file mode 100644 index 000000000..dddaa1001 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/monitor-speaker.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/monitor.svg b/crates/qbz-qt/qml/assets/icons/muted/monitor.svg new file mode 100644 index 000000000..389b187d7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/monitor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/moon.svg b/crates/qbz-qt/qml/assets/icons/muted/moon.svg new file mode 100644 index 000000000..ff1abdecd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/move.svg b/crates/qbz-qt/qml/assets/icons/muted/move.svg new file mode 100644 index 000000000..962f02474 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/move.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/mp3.svg b/crates/qbz-qt/qml/assets/icons/muted/mp3.svg new file mode 100644 index 000000000..96e1373aa --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/mp3.svg @@ -0,0 +1,2 @@ + +MP3 \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/muted/music-library-2.svg b/crates/qbz-qt/qml/assets/icons/muted/music-library-2.svg new file mode 100644 index 000000000..9fcff3d86 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/music-library-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/muted/music-note-slider.svg b/crates/qbz-qt/qml/assets/icons/muted/music-note-slider.svg new file mode 100644 index 000000000..c71d61fb4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/music-note-slider.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/music.svg b/crates/qbz-qt/qml/assets/icons/muted/music.svg new file mode 100644 index 000000000..5eb9dc5bc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/music.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/network.svg b/crates/qbz-qt/qml/assets/icons/muted/network.svg new file mode 100644 index 000000000..2bf0e6f9b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/network.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/panel-left.svg b/crates/qbz-qt/qml/assets/icons/muted/panel-left.svg new file mode 100644 index 000000000..6db252e8f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/panel-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/panel-right-close.svg b/crates/qbz-qt/qml/assets/icons/muted/panel-right-close.svg new file mode 100644 index 000000000..4c36c44fb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/panel-right-close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/muted/panel-right-open.svg new file mode 100644 index 000000000..0cba9f0ad --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/panel-right-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/pause.svg b/crates/qbz-qt/qml/assets/icons/muted/pause.svg new file mode 100644 index 000000000..c51927963 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/pen-line.svg b/crates/qbz-qt/qml/assets/icons/muted/pen-line.svg new file mode 100644 index 000000000..8fdbf520c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/pen-line.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/muted/picture-in-picture-2.svg new file mode 100644 index 000000000..8066ed6ff --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/picture-in-picture-2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/muted/pin-filled.svg new file mode 100644 index 000000000..64e0b9b7c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/pin-filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/pin.svg b/crates/qbz-qt/qml/assets/icons/muted/pin.svg new file mode 100644 index 000000000..4d9640417 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/pin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/play-fill.svg b/crates/qbz-qt/qml/assets/icons/muted/play-fill.svg new file mode 100644 index 000000000..0fc851ee3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/play-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/plus.svg b/crates/qbz-qt/qml/assets/icons/muted/plus.svg new file mode 100644 index 000000000..f484c989b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/qbz-symbolic.svg b/crates/qbz-qt/qml/assets/icons/muted/qbz-symbolic.svg new file mode 100644 index 000000000..f3eb73eb2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/qbz-symbolic.svg @@ -0,0 +1,32 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/radio.svg b/crates/qbz-qt/qml/assets/icons/muted/radio.svg new file mode 100644 index 000000000..9cd11e10e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/radio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/muted/refresh-cw.svg new file mode 100644 index 000000000..e63e89f1a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/refresh-cw.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/repeat-1.svg b/crates/qbz-qt/qml/assets/icons/muted/repeat-1.svg new file mode 100644 index 000000000..cbf3c6cda --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/repeat-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/repeat.svg b/crates/qbz-qt/qml/assets/icons/muted/repeat.svg new file mode 100644 index 000000000..62629dff9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/muted/rotate-ccw.svg new file mode 100644 index 000000000..7ef25ddaf --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/rows-3.svg b/crates/qbz-qt/qml/assets/icons/muted/rows-3.svg new file mode 100644 index 000000000..e2d88a57e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/rows-3.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/scissors.svg b/crates/qbz-qt/qml/assets/icons/muted/scissors.svg new file mode 100644 index 000000000..f08adaf53 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/scissors.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/muted/search.svg b/crates/qbz-qt/qml/assets/icons/muted/search.svg new file mode 100644 index 000000000..9682b1df3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/settings-2.svg b/crates/qbz-qt/qml/assets/icons/muted/settings-2.svg new file mode 100644 index 000000000..40bc0a32f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/settings-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/muted/shopping-bag.svg new file mode 100644 index 000000000..221212801 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/shopping-bag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/shuffle.svg b/crates/qbz-qt/qml/assets/icons/muted/shuffle.svg new file mode 100644 index 000000000..300f42129 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/shuffle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/skip-back.svg b/crates/qbz-qt/qml/assets/icons/muted/skip-back.svg new file mode 100644 index 000000000..9e59d24d7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/skip-back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/skip-forward.svg b/crates/qbz-qt/qml/assets/icons/muted/skip-forward.svg new file mode 100644 index 000000000..3f7c51946 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/skip-forward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/muted/sliders-horizontal.svg new file mode 100644 index 000000000..b4e3b140c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/sliders-horizontal.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/smartphone.svg b/crates/qbz-qt/qml/assets/icons/muted/smartphone.svg new file mode 100644 index 000000000..a26144522 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/smartphone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/speaker.svg b/crates/qbz-qt/qml/assets/icons/muted/speaker.svg new file mode 100644 index 000000000..b49530ff2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/speaker.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/square-check-big.svg b/crates/qbz-qt/qml/assets/icons/muted/square-check-big.svg new file mode 100644 index 000000000..c16f03f59 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/square-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/square.svg b/crates/qbz-qt/qml/assets/icons/muted/square.svg new file mode 100644 index 000000000..4a607870a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/square.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/star.svg b/crates/qbz-qt/qml/assets/icons/muted/star.svg new file mode 100644 index 000000000..07617eb6b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/muted/sun-moon.svg new file mode 100644 index 000000000..731f18a34 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/sun.svg b/crates/qbz-qt/qml/assets/icons/muted/sun.svg new file mode 100644 index 000000000..fcfe13407 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/tags.svg b/crates/qbz-qt/qml/assets/icons/muted/tags.svg new file mode 100644 index 000000000..bbefb6b11 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/tags.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/thumbs-down.svg b/crates/qbz-qt/qml/assets/icons/muted/thumbs-down.svg new file mode 100644 index 000000000..4118bff1b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/thumbs-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/ticket-check.svg b/crates/qbz-qt/qml/assets/icons/muted/ticket-check.svg new file mode 100644 index 000000000..10c83daac --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/ticket-check.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/tidal-tidal.svg b/crates/qbz-qt/qml/assets/icons/muted/tidal-tidal.svg new file mode 100644 index 000000000..9e00c260c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/tidal-tidal.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/translate.svg b/crates/qbz-qt/qml/assets/icons/muted/translate.svg new file mode 100644 index 000000000..3be24420c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/translate.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/muted/trash-2.svg b/crates/qbz-qt/qml/assets/icons/muted/trash-2.svg new file mode 100644 index 000000000..22df967e7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/trash-2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/trash-list.svg b/crates/qbz-qt/qml/assets/icons/muted/trash-list.svg new file mode 100644 index 000000000..ab8ebb77d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/trash-list.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/muted/triangle-alert.svg b/crates/qbz-qt/qml/assets/icons/muted/triangle-alert.svg new file mode 100644 index 000000000..3bb2b537e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/triangle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/turntable.svg b/crates/qbz-qt/qml/assets/icons/muted/turntable.svg new file mode 100644 index 000000000..67b9ff9f6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/turntable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/user-plus.svg b/crates/qbz-qt/qml/assets/icons/muted/user-plus.svg new file mode 100644 index 000000000..004f13ceb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/user-plus.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/muted/user.svg b/crates/qbz-qt/qml/assets/icons/muted/user.svg new file mode 100644 index 000000000..9b03cffd7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/volume-2.svg b/crates/qbz-qt/qml/assets/icons/muted/volume-2.svg new file mode 100644 index 000000000..044e93d33 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/volume-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/volume-x.svg b/crates/qbz-qt/qml/assets/icons/muted/volume-x.svg new file mode 100644 index 000000000..c2347bb9d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/volume-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/muted/wand-sparkles.svg new file mode 100644 index 000000000..88f1e7202 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/wifi.svg b/crates/qbz-qt/qml/assets/icons/muted/wifi.svg new file mode 100644 index 000000000..4a04abc1a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/wifi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/x.svg b/crates/qbz-qt/qml/assets/icons/muted/x.svg new file mode 100644 index 000000000..95df8d044 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/orange/cloud-download.svg b/crates/qbz-qt/qml/assets/icons/orange/cloud-download.svg new file mode 100644 index 000000000..fb2308054 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/orange/cloud-download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/primary/add-to-list.svg new file mode 100644 index 000000000..bb55e8871 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/add-to-list.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/icons/align-start-vertical.svg b/crates/qbz-qt/qml/assets/icons/primary/align-start-vertical.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/align-start-vertical.svg rename to crates/qbz-qt/qml/assets/icons/primary/align-start-vertical.svg diff --git a/crates/qbz-ui/ui/assets/icons/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/primary/arrow-up-down.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/arrow-up-down.svg rename to crates/qbz-qt/qml/assets/icons/primary/arrow-up-down.svg diff --git a/crates/qbz-ui/ui/assets/icons/audio-lines.svg b/crates/qbz-qt/qml/assets/icons/primary/audio-lines.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/audio-lines.svg rename to crates/qbz-qt/qml/assets/icons/primary/audio-lines.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/award.svg b/crates/qbz-qt/qml/assets/icons/primary/award.svg new file mode 100644 index 000000000..b1b2e6679 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/award.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-ui/ui/assets/icons/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/primary/blind-eye.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/blind-eye.svg rename to crates/qbz-qt/qml/assets/icons/primary/blind-eye.svg diff --git a/crates/qbz-ui/ui/assets/icons/book-open.svg b/crates/qbz-qt/qml/assets/icons/primary/book-open.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/book-open.svg rename to crates/qbz-qt/qml/assets/icons/primary/book-open.svg diff --git a/crates/qbz-ui/ui/assets/icons/bug.svg b/crates/qbz-qt/qml/assets/icons/primary/bug.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/bug.svg rename to crates/qbz-qt/qml/assets/icons/primary/bug.svg diff --git a/crates/qbz-ui/ui/assets/icons/cassette-tape.svg b/crates/qbz-qt/qml/assets/icons/primary/cassette-tape.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/cassette-tape.svg rename to crates/qbz-qt/qml/assets/icons/primary/cassette-tape.svg diff --git a/crates/qbz-ui/ui/assets/icons/cast.svg b/crates/qbz-qt/qml/assets/icons/primary/cast.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/cast.svg rename to crates/qbz-qt/qml/assets/icons/primary/cast.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/cd.svg b/crates/qbz-qt/qml/assets/icons/primary/cd.svg new file mode 100644 index 000000000..e1cf434fb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/icons/chart-no-axes-column.svg b/crates/qbz-qt/qml/assets/icons/primary/chart-no-axes-column.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/chart-no-axes-column.svg rename to crates/qbz-qt/qml/assets/icons/primary/chart-no-axes-column.svg diff --git a/crates/qbz-ui/ui/assets/icons/check.svg b/crates/qbz-qt/qml/assets/icons/primary/check.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/check.svg rename to crates/qbz-qt/qml/assets/icons/primary/check.svg diff --git a/crates/qbz-ui/ui/assets/icons/chevron-down.svg b/crates/qbz-qt/qml/assets/icons/primary/chevron-down.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/chevron-down.svg rename to crates/qbz-qt/qml/assets/icons/primary/chevron-down.svg diff --git a/crates/qbz-ui/ui/assets/icons/chevron-left.svg b/crates/qbz-qt/qml/assets/icons/primary/chevron-left.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/chevron-left.svg rename to crates/qbz-qt/qml/assets/icons/primary/chevron-left.svg diff --git a/crates/qbz-ui/ui/assets/icons/chevron-right.svg b/crates/qbz-qt/qml/assets/icons/primary/chevron-right.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/chevron-right.svg rename to crates/qbz-qt/qml/assets/icons/primary/chevron-right.svg diff --git a/crates/qbz-ui/ui/assets/icons/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/primary/chevron-up.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/chevron-up.svg rename to crates/qbz-qt/qml/assets/icons/primary/chevron-up.svg diff --git a/crates/qbz-ui/ui/assets/icons/circle-alert.svg b/crates/qbz-qt/qml/assets/icons/primary/circle-alert.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/circle-alert.svg rename to crates/qbz-qt/qml/assets/icons/primary/circle-alert.svg diff --git a/crates/qbz-ui/ui/assets/icons/circle-check-big.svg b/crates/qbz-qt/qml/assets/icons/primary/circle-check-big.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/circle-check-big.svg rename to crates/qbz-qt/qml/assets/icons/primary/circle-check-big.svg diff --git a/crates/qbz-ui/ui/assets/icons/circle-stop.svg b/crates/qbz-qt/qml/assets/icons/primary/circle-stop.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/circle-stop.svg rename to crates/qbz-qt/qml/assets/icons/primary/circle-stop.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/clipboard.svg b/crates/qbz-qt/qml/assets/icons/primary/clipboard.svg new file mode 100644 index 000000000..93185d37d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/clipboard.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-ui/ui/assets/icons/clock.svg b/crates/qbz-qt/qml/assets/icons/primary/clock.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/clock.svg rename to crates/qbz-qt/qml/assets/icons/primary/clock.svg diff --git a/crates/qbz-ui/ui/assets/icons/cloud-download.svg b/crates/qbz-qt/qml/assets/icons/primary/cloud-download.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/cloud-download.svg rename to crates/qbz-qt/qml/assets/icons/primary/cloud-download.svg diff --git a/crates/qbz-ui/ui/assets/icons/cloud-off.svg b/crates/qbz-qt/qml/assets/icons/primary/cloud-off.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/cloud-off.svg rename to crates/qbz-qt/qml/assets/icons/primary/cloud-off.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/cloud-sync.svg b/crates/qbz-qt/qml/assets/icons/primary/cloud-sync.svg new file mode 100644 index 000000000..ee286d917 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/cloud-sync.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/qbz-ui/ui/assets/icons/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/primary/cloud-upload.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/cloud-upload.svg rename to crates/qbz-qt/qml/assets/icons/primary/cloud-upload.svg diff --git a/crates/qbz-ui/ui/assets/icons/compass.svg b/crates/qbz-qt/qml/assets/icons/primary/compass.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/compass.svg rename to crates/qbz-qt/qml/assets/icons/primary/compass.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/copy.svg b/crates/qbz-qt/qml/assets/icons/primary/copy.svg new file mode 100644 index 000000000..530801483 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/copy.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-ui/ui/assets/icons/crown.svg b/crates/qbz-qt/qml/assets/icons/primary/crown.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/crown.svg rename to crates/qbz-qt/qml/assets/icons/primary/crown.svg diff --git a/crates/qbz-ui/ui/assets/icons/disc-3.svg b/crates/qbz-qt/qml/assets/icons/primary/disc-3.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/disc-3.svg rename to crates/qbz-qt/qml/assets/icons/primary/disc-3.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/disc-folder.svg b/crates/qbz-qt/qml/assets/icons/primary/disc-folder.svg new file mode 100644 index 000000000..e585737d3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/disc-folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-ui/ui/assets/icons/disc.svg b/crates/qbz-qt/qml/assets/icons/primary/disc.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/disc.svg rename to crates/qbz-qt/qml/assets/icons/primary/disc.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/element-connect.svg b/crates/qbz-qt/qml/assets/icons/primary/element-connect.svg new file mode 100644 index 000000000..95b7d6252 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/element-connect.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/icons/ellipsis.svg b/crates/qbz-qt/qml/assets/icons/primary/ellipsis.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/ellipsis.svg rename to crates/qbz-qt/qml/assets/icons/primary/ellipsis.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/external-link.svg b/crates/qbz-qt/qml/assets/icons/primary/external-link.svg new file mode 100644 index 000000000..0ca3390c8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/external-link.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/icons/eye-off.svg b/crates/qbz-qt/qml/assets/icons/primary/eye-off.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/eye-off.svg rename to crates/qbz-qt/qml/assets/icons/primary/eye-off.svg diff --git a/crates/qbz-ui/ui/assets/icons/eye.svg b/crates/qbz-qt/qml/assets/icons/primary/eye.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/eye.svg rename to crates/qbz-qt/qml/assets/icons/primary/eye.svg diff --git a/crates/qbz-ui/ui/assets/icons/folder-open.svg b/crates/qbz-qt/qml/assets/icons/primary/folder-open.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/folder-open.svg rename to crates/qbz-qt/qml/assets/icons/primary/folder-open.svg diff --git a/crates/qbz-ui/ui/assets/icons/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/primary/folder-plus.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/folder-plus.svg rename to crates/qbz-qt/qml/assets/icons/primary/folder-plus.svg diff --git a/crates/qbz-ui/ui/assets/icons/folder.svg b/crates/qbz-qt/qml/assets/icons/primary/folder.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/folder.svg rename to crates/qbz-qt/qml/assets/icons/primary/folder.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/gandalf.svg b/crates/qbz-qt/qml/assets/icons/primary/gandalf.svg new file mode 100644 index 000000000..ff0b359cf --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-ui/ui/assets/icons/globe.svg b/crates/qbz-qt/qml/assets/icons/primary/globe.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/globe.svg rename to crates/qbz-qt/qml/assets/icons/primary/globe.svg diff --git a/crates/qbz-ui/ui/assets/icons/grip-vertical.svg b/crates/qbz-qt/qml/assets/icons/primary/grip-vertical.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/grip-vertical.svg rename to crates/qbz-qt/qml/assets/icons/primary/grip-vertical.svg diff --git a/crates/qbz-ui/ui/assets/icons/hard-drive.svg b/crates/qbz-qt/qml/assets/icons/primary/hard-drive.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/hard-drive.svg rename to crates/qbz-qt/qml/assets/icons/primary/hard-drive.svg diff --git a/crates/qbz-ui/ui/assets/icons/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/primary/heart-filled.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/heart-filled.svg rename to crates/qbz-qt/qml/assets/icons/primary/heart-filled.svg diff --git a/crates/qbz-ui/ui/assets/icons/heart.svg b/crates/qbz-qt/qml/assets/icons/primary/heart.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/heart.svg rename to crates/qbz-qt/qml/assets/icons/primary/heart.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/home-gear.svg b/crates/qbz-qt/qml/assets/icons/primary/home-gear.svg new file mode 100644 index 000000000..e8d3459f9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-ui/ui/assets/icons/image-plus.svg b/crates/qbz-qt/qml/assets/icons/primary/image-plus.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/image-plus.svg rename to crates/qbz-qt/qml/assets/icons/primary/image-plus.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/image.svg b/crates/qbz-qt/qml/assets/icons/primary/image.svg new file mode 100644 index 000000000..71128aa9b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/image.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-ui/ui/assets/icons/import.svg b/crates/qbz-qt/qml/assets/icons/primary/import.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/import.svg rename to crates/qbz-qt/qml/assets/icons/primary/import.svg diff --git a/crates/qbz-ui/ui/assets/icons/infinity.svg b/crates/qbz-qt/qml/assets/icons/primary/infinity.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/infinity.svg rename to crates/qbz-qt/qml/assets/icons/primary/infinity.svg diff --git a/crates/qbz-ui/ui/assets/icons/info.svg b/crates/qbz-qt/qml/assets/icons/primary/info.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/info.svg rename to crates/qbz-qt/qml/assets/icons/primary/info.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/jellyfin.svg b/crates/qbz-qt/qml/assets/icons/primary/jellyfin.svg new file mode 100644 index 000000000..0c27da1b9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/jellyfin.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/icons/keyboard.svg b/crates/qbz-qt/qml/assets/icons/primary/keyboard.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/keyboard.svg rename to crates/qbz-qt/qml/assets/icons/primary/keyboard.svg diff --git a/crates/qbz-ui/ui/assets/icons/layers.svg b/crates/qbz-qt/qml/assets/icons/primary/layers.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/layers.svg rename to crates/qbz-qt/qml/assets/icons/primary/layers.svg diff --git a/crates/qbz-ui/ui/assets/icons/layout-grid.svg b/crates/qbz-qt/qml/assets/icons/primary/layout-grid.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/layout-grid.svg rename to crates/qbz-qt/qml/assets/icons/primary/layout-grid.svg diff --git a/crates/qbz-ui/ui/assets/icons/library-big.svg b/crates/qbz-qt/qml/assets/icons/primary/library-big.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/library-big.svg rename to crates/qbz-qt/qml/assets/icons/primary/library-big.svg diff --git a/crates/qbz-ui/ui/assets/icons/library.svg b/crates/qbz-qt/qml/assets/icons/primary/library.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/library.svg rename to crates/qbz-qt/qml/assets/icons/primary/library.svg diff --git a/crates/qbz-ui/ui/assets/icons/link.svg b/crates/qbz-qt/qml/assets/icons/primary/link.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/link.svg rename to crates/qbz-qt/qml/assets/icons/primary/link.svg diff --git a/crates/qbz-ui/ui/assets/icons/list-end.svg b/crates/qbz-qt/qml/assets/icons/primary/list-end.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/list-end.svg rename to crates/qbz-qt/qml/assets/icons/primary/list-end.svg diff --git a/crates/qbz-ui/ui/assets/icons/list-filter.svg b/crates/qbz-qt/qml/assets/icons/primary/list-filter.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/list-filter.svg rename to crates/qbz-qt/qml/assets/icons/primary/list-filter.svg diff --git a/crates/qbz-ui/ui/assets/icons/list-music.svg b/crates/qbz-qt/qml/assets/icons/primary/list-music.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/list-music.svg rename to crates/qbz-qt/qml/assets/icons/primary/list-music.svg diff --git a/crates/qbz-ui/ui/assets/icons/list-ordered.svg b/crates/qbz-qt/qml/assets/icons/primary/list-ordered.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/list-ordered.svg rename to crates/qbz-qt/qml/assets/icons/primary/list-ordered.svg diff --git a/crates/qbz-ui/ui/assets/icons/list-plus.svg b/crates/qbz-qt/qml/assets/icons/primary/list-plus.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/list-plus.svg rename to crates/qbz-qt/qml/assets/icons/primary/list-plus.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/list-start.svg b/crates/qbz-qt/qml/assets/icons/primary/list-start.svg new file mode 100644 index 000000000..6861506e9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/list-start.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-ui/ui/assets/icons/list-x.svg b/crates/qbz-qt/qml/assets/icons/primary/list-x.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/list-x.svg rename to crates/qbz-qt/qml/assets/icons/primary/list-x.svg diff --git a/crates/qbz-ui/ui/assets/icons/list.svg b/crates/qbz-qt/qml/assets/icons/primary/list.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/list.svg rename to crates/qbz-qt/qml/assets/icons/primary/list.svg diff --git a/crates/qbz-ui/ui/assets/icons/loader-circle.svg b/crates/qbz-qt/qml/assets/icons/primary/loader-circle.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/loader-circle.svg rename to crates/qbz-qt/qml/assets/icons/primary/loader-circle.svg diff --git a/crates/qbz-ui/ui/assets/icons/log-out.svg b/crates/qbz-qt/qml/assets/icons/primary/log-out.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/log-out.svg rename to crates/qbz-qt/qml/assets/icons/primary/log-out.svg diff --git a/crates/qbz-ui/ui/assets/icons/map-pin.svg b/crates/qbz-qt/qml/assets/icons/primary/map-pin.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/map-pin.svg rename to crates/qbz-qt/qml/assets/icons/primary/map-pin.svg diff --git a/crates/qbz-ui/ui/assets/icons/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/primary/maximize-2.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/maximize-2.svg rename to crates/qbz-qt/qml/assets/icons/primary/maximize-2.svg diff --git a/crates/qbz-ui/ui/assets/icons/menu.svg b/crates/qbz-qt/qml/assets/icons/primary/menu.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/menu.svg rename to crates/qbz-qt/qml/assets/icons/primary/menu.svg diff --git a/crates/qbz-ui/ui/assets/icons/mic-vocal.svg b/crates/qbz-qt/qml/assets/icons/primary/mic-vocal.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/mic-vocal.svg rename to crates/qbz-qt/qml/assets/icons/primary/mic-vocal.svg diff --git a/crates/qbz-ui/ui/assets/icons/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/primary/minimize-2.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/minimize-2.svg rename to crates/qbz-qt/qml/assets/icons/primary/minimize-2.svg diff --git a/crates/qbz-ui/ui/assets/icons/minus.svg b/crates/qbz-qt/qml/assets/icons/primary/minus.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/minus.svg rename to crates/qbz-qt/qml/assets/icons/primary/minus.svg diff --git a/crates/qbz-ui/ui/assets/icons/monitor-speaker.svg b/crates/qbz-qt/qml/assets/icons/primary/monitor-speaker.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/monitor-speaker.svg rename to crates/qbz-qt/qml/assets/icons/primary/monitor-speaker.svg diff --git a/crates/qbz-ui/ui/assets/icons/monitor.svg b/crates/qbz-qt/qml/assets/icons/primary/monitor.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/monitor.svg rename to crates/qbz-qt/qml/assets/icons/primary/monitor.svg diff --git a/crates/qbz-ui/ui/assets/icons/moon.svg b/crates/qbz-qt/qml/assets/icons/primary/moon.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/moon.svg rename to crates/qbz-qt/qml/assets/icons/primary/moon.svg diff --git a/crates/qbz-ui/ui/assets/icons/move.svg b/crates/qbz-qt/qml/assets/icons/primary/move.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/move.svg rename to crates/qbz-qt/qml/assets/icons/primary/move.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/mp3.svg b/crates/qbz-qt/qml/assets/icons/primary/mp3.svg new file mode 100644 index 000000000..fd9517e91 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/mp3.svg @@ -0,0 +1,2 @@ + +MP3 \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/primary/music-library-2.svg b/crates/qbz-qt/qml/assets/icons/primary/music-library-2.svg new file mode 100644 index 000000000..d878f8aaa --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/music-library-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/primary/music-note-slider.svg b/crates/qbz-qt/qml/assets/icons/primary/music-note-slider.svg new file mode 100644 index 000000000..af4c7aed1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/music-note-slider.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-ui/ui/assets/icons/music.svg b/crates/qbz-qt/qml/assets/icons/primary/music.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/music.svg rename to crates/qbz-qt/qml/assets/icons/primary/music.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/navidrome.svg b/crates/qbz-qt/qml/assets/icons/primary/navidrome.svg new file mode 100644 index 000000000..074395676 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/navidrome.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/icons/network.svg b/crates/qbz-qt/qml/assets/icons/primary/network.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/network.svg rename to crates/qbz-qt/qml/assets/icons/primary/network.svg diff --git a/crates/qbz-ui/ui/assets/icons/panel-left.svg b/crates/qbz-qt/qml/assets/icons/primary/panel-left.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/panel-left.svg rename to crates/qbz-qt/qml/assets/icons/primary/panel-left.svg diff --git a/crates/qbz-ui/ui/assets/icons/panel-right-close.svg b/crates/qbz-qt/qml/assets/icons/primary/panel-right-close.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/panel-right-close.svg rename to crates/qbz-qt/qml/assets/icons/primary/panel-right-close.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/primary/panel-right-open.svg new file mode 100644 index 000000000..0fe7bd153 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/panel-right-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-ui/ui/assets/icons/pause.svg b/crates/qbz-qt/qml/assets/icons/primary/pause.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/pause.svg rename to crates/qbz-qt/qml/assets/icons/primary/pause.svg diff --git a/crates/qbz-ui/ui/assets/icons/pen-line.svg b/crates/qbz-qt/qml/assets/icons/primary/pen-line.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/pen-line.svg rename to crates/qbz-qt/qml/assets/icons/primary/pen-line.svg diff --git a/crates/qbz-ui/ui/assets/icons/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/primary/picture-in-picture-2.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/picture-in-picture-2.svg rename to crates/qbz-qt/qml/assets/icons/primary/picture-in-picture-2.svg diff --git a/crates/qbz-ui/ui/assets/icons/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/primary/pin-filled.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/pin-filled.svg rename to crates/qbz-qt/qml/assets/icons/primary/pin-filled.svg diff --git a/crates/qbz-ui/ui/assets/icons/pin.svg b/crates/qbz-qt/qml/assets/icons/primary/pin.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/pin.svg rename to crates/qbz-qt/qml/assets/icons/primary/pin.svg diff --git a/crates/qbz-ui/ui/assets/icons/play-fill.svg b/crates/qbz-qt/qml/assets/icons/primary/play-fill.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/play-fill.svg rename to crates/qbz-qt/qml/assets/icons/primary/play-fill.svg diff --git a/crates/qbz-ui/ui/assets/icons/plus.svg b/crates/qbz-qt/qml/assets/icons/primary/plus.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/plus.svg rename to crates/qbz-qt/qml/assets/icons/primary/plus.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/qbz-symbolic.svg b/crates/qbz-qt/qml/assets/icons/primary/qbz-symbolic.svg new file mode 100644 index 000000000..e02ee4a65 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/qbz-symbolic.svg @@ -0,0 +1,32 @@ + + + + + + diff --git a/crates/qbz-ui/ui/assets/icons/radio.svg b/crates/qbz-qt/qml/assets/icons/primary/radio.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/radio.svg rename to crates/qbz-qt/qml/assets/icons/primary/radio.svg diff --git a/crates/qbz-ui/ui/assets/icons/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/primary/refresh-cw.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/refresh-cw.svg rename to crates/qbz-qt/qml/assets/icons/primary/refresh-cw.svg diff --git a/crates/qbz-ui/ui/assets/icons/repeat-1.svg b/crates/qbz-qt/qml/assets/icons/primary/repeat-1.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/repeat-1.svg rename to crates/qbz-qt/qml/assets/icons/primary/repeat-1.svg diff --git a/crates/qbz-ui/ui/assets/icons/repeat.svg b/crates/qbz-qt/qml/assets/icons/primary/repeat.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/repeat.svg rename to crates/qbz-qt/qml/assets/icons/primary/repeat.svg diff --git a/crates/qbz-ui/ui/assets/icons/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/primary/rotate-ccw.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/rotate-ccw.svg rename to crates/qbz-qt/qml/assets/icons/primary/rotate-ccw.svg diff --git a/crates/qbz-ui/ui/assets/icons/rows-3.svg b/crates/qbz-qt/qml/assets/icons/primary/rows-3.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/rows-3.svg rename to crates/qbz-qt/qml/assets/icons/primary/rows-3.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/scissors.svg b/crates/qbz-qt/qml/assets/icons/primary/scissors.svg new file mode 100644 index 000000000..d897b9d56 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/scissors.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-ui/ui/assets/icons/search.svg b/crates/qbz-qt/qml/assets/icons/primary/search.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/search.svg rename to crates/qbz-qt/qml/assets/icons/primary/search.svg diff --git a/crates/qbz-ui/ui/assets/icons/settings-2.svg b/crates/qbz-qt/qml/assets/icons/primary/settings-2.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/settings-2.svg rename to crates/qbz-qt/qml/assets/icons/primary/settings-2.svg diff --git a/crates/qbz-ui/ui/assets/icons/settings.svg b/crates/qbz-qt/qml/assets/icons/primary/settings.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/settings.svg rename to crates/qbz-qt/qml/assets/icons/primary/settings.svg diff --git a/crates/qbz-ui/ui/assets/icons/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/primary/shopping-bag.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/shopping-bag.svg rename to crates/qbz-qt/qml/assets/icons/primary/shopping-bag.svg diff --git a/crates/qbz-ui/ui/assets/icons/shuffle.svg b/crates/qbz-qt/qml/assets/icons/primary/shuffle.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/shuffle.svg rename to crates/qbz-qt/qml/assets/icons/primary/shuffle.svg diff --git a/crates/qbz-ui/ui/assets/icons/skip-back.svg b/crates/qbz-qt/qml/assets/icons/primary/skip-back.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/skip-back.svg rename to crates/qbz-qt/qml/assets/icons/primary/skip-back.svg diff --git a/crates/qbz-ui/ui/assets/icons/skip-forward.svg b/crates/qbz-qt/qml/assets/icons/primary/skip-forward.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/skip-forward.svg rename to crates/qbz-qt/qml/assets/icons/primary/skip-forward.svg diff --git a/crates/qbz-ui/ui/assets/icons/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/primary/sliders-horizontal.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/sliders-horizontal.svg rename to crates/qbz-qt/qml/assets/icons/primary/sliders-horizontal.svg diff --git a/crates/qbz-ui/ui/assets/icons/smartphone.svg b/crates/qbz-qt/qml/assets/icons/primary/smartphone.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/smartphone.svg rename to crates/qbz-qt/qml/assets/icons/primary/smartphone.svg diff --git a/crates/qbz-ui/ui/assets/icons/speaker.svg b/crates/qbz-qt/qml/assets/icons/primary/speaker.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/speaker.svg rename to crates/qbz-qt/qml/assets/icons/primary/speaker.svg diff --git a/crates/qbz-ui/ui/assets/icons/square-check-big.svg b/crates/qbz-qt/qml/assets/icons/primary/square-check-big.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/square-check-big.svg rename to crates/qbz-qt/qml/assets/icons/primary/square-check-big.svg diff --git a/crates/qbz-ui/ui/assets/icons/square.svg b/crates/qbz-qt/qml/assets/icons/primary/square.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/square.svg rename to crates/qbz-qt/qml/assets/icons/primary/square.svg diff --git a/crates/qbz-ui/ui/assets/icons/star.svg b/crates/qbz-qt/qml/assets/icons/primary/star.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/star.svg rename to crates/qbz-qt/qml/assets/icons/primary/star.svg diff --git a/crates/qbz-ui/ui/assets/icons/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/primary/sun-moon.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/sun-moon.svg rename to crates/qbz-qt/qml/assets/icons/primary/sun-moon.svg diff --git a/crates/qbz-ui/ui/assets/icons/sun.svg b/crates/qbz-qt/qml/assets/icons/primary/sun.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/sun.svg rename to crates/qbz-qt/qml/assets/icons/primary/sun.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/tags.svg b/crates/qbz-qt/qml/assets/icons/primary/tags.svg new file mode 100644 index 000000000..5abf08deb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/tags.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-ui/ui/assets/icons/thumbs-down.svg b/crates/qbz-qt/qml/assets/icons/primary/thumbs-down.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/thumbs-down.svg rename to crates/qbz-qt/qml/assets/icons/primary/thumbs-down.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/ticket-check.svg b/crates/qbz-qt/qml/assets/icons/primary/ticket-check.svg new file mode 100644 index 000000000..82573cf8b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/ticket-check.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/tidal-tidal.svg b/crates/qbz-qt/qml/assets/icons/primary/tidal-tidal.svg new file mode 100644 index 000000000..78278177f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/tidal-tidal.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/translate.svg b/crates/qbz-qt/qml/assets/icons/primary/translate.svg new file mode 100644 index 000000000..7ae9a475c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/translate.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/icons/trash-2.svg b/crates/qbz-qt/qml/assets/icons/primary/trash-2.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/trash-2.svg rename to crates/qbz-qt/qml/assets/icons/primary/trash-2.svg diff --git a/crates/qbz-qt/qml/assets/icons/primary/trash-list.svg b/crates/qbz-qt/qml/assets/icons/primary/trash-list.svg new file mode 100644 index 000000000..388ab12ea --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/trash-list.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/icons/triangle-alert.svg b/crates/qbz-qt/qml/assets/icons/primary/triangle-alert.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/triangle-alert.svg rename to crates/qbz-qt/qml/assets/icons/primary/triangle-alert.svg diff --git a/crates/qbz-ui/ui/assets/icons/turntable.svg b/crates/qbz-qt/qml/assets/icons/primary/turntable.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/turntable.svg rename to crates/qbz-qt/qml/assets/icons/primary/turntable.svg diff --git a/crates/qbz-ui/ui/assets/icons/user-plus.svg b/crates/qbz-qt/qml/assets/icons/primary/user-plus.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/user-plus.svg rename to crates/qbz-qt/qml/assets/icons/primary/user-plus.svg diff --git a/crates/qbz-ui/ui/assets/icons/user.svg b/crates/qbz-qt/qml/assets/icons/primary/user.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/user.svg rename to crates/qbz-qt/qml/assets/icons/primary/user.svg diff --git a/crates/qbz-ui/ui/assets/icons/volume-2.svg b/crates/qbz-qt/qml/assets/icons/primary/volume-2.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/volume-2.svg rename to crates/qbz-qt/qml/assets/icons/primary/volume-2.svg diff --git a/crates/qbz-ui/ui/assets/icons/volume-x.svg b/crates/qbz-qt/qml/assets/icons/primary/volume-x.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/volume-x.svg rename to crates/qbz-qt/qml/assets/icons/primary/volume-x.svg diff --git a/crates/qbz-ui/ui/assets/icons/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/primary/wand-sparkles.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/wand-sparkles.svg rename to crates/qbz-qt/qml/assets/icons/primary/wand-sparkles.svg diff --git a/crates/qbz-ui/ui/assets/icons/wifi.svg b/crates/qbz-qt/qml/assets/icons/primary/wifi.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/wifi.svg rename to crates/qbz-qt/qml/assets/icons/primary/wifi.svg diff --git a/crates/qbz-ui/ui/assets/icons/x.svg b/crates/qbz-qt/qml/assets/icons/primary/x.svg similarity index 100% rename from crates/qbz-ui/ui/assets/icons/x.svg rename to crates/qbz-qt/qml/assets/icons/primary/x.svg diff --git a/crates/qbz-qt/qml/assets/icons/secondary/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/secondary/add-to-list.svg new file mode 100644 index 000000000..aa001dfcc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/add-to-list.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/secondary/align-start-vertical.svg b/crates/qbz-qt/qml/assets/icons/secondary/align-start-vertical.svg new file mode 100644 index 000000000..b3f6abee2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/align-start-vertical.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/secondary/arrow-up-down.svg new file mode 100644 index 000000000..f6f62eda3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/arrow-up-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/audio-lines.svg b/crates/qbz-qt/qml/assets/icons/secondary/audio-lines.svg new file mode 100644 index 000000000..610b02950 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/audio-lines.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/award.svg b/crates/qbz-qt/qml/assets/icons/secondary/award.svg new file mode 100644 index 000000000..4d5a10eec --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/award.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/secondary/blind-eye.svg new file mode 100644 index 000000000..071fe9d01 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/blind-eye.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/book-open.svg b/crates/qbz-qt/qml/assets/icons/secondary/book-open.svg new file mode 100644 index 000000000..6f9b8631e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/book-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/bug.svg b/crates/qbz-qt/qml/assets/icons/secondary/bug.svg new file mode 100644 index 000000000..113399614 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cassette-tape.svg b/crates/qbz-qt/qml/assets/icons/secondary/cassette-tape.svg new file mode 100644 index 000000000..7891783e5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/cassette-tape.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cast.svg b/crates/qbz-qt/qml/assets/icons/secondary/cast.svg new file mode 100644 index 000000000..33ef119c0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/cast.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cd.svg b/crates/qbz-qt/qml/assets/icons/secondary/cd.svg new file mode 100644 index 000000000..d34d68005 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/secondary/chart-no-axes-column.svg b/crates/qbz-qt/qml/assets/icons/secondary/chart-no-axes-column.svg new file mode 100644 index 000000000..ce97ccedb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/chart-no-axes-column.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/check.svg b/crates/qbz-qt/qml/assets/icons/secondary/check.svg new file mode 100644 index 000000000..42936cd87 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/chevron-down.svg b/crates/qbz-qt/qml/assets/icons/secondary/chevron-down.svg new file mode 100644 index 000000000..eba86e002 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/chevron-left.svg b/crates/qbz-qt/qml/assets/icons/secondary/chevron-left.svg new file mode 100644 index 000000000..101766ef4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/chevron-right.svg b/crates/qbz-qt/qml/assets/icons/secondary/chevron-right.svg new file mode 100644 index 000000000..5b53e429c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/secondary/chevron-up.svg new file mode 100644 index 000000000..be5c94d5e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/circle-alert.svg b/crates/qbz-qt/qml/assets/icons/secondary/circle-alert.svg new file mode 100644 index 000000000..22a745c6b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/circle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/circle-check-big.svg b/crates/qbz-qt/qml/assets/icons/secondary/circle-check-big.svg new file mode 100644 index 000000000..d4a9fc54c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/circle-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/circle-stop.svg b/crates/qbz-qt/qml/assets/icons/secondary/circle-stop.svg new file mode 100644 index 000000000..3b88e5cc3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/circle-stop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/clipboard.svg b/crates/qbz-qt/qml/assets/icons/secondary/clipboard.svg new file mode 100644 index 000000000..2a7c39a8a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/clipboard.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/clock.svg b/crates/qbz-qt/qml/assets/icons/secondary/clock.svg new file mode 100644 index 000000000..db275ab66 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cloud-download.svg b/crates/qbz-qt/qml/assets/icons/secondary/cloud-download.svg new file mode 100644 index 000000000..3ca6e17c2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/cloud-download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cloud-off.svg b/crates/qbz-qt/qml/assets/icons/secondary/cloud-off.svg new file mode 100644 index 000000000..ec85f05f7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/cloud-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cloud-sync.svg b/crates/qbz-qt/qml/assets/icons/secondary/cloud-sync.svg new file mode 100644 index 000000000..1acd21de1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/cloud-sync.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/secondary/cloud-upload.svg new file mode 100644 index 000000000..a68510866 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/compass.svg b/crates/qbz-qt/qml/assets/icons/secondary/compass.svg new file mode 100644 index 000000000..abc2b0672 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/compass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/copy.svg b/crates/qbz-qt/qml/assets/icons/secondary/copy.svg new file mode 100644 index 000000000..032244585 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/copy.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/disc-3.svg b/crates/qbz-qt/qml/assets/icons/secondary/disc-3.svg new file mode 100644 index 000000000..7c4b69c1e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/disc-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/disc-folder.svg b/crates/qbz-qt/qml/assets/icons/secondary/disc-folder.svg new file mode 100644 index 000000000..b2a7ff8c2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/disc-folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/disc.svg b/crates/qbz-qt/qml/assets/icons/secondary/disc.svg new file mode 100644 index 000000000..5062472ec --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/disc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/element-connect.svg b/crates/qbz-qt/qml/assets/icons/secondary/element-connect.svg new file mode 100644 index 000000000..1ee151b2a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/element-connect.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/secondary/ellipsis.svg b/crates/qbz-qt/qml/assets/icons/secondary/ellipsis.svg new file mode 100644 index 000000000..b1d416218 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/external-link.svg b/crates/qbz-qt/qml/assets/icons/secondary/external-link.svg new file mode 100644 index 000000000..9dd52c965 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/external-link.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/secondary/eye-off.svg b/crates/qbz-qt/qml/assets/icons/secondary/eye-off.svg new file mode 100644 index 000000000..997324b4c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/eye.svg b/crates/qbz-qt/qml/assets/icons/secondary/eye.svg new file mode 100644 index 000000000..a52d8df3f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/folder-open.svg b/crates/qbz-qt/qml/assets/icons/secondary/folder-open.svg new file mode 100644 index 000000000..5304b08f6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/folder-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/secondary/folder-plus.svg new file mode 100644 index 000000000..1b11f1d04 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/folder-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/folder.svg b/crates/qbz-qt/qml/assets/icons/secondary/folder.svg new file mode 100644 index 000000000..27fb02298 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/gandalf.svg b/crates/qbz-qt/qml/assets/icons/secondary/gandalf.svg new file mode 100644 index 000000000..0e8277d2c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/globe.svg b/crates/qbz-qt/qml/assets/icons/secondary/globe.svg new file mode 100644 index 000000000..f87be5110 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/globe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/grip-vertical.svg b/crates/qbz-qt/qml/assets/icons/secondary/grip-vertical.svg new file mode 100644 index 000000000..390814e24 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/grip-vertical.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/hard-drive.svg b/crates/qbz-qt/qml/assets/icons/secondary/hard-drive.svg new file mode 100644 index 000000000..da0326a8a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/hard-drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/secondary/heart-filled.svg new file mode 100644 index 000000000..a046a2c1b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/heart-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/heart.svg b/crates/qbz-qt/qml/assets/icons/secondary/heart.svg new file mode 100644 index 000000000..25dc8627a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/home-gear.svg b/crates/qbz-qt/qml/assets/icons/secondary/home-gear.svg new file mode 100644 index 000000000..b4fb7fe33 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/image-plus.svg b/crates/qbz-qt/qml/assets/icons/secondary/image-plus.svg new file mode 100644 index 000000000..2890b9397 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/image-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/image.svg b/crates/qbz-qt/qml/assets/icons/secondary/image.svg new file mode 100644 index 000000000..e8f1a7239 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/image.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/import.svg b/crates/qbz-qt/qml/assets/icons/secondary/import.svg new file mode 100644 index 000000000..e2c23bafc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/infinity.svg b/crates/qbz-qt/qml/assets/icons/secondary/infinity.svg new file mode 100644 index 000000000..489d8cee1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/info.svg b/crates/qbz-qt/qml/assets/icons/secondary/info.svg new file mode 100644 index 000000000..15633cd80 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/keyboard.svg b/crates/qbz-qt/qml/assets/icons/secondary/keyboard.svg new file mode 100644 index 000000000..fcced4f2a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/keyboard.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/layers.svg b/crates/qbz-qt/qml/assets/icons/secondary/layers.svg new file mode 100644 index 000000000..2955a1f69 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/layout-grid.svg b/crates/qbz-qt/qml/assets/icons/secondary/layout-grid.svg new file mode 100644 index 000000000..81cd62cd3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/layout-grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/library-big.svg b/crates/qbz-qt/qml/assets/icons/secondary/library-big.svg new file mode 100644 index 000000000..ab692d674 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/library-big.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/library.svg b/crates/qbz-qt/qml/assets/icons/secondary/library.svg new file mode 100644 index 000000000..64bbf3ce6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/library.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/link.svg b/crates/qbz-qt/qml/assets/icons/secondary/link.svg new file mode 100644 index 000000000..43dd4f07c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/link.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-end.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-end.svg new file mode 100644 index 000000000..2283fc9bb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/list-end.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-filter.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-filter.svg new file mode 100644 index 000000000..f25935a21 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-music.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-music.svg new file mode 100644 index 000000000..5329760eb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/list-music.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-ordered.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-ordered.svg new file mode 100644 index 000000000..3002fea91 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/list-ordered.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-plus.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-plus.svg new file mode 100644 index 000000000..0b5cf8bf2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/list-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-start.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-start.svg new file mode 100644 index 000000000..52ca3740e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/list-start.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-x.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-x.svg new file mode 100644 index 000000000..4f5f5e3fe --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/list-x.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list.svg b/crates/qbz-qt/qml/assets/icons/secondary/list.svg new file mode 100644 index 000000000..99b73b161 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/loader-circle.svg b/crates/qbz-qt/qml/assets/icons/secondary/loader-circle.svg new file mode 100644 index 000000000..946d3602e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/loader-circle.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/log-out.svg b/crates/qbz-qt/qml/assets/icons/secondary/log-out.svg new file mode 100644 index 000000000..11706f617 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/log-out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/map-pin.svg b/crates/qbz-qt/qml/assets/icons/secondary/map-pin.svg new file mode 100644 index 000000000..464380161 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/map-pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/maximize-2.svg new file mode 100644 index 000000000..50b41040b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/maximize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/menu.svg b/crates/qbz-qt/qml/assets/icons/secondary/menu.svg new file mode 100644 index 000000000..67c71b2d2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/mic-vocal.svg b/crates/qbz-qt/qml/assets/icons/secondary/mic-vocal.svg new file mode 100644 index 000000000..6505f31db --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/mic-vocal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/minimize-2.svg new file mode 100644 index 000000000..082d483c6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/minimize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/minus.svg b/crates/qbz-qt/qml/assets/icons/secondary/minus.svg new file mode 100644 index 000000000..4552548f2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/monitor-speaker.svg b/crates/qbz-qt/qml/assets/icons/secondary/monitor-speaker.svg new file mode 100644 index 000000000..7a7ab6cde --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/monitor-speaker.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/monitor.svg b/crates/qbz-qt/qml/assets/icons/secondary/monitor.svg new file mode 100644 index 000000000..26986c13a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/monitor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/moon.svg b/crates/qbz-qt/qml/assets/icons/secondary/moon.svg new file mode 100644 index 000000000..6b065abfb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/move.svg b/crates/qbz-qt/qml/assets/icons/secondary/move.svg new file mode 100644 index 000000000..96be9cb0c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/move.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/mp3.svg b/crates/qbz-qt/qml/assets/icons/secondary/mp3.svg new file mode 100644 index 000000000..2ae1ba0ee --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/mp3.svg @@ -0,0 +1,2 @@ + +MP3 \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/secondary/music-library-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/music-library-2.svg new file mode 100644 index 000000000..7caa2e123 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/music-library-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/secondary/music-note-slider.svg b/crates/qbz-qt/qml/assets/icons/secondary/music-note-slider.svg new file mode 100644 index 000000000..ede7ea777 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/music-note-slider.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/music.svg b/crates/qbz-qt/qml/assets/icons/secondary/music.svg new file mode 100644 index 000000000..56d04dd1d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/music.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/network.svg b/crates/qbz-qt/qml/assets/icons/secondary/network.svg new file mode 100644 index 000000000..d7b4a237f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/network.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/panel-left.svg b/crates/qbz-qt/qml/assets/icons/secondary/panel-left.svg new file mode 100644 index 000000000..ccf686763 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/panel-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/panel-right-close.svg b/crates/qbz-qt/qml/assets/icons/secondary/panel-right-close.svg new file mode 100644 index 000000000..17f22655f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/panel-right-close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/secondary/panel-right-open.svg new file mode 100644 index 000000000..a2659d1ef --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/panel-right-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/pause.svg b/crates/qbz-qt/qml/assets/icons/secondary/pause.svg new file mode 100644 index 000000000..cdd31b392 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/pen-line.svg b/crates/qbz-qt/qml/assets/icons/secondary/pen-line.svg new file mode 100644 index 000000000..28e47b6f0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/pen-line.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/picture-in-picture-2.svg new file mode 100644 index 000000000..8afa5d231 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/picture-in-picture-2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/secondary/pin-filled.svg new file mode 100644 index 000000000..e1dd9ff71 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/pin-filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/pin.svg b/crates/qbz-qt/qml/assets/icons/secondary/pin.svg new file mode 100644 index 000000000..b446cd43b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/pin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/play-fill.svg b/crates/qbz-qt/qml/assets/icons/secondary/play-fill.svg new file mode 100644 index 000000000..dead65217 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/play-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/plus.svg b/crates/qbz-qt/qml/assets/icons/secondary/plus.svg new file mode 100644 index 000000000..c71ae1909 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/qbz-symbolic.svg b/crates/qbz-qt/qml/assets/icons/secondary/qbz-symbolic.svg new file mode 100644 index 000000000..f6af23aca --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/qbz-symbolic.svg @@ -0,0 +1,32 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/radio.svg b/crates/qbz-qt/qml/assets/icons/secondary/radio.svg new file mode 100644 index 000000000..d65a82e3f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/radio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/secondary/refresh-cw.svg new file mode 100644 index 000000000..91c49c810 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/refresh-cw.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/repeat-1.svg b/crates/qbz-qt/qml/assets/icons/secondary/repeat-1.svg new file mode 100644 index 000000000..20092fada --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/repeat-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/repeat.svg b/crates/qbz-qt/qml/assets/icons/secondary/repeat.svg new file mode 100644 index 000000000..ebd20fd80 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/secondary/rotate-ccw.svg new file mode 100644 index 000000000..9a3ae82d5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/rows-3.svg b/crates/qbz-qt/qml/assets/icons/secondary/rows-3.svg new file mode 100644 index 000000000..b1efe13a5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/rows-3.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/scissors.svg b/crates/qbz-qt/qml/assets/icons/secondary/scissors.svg new file mode 100644 index 000000000..9c280df52 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/scissors.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/search.svg b/crates/qbz-qt/qml/assets/icons/secondary/search.svg new file mode 100644 index 000000000..414058893 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/settings-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/settings-2.svg new file mode 100644 index 000000000..669184358 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/settings-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/secondary/shopping-bag.svg new file mode 100644 index 000000000..df558b1b0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/shopping-bag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/shuffle.svg b/crates/qbz-qt/qml/assets/icons/secondary/shuffle.svg new file mode 100644 index 000000000..947d34eea --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/shuffle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/skip-back.svg b/crates/qbz-qt/qml/assets/icons/secondary/skip-back.svg new file mode 100644 index 000000000..1fa699d28 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/skip-back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/skip-forward.svg b/crates/qbz-qt/qml/assets/icons/secondary/skip-forward.svg new file mode 100644 index 000000000..e45fc2791 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/skip-forward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/secondary/sliders-horizontal.svg new file mode 100644 index 000000000..cf0c1f6a5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/sliders-horizontal.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/smartphone.svg b/crates/qbz-qt/qml/assets/icons/secondary/smartphone.svg new file mode 100644 index 000000000..aa18d2f5e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/smartphone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/speaker.svg b/crates/qbz-qt/qml/assets/icons/secondary/speaker.svg new file mode 100644 index 000000000..d7ec9a984 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/speaker.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/square-check-big.svg b/crates/qbz-qt/qml/assets/icons/secondary/square-check-big.svg new file mode 100644 index 000000000..8df483c61 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/square-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/square.svg b/crates/qbz-qt/qml/assets/icons/secondary/square.svg new file mode 100644 index 000000000..ffac7d7c9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/square.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/star.svg b/crates/qbz-qt/qml/assets/icons/secondary/star.svg new file mode 100644 index 000000000..7e6bb6801 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/secondary/sun-moon.svg new file mode 100644 index 000000000..7f9c580a2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/sun.svg b/crates/qbz-qt/qml/assets/icons/secondary/sun.svg new file mode 100644 index 000000000..7b61b1604 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/tags.svg b/crates/qbz-qt/qml/assets/icons/secondary/tags.svg new file mode 100644 index 000000000..c59bc713a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/tags.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/thumbs-down.svg b/crates/qbz-qt/qml/assets/icons/secondary/thumbs-down.svg new file mode 100644 index 000000000..199212236 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/thumbs-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/ticket-check.svg b/crates/qbz-qt/qml/assets/icons/secondary/ticket-check.svg new file mode 100644 index 000000000..4e5fe4dc5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/ticket-check.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/tidal-tidal.svg b/crates/qbz-qt/qml/assets/icons/secondary/tidal-tidal.svg new file mode 100644 index 000000000..3595ed641 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/tidal-tidal.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/translate.svg b/crates/qbz-qt/qml/assets/icons/secondary/translate.svg new file mode 100644 index 000000000..40c501e62 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/translate.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/secondary/trash-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/trash-2.svg new file mode 100644 index 000000000..2a9012d7b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/trash-2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/trash-list.svg b/crates/qbz-qt/qml/assets/icons/secondary/trash-list.svg new file mode 100644 index 000000000..1bab8d86a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/trash-list.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/secondary/triangle-alert.svg b/crates/qbz-qt/qml/assets/icons/secondary/triangle-alert.svg new file mode 100644 index 000000000..0f0b5d3ef --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/triangle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/turntable.svg b/crates/qbz-qt/qml/assets/icons/secondary/turntable.svg new file mode 100644 index 000000000..a5f4f41b9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/turntable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/user-plus.svg b/crates/qbz-qt/qml/assets/icons/secondary/user-plus.svg new file mode 100644 index 000000000..e46f637eb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/user-plus.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/user.svg b/crates/qbz-qt/qml/assets/icons/secondary/user.svg new file mode 100644 index 000000000..1e7be0bd0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/volume-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/volume-2.svg new file mode 100644 index 000000000..4e39cd845 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/volume-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/volume-x.svg b/crates/qbz-qt/qml/assets/icons/secondary/volume-x.svg new file mode 100644 index 000000000..95d5a3633 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/volume-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/secondary/wand-sparkles.svg new file mode 100644 index 000000000..3984aeef8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/wifi.svg b/crates/qbz-qt/qml/assets/icons/secondary/wifi.svg new file mode 100644 index 000000000..bf33db888 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/wifi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/x.svg b/crates/qbz-qt/qml/assets/icons/secondary/x.svg new file mode 100644 index 000000000..7f98ab7cb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/warning/add-to-list.svg new file mode 100644 index 000000000..26121fef3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/add-to-list.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/warning/align-start-vertical.svg b/crates/qbz-qt/qml/assets/icons/warning/align-start-vertical.svg new file mode 100644 index 000000000..e1551fe49 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/align-start-vertical.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/warning/arrow-up-down.svg new file mode 100644 index 000000000..dc2e7f560 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/arrow-up-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/audio-lines.svg b/crates/qbz-qt/qml/assets/icons/warning/audio-lines.svg new file mode 100644 index 000000000..8d99af25b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/audio-lines.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/award.svg b/crates/qbz-qt/qml/assets/icons/warning/award.svg new file mode 100644 index 000000000..9e243f52b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/award.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/warning/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/warning/blind-eye.svg new file mode 100644 index 000000000..d19435b26 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/blind-eye.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/book-open.svg b/crates/qbz-qt/qml/assets/icons/warning/book-open.svg new file mode 100644 index 000000000..a21584fa8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/book-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/bug.svg b/crates/qbz-qt/qml/assets/icons/warning/bug.svg new file mode 100644 index 000000000..2fe8bccd6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/cassette-tape.svg b/crates/qbz-qt/qml/assets/icons/warning/cassette-tape.svg new file mode 100644 index 000000000..79a979f00 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/cassette-tape.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/cast.svg b/crates/qbz-qt/qml/assets/icons/warning/cast.svg new file mode 100644 index 000000000..79137dc82 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/cast.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/cd.svg b/crates/qbz-qt/qml/assets/icons/warning/cd.svg new file mode 100644 index 000000000..48a5fb8ba --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/warning/chart-no-axes-column.svg b/crates/qbz-qt/qml/assets/icons/warning/chart-no-axes-column.svg new file mode 100644 index 000000000..7395450b9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/chart-no-axes-column.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/check.svg b/crates/qbz-qt/qml/assets/icons/warning/check.svg new file mode 100644 index 000000000..7485942e6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/chevron-down.svg b/crates/qbz-qt/qml/assets/icons/warning/chevron-down.svg new file mode 100644 index 000000000..e7361a54e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/chevron-left.svg b/crates/qbz-qt/qml/assets/icons/warning/chevron-left.svg new file mode 100644 index 000000000..a0973c905 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/chevron-right.svg b/crates/qbz-qt/qml/assets/icons/warning/chevron-right.svg new file mode 100644 index 000000000..6e3fe0ac8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/warning/chevron-up.svg new file mode 100644 index 000000000..b67b80337 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/circle-alert.svg b/crates/qbz-qt/qml/assets/icons/warning/circle-alert.svg new file mode 100644 index 000000000..24fe7b1a5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/circle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/circle-check-big.svg b/crates/qbz-qt/qml/assets/icons/warning/circle-check-big.svg new file mode 100644 index 000000000..856ef0c04 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/circle-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/circle-stop.svg b/crates/qbz-qt/qml/assets/icons/warning/circle-stop.svg new file mode 100644 index 000000000..d9db1f005 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/circle-stop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/clipboard.svg b/crates/qbz-qt/qml/assets/icons/warning/clipboard.svg new file mode 100644 index 000000000..50c87b990 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/clipboard.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/warning/clock.svg b/crates/qbz-qt/qml/assets/icons/warning/clock.svg new file mode 100644 index 000000000..0bc76b29d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/cloud-download.svg b/crates/qbz-qt/qml/assets/icons/warning/cloud-download.svg new file mode 100644 index 000000000..0707f90ff --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/cloud-download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/cloud-off.svg b/crates/qbz-qt/qml/assets/icons/warning/cloud-off.svg new file mode 100644 index 000000000..ad26889db --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/cloud-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/cloud-sync.svg b/crates/qbz-qt/qml/assets/icons/warning/cloud-sync.svg new file mode 100644 index 000000000..0cc74ba68 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/cloud-sync.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/warning/cloud-upload.svg new file mode 100644 index 000000000..0d040afe5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/compass.svg b/crates/qbz-qt/qml/assets/icons/warning/compass.svg new file mode 100644 index 000000000..45a7df84b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/compass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/copy.svg b/crates/qbz-qt/qml/assets/icons/warning/copy.svg new file mode 100644 index 000000000..447b9db80 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/copy.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/warning/crown.svg b/crates/qbz-qt/qml/assets/icons/warning/crown.svg new file mode 100644 index 000000000..bc4d96a52 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/crown.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/warning/disc-3.svg b/crates/qbz-qt/qml/assets/icons/warning/disc-3.svg new file mode 100644 index 000000000..667bb542c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/disc-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/disc-folder.svg b/crates/qbz-qt/qml/assets/icons/warning/disc-folder.svg new file mode 100644 index 000000000..40023fe0e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/disc-folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/disc.svg b/crates/qbz-qt/qml/assets/icons/warning/disc.svg new file mode 100644 index 000000000..3b67f8c33 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/disc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/element-connect.svg b/crates/qbz-qt/qml/assets/icons/warning/element-connect.svg new file mode 100644 index 000000000..93a13ce07 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/element-connect.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/warning/ellipsis.svg b/crates/qbz-qt/qml/assets/icons/warning/ellipsis.svg new file mode 100644 index 000000000..a630f791b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/external-link.svg b/crates/qbz-qt/qml/assets/icons/warning/external-link.svg new file mode 100644 index 000000000..4693433f2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/external-link.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/warning/eye-off.svg b/crates/qbz-qt/qml/assets/icons/warning/eye-off.svg new file mode 100644 index 000000000..8f8ce63d1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/eye.svg b/crates/qbz-qt/qml/assets/icons/warning/eye.svg new file mode 100644 index 000000000..5aa91cb8f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/folder-open.svg b/crates/qbz-qt/qml/assets/icons/warning/folder-open.svg new file mode 100644 index 000000000..fd61e459c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/folder-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/warning/folder-plus.svg new file mode 100644 index 000000000..a92bdc926 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/folder-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/folder.svg b/crates/qbz-qt/qml/assets/icons/warning/folder.svg new file mode 100644 index 000000000..454920ba9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/gandalf.svg b/crates/qbz-qt/qml/assets/icons/warning/gandalf.svg new file mode 100644 index 000000000..3ed8da3ba --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/globe.svg b/crates/qbz-qt/qml/assets/icons/warning/globe.svg new file mode 100644 index 000000000..3359816c6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/globe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/grip-vertical.svg b/crates/qbz-qt/qml/assets/icons/warning/grip-vertical.svg new file mode 100644 index 000000000..7a1c6bd21 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/grip-vertical.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/hard-drive.svg b/crates/qbz-qt/qml/assets/icons/warning/hard-drive.svg new file mode 100644 index 000000000..7e0d172a8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/hard-drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/warning/heart-filled.svg new file mode 100644 index 000000000..ec87a618f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/heart-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/heart.svg b/crates/qbz-qt/qml/assets/icons/warning/heart.svg new file mode 100644 index 000000000..01cab4ac0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/home-gear.svg b/crates/qbz-qt/qml/assets/icons/warning/home-gear.svg new file mode 100644 index 000000000..ce1325c52 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/image-plus.svg b/crates/qbz-qt/qml/assets/icons/warning/image-plus.svg new file mode 100644 index 000000000..0f50cfd41 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/image-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/image.svg b/crates/qbz-qt/qml/assets/icons/warning/image.svg new file mode 100644 index 000000000..f90c4f71d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/image.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/warning/import.svg b/crates/qbz-qt/qml/assets/icons/warning/import.svg new file mode 100644 index 000000000..2f634900e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/infinity.svg b/crates/qbz-qt/qml/assets/icons/warning/infinity.svg new file mode 100644 index 000000000..74b2912dd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/info.svg b/crates/qbz-qt/qml/assets/icons/warning/info.svg new file mode 100644 index 000000000..577747edb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/keyboard.svg b/crates/qbz-qt/qml/assets/icons/warning/keyboard.svg new file mode 100644 index 000000000..60bd12911 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/keyboard.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/layers.svg b/crates/qbz-qt/qml/assets/icons/warning/layers.svg new file mode 100644 index 000000000..ca364e992 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/layout-grid.svg b/crates/qbz-qt/qml/assets/icons/warning/layout-grid.svg new file mode 100644 index 000000000..effa8b6e5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/layout-grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/library-big.svg b/crates/qbz-qt/qml/assets/icons/warning/library-big.svg new file mode 100644 index 000000000..3dca8423c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/library-big.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/library.svg b/crates/qbz-qt/qml/assets/icons/warning/library.svg new file mode 100644 index 000000000..2ad1d08ab --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/library.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/link.svg b/crates/qbz-qt/qml/assets/icons/warning/link.svg new file mode 100644 index 000000000..e99a7720d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/link.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-end.svg b/crates/qbz-qt/qml/assets/icons/warning/list-end.svg new file mode 100644 index 000000000..fae91cc0c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/list-end.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-filter.svg b/crates/qbz-qt/qml/assets/icons/warning/list-filter.svg new file mode 100644 index 000000000..9927b34f4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-music.svg b/crates/qbz-qt/qml/assets/icons/warning/list-music.svg new file mode 100644 index 000000000..43d322dbc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/list-music.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-ordered.svg b/crates/qbz-qt/qml/assets/icons/warning/list-ordered.svg new file mode 100644 index 000000000..b687fc465 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/list-ordered.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-plus.svg b/crates/qbz-qt/qml/assets/icons/warning/list-plus.svg new file mode 100644 index 000000000..54e969657 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/list-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-start.svg b/crates/qbz-qt/qml/assets/icons/warning/list-start.svg new file mode 100644 index 000000000..964ffbca3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/list-start.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-x.svg b/crates/qbz-qt/qml/assets/icons/warning/list-x.svg new file mode 100644 index 000000000..51f34535d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/list-x.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/list.svg b/crates/qbz-qt/qml/assets/icons/warning/list.svg new file mode 100644 index 000000000..54f721c9e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/loader-circle.svg b/crates/qbz-qt/qml/assets/icons/warning/loader-circle.svg new file mode 100644 index 000000000..829c31e8f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/loader-circle.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/warning/log-out.svg b/crates/qbz-qt/qml/assets/icons/warning/log-out.svg new file mode 100644 index 000000000..6b46fc78b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/log-out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/map-pin.svg b/crates/qbz-qt/qml/assets/icons/warning/map-pin.svg new file mode 100644 index 000000000..f91ed28a5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/map-pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/warning/maximize-2.svg new file mode 100644 index 000000000..8cfed918a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/maximize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/menu.svg b/crates/qbz-qt/qml/assets/icons/warning/menu.svg new file mode 100644 index 000000000..2e7d35fba --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/mic-vocal.svg b/crates/qbz-qt/qml/assets/icons/warning/mic-vocal.svg new file mode 100644 index 000000000..3ecf35318 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/mic-vocal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/warning/minimize-2.svg new file mode 100644 index 000000000..85cf37832 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/minimize-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/minus.svg b/crates/qbz-qt/qml/assets/icons/warning/minus.svg new file mode 100644 index 000000000..cad3f688d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/monitor-speaker.svg b/crates/qbz-qt/qml/assets/icons/warning/monitor-speaker.svg new file mode 100644 index 000000000..f02f5f5bb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/monitor-speaker.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/monitor.svg b/crates/qbz-qt/qml/assets/icons/warning/monitor.svg new file mode 100644 index 000000000..8303048c1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/monitor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/moon.svg b/crates/qbz-qt/qml/assets/icons/warning/moon.svg new file mode 100644 index 000000000..dd26ba6b2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/move.svg b/crates/qbz-qt/qml/assets/icons/warning/move.svg new file mode 100644 index 000000000..f7403f583 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/move.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/mp3.svg b/crates/qbz-qt/qml/assets/icons/warning/mp3.svg new file mode 100644 index 000000000..16984b643 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/mp3.svg @@ -0,0 +1,2 @@ + +MP3 \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/warning/music-library-2.svg b/crates/qbz-qt/qml/assets/icons/warning/music-library-2.svg new file mode 100644 index 000000000..d3914fa63 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/music-library-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/warning/music-note-slider.svg b/crates/qbz-qt/qml/assets/icons/warning/music-note-slider.svg new file mode 100644 index 000000000..28e45c3c0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/music-note-slider.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/music.svg b/crates/qbz-qt/qml/assets/icons/warning/music.svg new file mode 100644 index 000000000..4b33c40cd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/music.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/network.svg b/crates/qbz-qt/qml/assets/icons/warning/network.svg new file mode 100644 index 000000000..ae45e904a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/network.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/panel-left.svg b/crates/qbz-qt/qml/assets/icons/warning/panel-left.svg new file mode 100644 index 000000000..3f1c93a1d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/panel-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/panel-right-close.svg b/crates/qbz-qt/qml/assets/icons/warning/panel-right-close.svg new file mode 100644 index 000000000..82140ffc9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/panel-right-close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/warning/panel-right-open.svg new file mode 100644 index 000000000..138685b00 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/panel-right-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/pause.svg b/crates/qbz-qt/qml/assets/icons/warning/pause.svg new file mode 100644 index 000000000..039b5f257 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/pen-line.svg b/crates/qbz-qt/qml/assets/icons/warning/pen-line.svg new file mode 100644 index 000000000..ba9946a2b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/pen-line.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/warning/picture-in-picture-2.svg new file mode 100644 index 000000000..468e250d8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/picture-in-picture-2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/warning/pin-filled.svg new file mode 100644 index 000000000..9d3465c79 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/pin-filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/pin.svg b/crates/qbz-qt/qml/assets/icons/warning/pin.svg new file mode 100644 index 000000000..732e1df7c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/pin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/play-fill.svg b/crates/qbz-qt/qml/assets/icons/warning/play-fill.svg new file mode 100644 index 000000000..1c7ad4315 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/play-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/plus.svg b/crates/qbz-qt/qml/assets/icons/warning/plus.svg new file mode 100644 index 000000000..47fdfc648 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/qbz-symbolic.svg b/crates/qbz-qt/qml/assets/icons/warning/qbz-symbolic.svg new file mode 100644 index 000000000..2618dbb2d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/qbz-symbolic.svg @@ -0,0 +1,32 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/radio.svg b/crates/qbz-qt/qml/assets/icons/warning/radio.svg new file mode 100644 index 000000000..c6137fe0a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/radio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/warning/refresh-cw.svg new file mode 100644 index 000000000..b6d9d7fb0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/refresh-cw.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/repeat-1.svg b/crates/qbz-qt/qml/assets/icons/warning/repeat-1.svg new file mode 100644 index 000000000..06f9c43f5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/repeat-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/repeat.svg b/crates/qbz-qt/qml/assets/icons/warning/repeat.svg new file mode 100644 index 000000000..461d58aac --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/warning/rotate-ccw.svg new file mode 100644 index 000000000..c01456ecd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/rows-3.svg b/crates/qbz-qt/qml/assets/icons/warning/rows-3.svg new file mode 100644 index 000000000..47822b858 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/rows-3.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/scissors.svg b/crates/qbz-qt/qml/assets/icons/warning/scissors.svg new file mode 100644 index 000000000..3ec64a7e7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/scissors.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/warning/search.svg b/crates/qbz-qt/qml/assets/icons/warning/search.svg new file mode 100644 index 000000000..04e93bc80 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/settings-2.svg b/crates/qbz-qt/qml/assets/icons/warning/settings-2.svg new file mode 100644 index 000000000..84af63efc --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/settings-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/warning/shopping-bag.svg new file mode 100644 index 000000000..a5129dc45 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/shopping-bag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/shuffle.svg b/crates/qbz-qt/qml/assets/icons/warning/shuffle.svg new file mode 100644 index 000000000..328a04421 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/shuffle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/skip-back.svg b/crates/qbz-qt/qml/assets/icons/warning/skip-back.svg new file mode 100644 index 000000000..7084c6c94 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/skip-back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/skip-forward.svg b/crates/qbz-qt/qml/assets/icons/warning/skip-forward.svg new file mode 100644 index 000000000..2b293ecdf --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/skip-forward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/warning/sliders-horizontal.svg new file mode 100644 index 000000000..8266dabd3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/sliders-horizontal.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/smartphone.svg b/crates/qbz-qt/qml/assets/icons/warning/smartphone.svg new file mode 100644 index 000000000..39f5ae1b3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/smartphone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/speaker.svg b/crates/qbz-qt/qml/assets/icons/warning/speaker.svg new file mode 100644 index 000000000..72b053686 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/speaker.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/square-check-big.svg b/crates/qbz-qt/qml/assets/icons/warning/square-check-big.svg new file mode 100644 index 000000000..e5f68629a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/square-check-big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/square.svg b/crates/qbz-qt/qml/assets/icons/warning/square.svg new file mode 100644 index 000000000..14a390bac --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/square.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/star.svg b/crates/qbz-qt/qml/assets/icons/warning/star.svg new file mode 100644 index 000000000..793eff7aa --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/warning/sun-moon.svg new file mode 100644 index 000000000..b296f8dd2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/sun.svg b/crates/qbz-qt/qml/assets/icons/warning/sun.svg new file mode 100644 index 000000000..eb6074103 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/tags.svg b/crates/qbz-qt/qml/assets/icons/warning/tags.svg new file mode 100644 index 000000000..788bac878 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/tags.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/thumbs-down.svg b/crates/qbz-qt/qml/assets/icons/warning/thumbs-down.svg new file mode 100644 index 000000000..e61c3497f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/thumbs-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/ticket-check.svg b/crates/qbz-qt/qml/assets/icons/warning/ticket-check.svg new file mode 100644 index 000000000..610606002 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/ticket-check.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/tidal-tidal.svg b/crates/qbz-qt/qml/assets/icons/warning/tidal-tidal.svg new file mode 100644 index 000000000..0aea5469d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/tidal-tidal.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/translate.svg b/crates/qbz-qt/qml/assets/icons/warning/translate.svg new file mode 100644 index 000000000..c257dc1b3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/translate.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/warning/trash-2.svg b/crates/qbz-qt/qml/assets/icons/warning/trash-2.svg new file mode 100644 index 000000000..e2b1458cf --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/trash-2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/trash-list.svg b/crates/qbz-qt/qml/assets/icons/warning/trash-list.svg new file mode 100644 index 000000000..9476fcd41 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/trash-list.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/warning/triangle-alert.svg b/crates/qbz-qt/qml/assets/icons/warning/triangle-alert.svg new file mode 100644 index 000000000..c0e5c659b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/triangle-alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/turntable.svg b/crates/qbz-qt/qml/assets/icons/warning/turntable.svg new file mode 100644 index 000000000..5f00528fe --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/turntable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/user-plus.svg b/crates/qbz-qt/qml/assets/icons/warning/user-plus.svg new file mode 100644 index 000000000..4837936a9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/user-plus.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-qt/qml/assets/icons/warning/user.svg b/crates/qbz-qt/qml/assets/icons/warning/user.svg new file mode 100644 index 000000000..534d6ffcd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/volume-2.svg b/crates/qbz-qt/qml/assets/icons/warning/volume-2.svg new file mode 100644 index 000000000..84c989dac --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/volume-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/volume-x.svg b/crates/qbz-qt/qml/assets/icons/warning/volume-x.svg new file mode 100644 index 000000000..1c86f1cfb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/volume-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/warning/wand-sparkles.svg new file mode 100644 index 000000000..b64a5b6d0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/wifi.svg b/crates/qbz-qt/qml/assets/icons/warning/wifi.svg new file mode 100644 index 000000000..06d50602b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/wifi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/x.svg b/crates/qbz-qt/qml/assets/icons/warning/x.svg new file mode 100644 index 000000000..d0fecde00 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/kiosk-art.js b/crates/qbz-qt/qml/assets/kiosk-art.js new file mode 100644 index 000000000..471594c44 --- /dev/null +++ b/crates/qbz-qt/qml/assets/kiosk-art.js @@ -0,0 +1,16 @@ +.pragma library + +// One request policy for Kiosk cache requests and decode components. +function bucketFor(pixels) { + var buckets = [50, 100, 150, 230, 300, 600] + for (var i = 0; i < buckets.length; i++) + if (buckets[i] >= pixels) return buckets[i] + return 0 +} + +function sizedUrl(url, pixels) { + if (!/^https?:\/\/(?:[^/]+\.)?qobuz\.com(?::[0-9]+)?\//i.test(url) || pixels <= 0) return url + var bucket = bucketFor(pixels) + if (!bucket) return url + return url.replace(/_(50|100|150|230|300|600|max|org)(\.[^/?]+)(\?.*)?$/, "_" + bucket + "$2$3") +} diff --git a/crates/qbz-qt/qml/assets/mp3.svg b/crates/qbz-qt/qml/assets/mp3.svg new file mode 100644 index 000000000..cdabb7e0f --- /dev/null +++ b/crates/qbz-qt/qml/assets/mp3.svg @@ -0,0 +1,2 @@ + +MP3 \ No newline at end of file diff --git a/crates/qbz-ui/ui/assets/qbz-logo-128.png b/crates/qbz-qt/qml/assets/qbz-logo-128.png similarity index 100% rename from crates/qbz-ui/ui/assets/qbz-logo-128.png rename to crates/qbz-qt/qml/assets/qbz-logo-128.png diff --git a/crates/qbz-ui/ui/assets/qbz-logo.png b/crates/qbz-qt/qml/assets/qbz-logo.png similarity index 100% rename from crates/qbz-ui/ui/assets/qbz-logo.png rename to crates/qbz-qt/qml/assets/qbz-logo.png diff --git a/crates/qbz-qt/qml/assets/shaders/ambient.frag b/crates/qbz-qt/qml/assets/shaders/ambient.frag new file mode 100644 index 000000000..080032e06 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/ambient.frag @@ -0,0 +1,182 @@ +#version 440 + +// Ambient — the app-wide dynamic background, a 1:1 GLSL port of +// crates/qbz-ui/ui/shaders/ambient.wgsl (WGSL, wgpu scene 7). Every constant, +// every orbit and every curve below is the same number the reference uses; the +// only differences are mechanical (WGSL -> GLSL 440, uniform block instead of a +// bind group, and `time` arrives as a QML property instead of a shared frame +// uniform). +// +// WHY THIS IS A SHADER AND NOT A CANVAS. The Canvas version this replaces +// painted four radial gradients over a BLACK base, so anywhere outside a blob's +// radius the window was #000000 — measured on the owner's 2026-08-04 side by +// side: Qt at (1600,600) and (1690,880) was (0,0,0) while Slint was (58,51,5) +// and (29,27,10). The reference cannot produce black: `col` is the +// metaball-WEIGHTED album colour (the field divides out, so it is defined +// everywhere) and the darkest it is ever scaled is `mix(0.42, 1.12, shape)` at +// shape = 0. The floor is 42% of the album colour across the WHOLE window, +// which is why the Slint reads as one wash that brightens into lobes and the +// Canvas read as spotlights on black. That floor is not reachable by adding +// gradients — it needs the per-pixel weighted average, i.e. this. +// +// Canvas remains as the fallback arm for the software scene graph (see +// AmbientField.qml), where it now paints the same floor as a flat base. +// +// The uniform block is the ShaderEffect contract: qt_Matrix and qt_Opacity are +// supplied by Qt, everything after them must exist as a `property real` / +// `property color` on the effect item. Qt resolves them BY NAME out of the +// .qsb's reflection data, so the QML declaration order is free — but a name +// with no matching property is silently left at zero, which is why every +// member below is spelled identically on both sides. + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 primary; + vec4 secondary; + vec4 accent; + float qt_Opacity; + float time; + float levelSmooth; + float resX; + float resY; + float dim; +}; + +// QSB-SKIP-GLES100 — build.rs drops the `100 es` variant for this file. GLSL ES +// 1.00 has no uint, SPIRV-Cross re-types the hash constants below as int, the +// large ones go negative and qsb fails the WHOLE bake. Dropping the ES 2.0 +// level costs nothing here: AmbientField falls back to its Canvas arm wherever +// the effect cannot load, and the hash cannot become float-based (see below). +// +// INTEGER lattice hash (bit-exact), wgsl:hash2. The reference's comment is +// load-bearing and applies verbatim here: float hashes leave the result to f32 +// rounding, so the SAME lattice corner hashed from two adjacent cells can +// disagree and draw a "wallpaper join" seam through the warp field. uint +// arithmetic has no rounding. +float hash2(vec2 p) { + uint h = uint(int(p.x)) * 0x8da6b343u + uint(int(p.y)) * 0xd8163841u; + h = (h ^ (h >> 15u)) * 0x2c1b3c6du; + h = (h ^ (h >> 12u)) * 0x297a2d39u; + h = h ^ (h >> 15u); + return float(h >> 8u) * (1.0 / 16777216.0); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + float a = hash2(i); + float b = hash2(i + vec2(1.0, 0.0)); + float c = hash2(i + vec2(0.0, 1.0)); + float d = hash2(i + vec2(1.0, 1.0)); + vec2 w = f * f * (3.0 - 2.0 * f); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +float fbm(vec2 p) { + float v = 0.0; + float amp = 0.55; + vec2 q = p; + v += amp * vnoise(q); q *= 2.02; amp *= 0.5; + v += amp * vnoise(q); q *= 2.03; amp *= 0.5; + v += amp * vnoise(q); + return v; +} + +// Metaball potential (r^2/d^2): summed over several movers the iso-surface +// merges and splits organically. Unlike a gaussian blob this has a LONG TAIL — +// that tail is what fills the window between the lobes, and it is precisely +// what a canvas radial gradient (which terminates at its radius) cannot do. +float mball(vec2 uv, vec2 c, float r) { + vec2 d = uv - c; + return (r * r) / (dot(d, d) + 0.0009); +} + +void main() { + // Aspect-correct so the drift looks even on wide windows. NOTE the space + // this puts us in: x spans [0, aspect] and y spans [0, 1], so the radius + // below is a fraction of the window HEIGHT — the Canvas port had it as + // 0.36 * max(W, H), which on a 1700x1400 window made the blobs 29% too big. + float aspect = resX / max(resY, 1.0); + vec2 uv = qt_TexCoord0; + uv.x *= aspect; + + // Clock. Fast enough that the flow is visible in seconds (blob orbits are + // ~10-18 s); `levelSmooth` adds a gentle breathe when audio is flowing. + float t = time * 0.75; + float breathe = 1.0 + 0.12 * levelSmooth; + + // Two-octave domain warp. The low octave does the big organic flow; the + // high octave stays small and lower-frequency — a strong high-freq warp + // folds the metaball field into sharp creases that read as hard edges over + // the translucent UI. + vec2 w1 = vec2( + fbm(uv * 1.3 + vec2(t * 0.5, t * 0.2)), + fbm(uv * 1.3 + vec2(-t * 0.3, t * 0.45)) + ); + vec2 w2 = vec2( + fbm(uv * 2.3 + vec2(-t * 0.6, t * 0.45)), + fbm(uv * 2.3 + vec2(t * 0.55, -t * 0.35)) + ); + vec2 p = uv + (w1 - 0.5) * 0.68 + (w2 - 0.5) * 0.14; + + // Four album-coloured metaballs on big wandering orbits. + vec3 c4 = mix(primary.rgb, accent.rgb, 0.5); + vec2 cA = vec2((0.32 + 0.30 * sin(t * 0.40)) * aspect, 0.42 + 0.30 * cos(t * 0.33)); + vec2 cB = vec2((0.66 + 0.30 * sin(t * 0.35 + 2.1)) * aspect, 0.56 + 0.32 * cos(t * 0.29 + 1.3)); + vec2 cC = vec2((0.50 + 0.34 * cos(t * 0.31 + 4.0)) * aspect, 0.36 + 0.30 * sin(t * 0.45 + 3.2)); + vec2 cD = vec2((0.46 + 0.32 * sin(t * 0.27 + 5.3)) * aspect, 0.64 + 0.28 * cos(t * 0.49 + 0.7)); + + float rr = 0.34 * breathe; + float fA = mball(p, cA, rr); + float fB = mball(p, cB, rr * 0.95); + float fC = mball(p, cC, rr * 0.88); + float fD = mball(p, cD, rr * 0.82); + float field = fA + fB + fC + fD; + + // Metaball-weighted album colour (which lobe dominates here). + vec3 col = (primary.rgb * fA + secondary.rgb * fB + accent.rgb * fC + c4 * fD) + / (field + 0.0001); + + // The amoeba structure: the iso-surface. A WIDE smoothstep so the lobes melt + // into the base over a long soft gradient instead of a hard rim, and a + // gentler bright/dark spread so transitions never snap. 0.42 is the floor + // this whole file exists for. + float shape = smoothstep(0.45, 3.4, field); + col *= mix(0.42, 1.12, shape); + + // Push saturation/contrast a little so the lobes read as album colour, not + // a muddy average — but not so hard it re-sharpens the transitions. + float luma = dot(col, vec3(0.299, 0.587, 0.114)); + col = mix(vec3(luma), col, 1.28); + + // Vertical falloff -> a touch darker at the very top/bottom edges so chrome + // (titlebar, player bar) sits on calmer colour. QUADRATIC, not the linear + // black ramp the Canvas used. + float vshade = 1.0 - 0.16 * pow(abs(qt_TexCoord0.y - 0.5) * 2.0, 2.0); + col *= vshade; + + // Overall brightness — vivid; the dim scrim above this layer provides the + // legibility dim, so the base can stay bright without glaring. + col = clamp(col, vec3(0.0), vec3(1.0)) * 0.92; + + // A touch of grain (dither) to break up 8-bit banding on the smooth + // gradient — banding rings read as faint hard edges too. + float grain = (vnoise(qt_TexCoord0 * vec2(resX, resY) * 0.5) - 0.5) * 0.022; + col += vec3(grain); + + // The legibility scrim, FOLDED IN. The reference paints it as a separate + // full-window black Rectangle at `dim` alpha over the field + // (AppShell.slint:236-241), which is arithmetically `field * (1 - dim)` — + // identical to doing it here, one multiply instead of a whole extra + // full-screen blend pass in every frame the scene redraws. With the + // background up the shell is already an all-alpha stack, so a pass removed + // is a pass removed from EVERY frame, not just the 30 the field owns. + col *= (1.0 - clamp(dim, 0.0, 1.0)); + + col = clamp(col, vec3(0.0), vec3(1.0)); + // Premultiplied, like every other effect in this tree. + fragColor = vec4(col * qt_Opacity, qt_Opacity); +} diff --git a/crates/qbz-qt/qml/assets/shaders/ambient.frag.qsb b/crates/qbz-qt/qml/assets/shaders/ambient.frag.qsb new file mode 100644 index 000000000..4f1462e3e Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/ambient.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/ambient.vert b/crates/qbz-qt/qml/assets/shaders/ambient.vert new file mode 100644 index 000000000..d72c81d66 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/ambient.vert @@ -0,0 +1,31 @@ +#version 440 + +// PAIRED with ambient.frag (AmbientField + the immersive Ambient scene). +// THE PAIRING RULE (see scene_pack.vert): both stages must declare the SAME +// uniform block, member for member, or the OpenGL program link rejects the +// two layouts (Vulkan never links, so it tolerated the mismatch). Keep this +// block byte-identical to ambient.frag's. + +layout(location = 0) in vec4 qt_Vertex; +layout(location = 1) in vec2 qt_MultiTexCoord0; +layout(location = 0) out vec2 qt_TexCoord0; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 primary; + vec4 secondary; + vec4 accent; + float qt_Opacity; + float time; + float levelSmooth; + float resX; + float resY; + float dim; +}; + +out gl_PerVertex { vec4 gl_Position; }; + +void main() { + qt_TexCoord0 = qt_MultiTexCoord0; + gl_Position = qt_Matrix * qt_Vertex; +} diff --git a/crates/qbz-qt/qml/assets/shaders/ambient.vert.qsb b/crates/qbz-qt/qml/assets/shaders/ambient.vert.qsb new file mode 100644 index 000000000..18441a6c5 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/ambient.vert.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/atmosphere.frag b/crates/qbz-qt/qml/assets/shaders/atmosphere.frag new file mode 100644 index 000000000..44f08918e --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/atmosphere.frag @@ -0,0 +1,78 @@ +#version 440 + +// atmosphere.frag — the ImmersiveAtmosphere "blurred art" background +// (app-wide background mode 2, ImmersiveView underlay, album/artist header +// route A) as ONE full-window pass. +// +// WHY THIS EXISTS (2026-08-13 single-pulse wave). The QML build of this +// component was FOUR oversized Images of the same 128x128 atmosphere bitmap +// (PreserveAspectCrop, sine-drifting origins, opacities .95/.48/.24/.18) over +// a base Rectangle, under a dim overlay and a black alpha scrim: seven +// full-window nodes, six of them blended, re-executed on EVERY present — +// and Qt Quick repaints the whole window on any dirty, so the visualiser's +// presents paid the background's six passes too. The fragment math below is +// the exact same composite (same crop, same src-over order, same overlays), +// so the background tier is one opaque draw instead of ~7 blended ones. +// +// THE COMPOSITE, in the QML stack's order (all arithmetic in the same sRGB +// space the scene graph blends in): +// c = #0a0a0b (base Rectangle) +// c = tex_i.rgb*o_i + c*(1 - a_i*o_i) x4 (Image src-over, textures +// premultiplied; a_i = 1 for +// the opaque bitmap, where +// this is plain mix) +// c *= 1 - dim (black dim overlay) +// c *= 1 - scrim(y) (black alpha gradient: +// .4 top -> 0 @35% -> ~.5 +// bottom, linear in alpha — +// the QML GradientStop +// interpolation is exact here +// because every stop is black) +// The output is opaque, so the item sets `blending: false` and lands in the +// opaque pass (front-to-back, early-z) instead of the 500-node alpha stack. +// +// PreserveAspectCrop of a SQUARE source into a layer rect R = (rw, rh) at +// origin o: cover scale s = max(rw, rh), the crop is centred, so a fragment +// at item-pixel px samples uv = (px - (o + R/2)) / s + 0.5. QML packs +// (cx, cy, s, opacity) per layer — see ImmersiveAtmosphere.qml packLayer(). + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float resX; + float resY; + float dim; + vec4 l1; // xy: crop centre in item px, z: cover scale (px per uv), w: opacity + vec4 l2; + vec4 l3; + vec4 l4; +}; + +layout(binding = 1) uniform sampler2D tex; + +vec3 overLayer(vec3 base, vec2 px, vec4 l) { + vec2 uv = (px - l.xy) / l.z + 0.5; + vec4 t = texture(tex, uv); + return t.rgb * l.w + base * (1.0 - t.a * l.w); +} + +void main() { + vec2 px = qt_TexCoord0 * vec2(resX, resY); + vec3 c = vec3(10.0 / 255.0, 10.0 / 255.0, 11.0 / 255.0); // #0a0a0b + c = overLayer(c, px, l1); + c = overLayer(c, px, l2); + c = overLayer(c, px, l3); + c = overLayer(c, px, l4); + c *= 1.0 - dim; + float y = qt_TexCoord0.y; + float scrim = y < 0.35 + ? (102.0 / 255.0) * (1.0 - y / 0.35) // #66000000 -> transparent + : (128.0 / 255.0) * ((y - 0.35) / 0.65); // transparent -> #80000000 + c *= 1.0 - scrim; + // Opaque on purpose (blending: false on the item): the base rect makes + // alpha 1 everywhere, and an opaque node leaves the alpha pass entirely. + fragColor = vec4(c, 1.0); +} diff --git a/crates/qbz-qt/qml/assets/shaders/atmosphere.frag.qsb b/crates/qbz-qt/qml/assets/shaders/atmosphere.frag.qsb new file mode 100644 index 000000000..6ab5dc5e6 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/atmosphere.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/atmosphere.vert b/crates/qbz-qt/qml/assets/shaders/atmosphere.vert new file mode 100644 index 000000000..a7564701e --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/atmosphere.vert @@ -0,0 +1,29 @@ +#version 440 + +// PAIRED with atmosphere.frag (ImmersiveAtmosphere). THE PAIRING RULE (see +// scene_pack.vert): both stages must declare the SAME uniform block, member +// for member, or the OpenGL program link rejects the two layouts. Keep this +// block byte-identical to atmosphere.frag's. + +layout(location = 0) in vec4 qt_Vertex; +layout(location = 1) in vec2 qt_MultiTexCoord0; +layout(location = 0) out vec2 qt_TexCoord0; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float resX; + float resY; + float dim; + vec4 l1; + vec4 l2; + vec4 l3; + vec4 l4; +}; + +out gl_PerVertex { vec4 gl_Position; }; + +void main() { + qt_TexCoord0 = qt_MultiTexCoord0; + gl_Position = qt_Matrix * qt_Vertex; +} diff --git a/crates/qbz-qt/qml/assets/shaders/atmosphere.vert.qsb b/crates/qbz-qt/qml/assets/shaders/atmosphere.vert.qsb new file mode 100644 index 000000000..78e5cb67c Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/atmosphere.vert.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/aurora.frag b/crates/qbz-qt/qml/assets/shaders/aurora.frag new file mode 100644 index 000000000..a4bf9b830 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/aurora.frag @@ -0,0 +1,124 @@ +#version 440 + +// Aurora (immersive scene, mode 3) — a line-faithful GLSL 440 port of +// crates/qbz-ui/ui/shaders/aurora.wgsl (AURORA WARP, original clean-room +// shader; the stage-0 spike scene of the 2026-08-15 immersive-completion +// contract, chosen because it exercises the full uniform pack with zero +// extra GPU resources). Every constant and every formula is the reference's; +// the differences are mechanical (same list as tunnel.frag): uniform block +// instead of a bind group, resolution as resX/resY, energy/bands vec4s by +// their QML property names, `let` -> local floats, uv = qt_TexCoord0 with NO +// y-flip (the ambient.frag convention), premultiplied output. +// +// Three sine-domain-warped curtains take their colors from the album-art +// palette (primary / secondary / accent); bass swells the low curtain, mids +// drive sway speed/amplitude, presence the top curtain, air = shimmer +// frequency (5.0 + air*8.0) cross-fading toward accent, beat kicks the +// brightness and lifts the curtains, level_smooth sets the baseline bloom. +// Fixed work per pixel, no loops — GPU-cheap. +// +// Pure float math (no integer ops), so this shader keeps the FULL qsb +// variant set including 100 es (unlike ambient.frag / tunnel.frag). + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 primary; + vec4 secondary; + vec4 accent; + vec4 energyLo; + vec4 energyHi; + vec4 bandsLo; + vec4 bandsHi; + float qt_Opacity; + float time; + float phase; + float beat; + float level; + float resX; + float resY; + float levelSmooth; + // Named transientAmp, not `transient` — a RESERVED QML keyword (see + // tunnel.frag). The JSON pack key stays `transient`. + float transientAmp; +}; + +// A small smooth flow field: cheap sine lobes that act like a fake curl, so +// the curtains ripple horizontally instead of sliding rigidly. +float flow(vec2 p, float t) { + float a = sin(p.x * 2.3 + t * 0.7) * 0.5; + float b = sin(p.x * 4.7 - t * 0.45 + p.y * 1.3) * 0.25; + float c = sin((p.x + p.y) * 1.7 + t * 0.9) * 0.18; + return a + b + c; +} + +void main() { + float t = time; + float bass = clamp(energyLo.y, 0.0, 1.0); + float mids = clamp(energyLo.z, 0.0, 1.0); + float presence = clamp(energyLo.w, 0.0, 1.0); + float air = clamp(energyHi.x, 0.0, 1.0); + float beatC = clamp(beat, 0.0, 1.0); + float lvl = clamp(levelSmooth, 0.0, 1.0); + + float aspect = resX / max(resY, 1.0); + // x spread by aspect so the curtains aren't squashed on wide windows. + vec2 p = vec2((qt_TexCoord0.x - 0.5) * aspect, qt_TexCoord0.y); + + // Mids drive the sway amplitude & speed. + float swaySpeed = 0.6 + mids * 0.9; + float warp1 = flow(p * 3.0, t * swaySpeed); + float warp2 = flow(p * 2.0 + vec2(7.3, 0.0), t * (0.8 * swaySpeed) + 2.0); + float warp3 = flow(p * 2.6 + vec2(-4.1, 1.0), t * (0.65 * swaySpeed) + 4.0); + + // Curtain widths: bass swells the low curtain; beat lifts; presence the + // top. + float bw1 = 0.10 + bass * 0.16 + beatC * 0.03; + float bw2 = 0.09 + mids * 0.10; + float bw3 = 0.07 + presence * 0.10; + + // Anchors drift on a slow vertical LFO + a beat lift; sway from warp + + // mids. + float lift = beatC * 0.03; + float sway = 0.08 + mids * 0.12; + float center1 = 0.40 + 0.02 * sin(t * 0.23) + warp1 * sway - lift; + float center2 = 0.60 + 0.02 * sin(t * 0.19 + 1.7) + warp2 * sway - lift; + float center3 = 0.50 + 0.025 * sin(t * 0.16 + 3.1) + warp3 * (sway * 0.9) - lift; + + float band1 = bw1 / (abs(p.y - center1) + bw1); + float band2 = bw2 / (abs(p.y - center2) + bw2); + float band3 = bw3 / (abs(p.y - center3) + bw3); + + // Treble shimmer cross-fades each curtain base color toward accent. + float shFreq = 5.0 + air * 8.0; + float sh1 = 0.5 + 0.5 * sin(p.x * shFreq + t * 1.3); + float sh2 = 0.5 + 0.5 * sin(p.x * (shFreq * 0.8) - t * 1.1 + 1.0); + float sh3 = 0.5 + 0.5 * sin(p.x * (shFreq * 1.2) + t * 1.6 + 2.0); + float shGain = 0.25 + air * 0.5; + + vec3 col1 = mix(primary.rgb, accent.rgb, sh1 * shGain); + vec3 col2 = mix(secondary.rgb, accent.rgb, sh2 * shGain); + vec3 col3 = mix(accent.rgb, primary.rgb, sh3 * shGain); + + vec3 col = col1 * band1 * (0.55 + sh1 * 0.45); + col += col2 * band2 * (0.55 + sh2 * 0.45); + col += col3 * band3 * (0.5 + sh3 * 0.4) * (0.4 + presence * 0.8); + + // Baseline brightness from the smoothed level + beat; dim primary floor + // glow. + col *= 0.5 + lvl * 0.9; + col *= 1.0 + beatC * 0.5; + col += primary.rgb * 0.04; + + // Beat bloom: vertical shimmer streaks where the curtains already are. + float streak = abs(fract(p.y * 6.0 - t * 2.0) - 0.5); + float bloom = (0.5 / (streak * 8.0 + 0.5)) * beatC; + col += accent.rgb * bloom * (band1 + band2 + band3) * 0.4; + + col = clamp(col, vec3(0.0), vec3(1.0)); + // Premultiplied, like every other effect in this tree (blending: false at + // the mount, so qt_Opacity is 1 and this equals the reference frame). + fragColor = vec4(col * qt_Opacity, qt_Opacity); +} diff --git a/crates/qbz-qt/qml/assets/shaders/aurora.frag.qsb b/crates/qbz-qt/qml/assets/shaders/aurora.frag.qsb new file mode 100644 index 000000000..971dc7b1f Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/aurora.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/linebed.frag b/crates/qbz-qt/qml/assets/shaders/linebed.frag new file mode 100644 index 000000000..d65b69cc7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/linebed.frag @@ -0,0 +1,41 @@ +#version 440 + +// Line Bed (immersive scene, mode 5) — FRAGMENT stage. Line-faithful port +// of `fs_main` in crates/qbz-ui/ui/shaders/line_bed.wgsl: +// +// * the bottom cutoff (`in.pos.y > u.resolution.y * 0.86 → discard`) +// reads the `yFrac` varying the VS computed from its own screen-space +// position — identical value to the reference's framebuffer-space +// builtin, portable across the gl_FragCoord y-orientation split (see +// the vertex shader's header); +// * the gradient: valleys take `primary`, peaks tip toward `accent` +// (`g = clamp(intensity * 1.25, 0, 1)`), peaks also brighten via alpha +// (0.5 + g*0.4) under the pipeline's SrcAlpha blend. +// +// QSB-SKIP-GLES100 — paired with the vertex stage (texelFetch); the ES 2.0 +// variant cannot carry the scene and the tier gate keeps it off that +// hardware anyway. + +layout(std140, binding = 0) uniform buf { + vec2 resolution; + vec4 primary; + vec4 accent; +} u; + +layout(location = 0) in float intensity; +layout(location = 1) in float yFrac; + +layout(location = 0) out vec4 fragColor; + +void main() { + // Bottom cutoff — don't render below ~86% of the view, so the bed ends + // above the player bar instead of spilling to the window bottom. + if (yFrac > 0.86) + discard; + // Height gradient: the bed/valleys take `primary`, the mountain peaks + // tip toward `accent`, gradually — peaks also brighten. + float g = clamp(intensity * 1.25, 0.0, 1.0); + vec3 col = mix(u.primary.rgb, u.accent.rgb, g); + float a = 0.5 + g * 0.4; + fragColor = vec4(col, a); +} diff --git a/crates/qbz-qt/qml/assets/shaders/linebed.frag.qsb b/crates/qbz-qt/qml/assets/shaders/linebed.frag.qsb new file mode 100644 index 000000000..80483d0a0 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/linebed.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/linebed.vert b/crates/qbz-qt/qml/assets/shaders/linebed.vert new file mode 100644 index 000000000..556fe779b --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/linebed.vert @@ -0,0 +1,140 @@ +#version 440 + +// Line Bed (immersive scene, mode 5) — VERTEX stage. A line-faithful GLSL +// 440 port of `vs_main` in crates/qbz-ui/ui/shaders/line_bed.wgsl (itself a +// clean-room port of the Tauri LinebedPanel projection math): 200 +// depth-stacked polylines, one historical 256-point spectrum each (X = +// frequency, Y = magnitude, Z = age), projected with a LEVELED pitch-only +// camera, each band span subdivided with a Catmull-Rom-STYLE uniform cubic +// B-spline across the 4 neighboring bands so the polylines read as smooth +// curves. The 256x200 heights arrive as an R32F texture (binding 4), +// DEPTH-ORDERED row 0 = newest, fed by the Rust LineBedState +// (src/linebed_qt.rs) — `textureLoad` becomes `texelFetch`. +// +// The ONLY mechanical differences from the WGSL, enumerated: +// * @builtin(vertex_index) / @builtin(instance_index) become two FLOAT +// vertex attributes (`vid`, `lineIdx`) driven from the 1531-vert +// template buffer and a 200-entry per-instance buffer — the portable +// spelling under QRhi (GLES 3.0 has no gl_VertexID; the C++ side +// uploads 0..1530 / 0..199 verbatim). +// * `u.resolution: vec2` sits at offset 0 of a TRIMMED uniform block +// (this scene reads only resolution in the VS and primary/accent in the +// FS — line_bed.wgsl has no time/level/energy input); same values, +// same meaning as the 144-byte block's fields. +// * the FS cutoff coordinate is exported as the `yFrac` varying +// (screen_y / resolution.y) instead of being re-read from +// @builtin(position) in the FS — gl_FragCoord.y is BOTTOM-up on GL, +// top-down on wgpu/Vulkan, so the portable route is to interpolate the +// value the VS already computed. w == 1 throughout, so the interpolated +// yFrac equals the reference's per-pixel pos.y/resolution.y exactly. +// +// QSB-SKIP-GLES100 — texelFetch (vertex texture fetch) needs GLSL ES 3.00; +// the ES 2.0 variant cannot carry this shader and the tier gate keeps the +// scene off that hardware anyway. +// QSB-SKIP-BATCH — a QQuickRhiItem shader, NOT a Qt Quick ShaderEffect +// stage: the `-b` batching rewrite is only valid for scene-graph batched +// items and would corrupt the custom attribute layout. + +layout(location = 0) in float vid; // 0..1530: subdivided point index +layout(location = 1) in float lineIdx; // 0..199: depth row (per instance) + +layout(std140, binding = 0) uniform buf { + vec2 resolution; + vec4 primary; + vec4 accent; +} u; + +layout(binding = 4) uniform sampler2D heights_tex; + +layout(location = 0) out float intensity; +layout(location = 1) out float yFrac; + +out gl_PerVertex { vec4 gl_Position; }; + +// --- Camera constants (verbatim from line_bed.wgsl:42-59, which took them +// verbatim from LinebedPanel.svelte) --------------------------------------- +const float LINE_LENGTH = 9.0; +const float WORLD_AMPLITUDE = 2.4; +const float PLANE_HALF_WIDTH = 1147.5; // (255*9)/2 +const float PLANE_HALF_DEPTH = 1990.0; // (199*20)/2 +const float CAM_X = 26.1; +const float CAM_Y = 1738.6; +const float CAM_Z = 868.8; +const float CAMERA_NEAR = 80.0; +const float FOV_DEG = 45.0; // vertical +const int NUM_BANDS = 256; +// MUST match the host's LINEBED_SUBDIV (shader_underlay.rs:54 / +// linebed_qt.rs) and the 1531-vert template the C++ uploads. +const float SUBDIV = 6.0; +// Vertical screen position of the projection origin (lower = the bed sits +// HIGHER). 0.30 so the bed lifts off the bottom, clears the player bar, +// and uses the dead space up top. +const float VCENTER = 0.30; + +float height_at(int line, int band) { + int b = clamp(band, 0, NUM_BANDS - 1); + return texelFetch(heights_tex, ivec2(b, line), 0).r; +} + +// Uniform cubic B-spline through the 4 neighboring bands — C2-continuous +// (no kinks at the band points) and never overshoots; it APPROXIMATES +// (gently smooths) the samples, so the whole line reads as one continuous +// curve instead of stitched segments. +float curve_height(int line, float band_f) { + int b0 = int(floor(band_f)); + float t = band_f - float(b0); + float p0 = height_at(line, b0 - 1); + float p1 = height_at(line, b0); + float p2 = height_at(line, b0 + 1); + float p3 = height_at(line, b0 + 2); + float t2 = t * t; + float t3 = t2 * t; + float w0 = (1.0 - 3.0 * t + 3.0 * t2 - t3) / 6.0; + float w1 = (4.0 - 6.0 * t2 + 3.0 * t3) / 6.0; + float w2 = (1.0 + 3.0 * t + 3.0 * t2 - 3.0 * t3) / 6.0; + float w3 = t3 / 6.0; + return p0 * w0 + p1 * w1 + p2 * w2 + p3 * w3; +} + +void main() { + int line = int(lineIdx); + // Continuous (subdivided) band position along the line, 0..255. + float band_f = vid / SUBDIV; + float h = curve_height(line, band_f); + + // World position. Only Y is audio-driven; X/Z are the fixed lattice. + float world_x = band_f * LINE_LENGTH - PLANE_HALF_WIDTH; + float world_y = h * WORLD_AMPLITUDE; + float depth_factor = lineIdx / 199.0; + float world_z = -PLANE_HALF_DEPTH + depth_factor * (PLANE_HALF_DEPTH * 2.0); + + // LEVELED view transform: pitch ONLY (Rx). Yaw and roll are ZEROED so + // every line sits flat. + float cosX = cos(0.6543); + float sinX = sin(0.6543); + + float tX = world_x - CAM_X; + float tY = world_y - CAM_Y; + float tZ = world_z - CAM_Z; + + // Rz, Ry = identity (leveled); apply Rx (pitch about the X axis). + float rX = tX; + float rY = tY * cosX - tZ * sinX; + float rZ = tY * sinX + tZ * cosX; + + // Near-clip (v1 — no polyline split, like the reference). + float depth = max(-rZ, CAMERA_NEAR); + + // SAME focal for X and Y (no aspect correction — faithful to Tauri). + float focal = u.resolution.y * 0.5 / tan(FOV_DEG * 3.14159265 / 360.0); + float screen_x = u.resolution.x * 0.5 + rX * focal / depth; + float screen_y = u.resolution.y * VCENTER - rY * focal / depth; + + // Screen px -> clip/NDC (flip Y — QRhi's NDC is y-up like wgpu's). + float ndc_x = screen_x / u.resolution.x * 2.0 - 1.0; + float ndc_y = 1.0 - screen_y / u.resolution.y * 2.0; + + gl_Position = vec4(ndc_x, ndc_y, 0.0, 1.0); + intensity = clamp(h / 84.0, 0.0, 1.0); + yFrac = screen_y / u.resolution.y; +} diff --git a/crates/qbz-qt/qml/assets/shaders/linebed.vert.qsb b/crates/qbz-qt/qml/assets/shaders/linebed.vert.qsb new file mode 100644 index 000000000..b6eae2476 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/linebed.vert.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/plasma.frag b/crates/qbz-qt/qml/assets/shaders/plasma.frag new file mode 100644 index 000000000..d03b29ca2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/plasma.frag @@ -0,0 +1,316 @@ +#version 440 + +// Plasma (immersive scene, mode 1) — FRAGMENT stage. Line-faithful GLSL 440 +// port of `fs_main` in crates/qbz-ui/ui/shaders/plasma.wgsl (the MilkDrop- +// class feedback fluid: sample the previous frame at a warped UV, decay, +// inject orbiting ink + beat splats). The feedback history lives in two +// RGBA8 ping-pong textures owned by the C++ PlasmaItem (the reference keeps +// ONE history and copies the frame back — shader_underlay.rs:1003-1025 — +// which is the same recurrence; ping-pong is how you say "sample last frame, +// write this frame" without a surface readback). +// +// Mechanical differences from the WGSL, enumerated: +// * textureSample(prev_tex, prev_samp, uv) -> texture(prev_tex, uv) +// (combined image sampler, binding 1). +// * bitcast(i32(x)) -> uint(int(x)): int->uint is a modulo-2^32 +// conversion in GLSL, i.e. the same bit pattern the WGSL bitcast keeps. +// * The uniform block is the FULL 144-byte std140 scene block (spec 01 +// §1) — this scene reads time/resolution/energy_lo/energy_hi.x/beat/ +// level/level_smooth/primary/secondary/accent; the remaining fields +// arrive zeroed. +// +// QSB-SKIP-GLES100 — the lattice hash is UINT arithmetic (plasma.wgsl:55-67 +// records why a float hash draws "wallpaper join" seams); ES 2.0 has no +// uint. The tier gate keeps the scene off that hardware. + +layout(std140, binding = 0) uniform buf { + float time; + float phase; + float beat; + float level; + vec2 resolution; + float levelSmooth; + float transientAmp; + vec4 energyLo; + vec4 energyHi; + vec4 bandsLo; + vec4 bandsHi; + vec4 primary; + vec4 secondary; + vec4 accent; +} u; + +layout(binding = 1) uniform sampler2D prev_tex; + +layout(location = 0) in vec2 uv; +layout(location = 0) out vec4 fragColor; + +// INTEGER lattice hash (bit-exact) — see the WGSL header above. +float hash2(vec2 p) { + uint h = uint(int(p.x)) * 0x8da6b343u + uint(int(p.y)) * 0xd8163841u; + h = (h ^ (h >> 15u)) * 0x2c1b3c6du; + h = (h ^ (h >> 12u)) * 0x297a2d39u; + h = h ^ (h >> 15u); + return float(h >> 8u) * (1.0 / 16777216.0); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + float a = hash2(i); + float b = hash2(i + vec2(1.0, 0.0)); + float c = hash2(i + vec2(0.0, 1.0)); + float d = hash2(i + vec2(1.0, 1.0)); + vec2 uf = f * f * (3.0 - 2.0 * f); + return mix(mix(a, b, uf.x), mix(c, d, uf.x), uf.y); +} + +// Divergence-free flow from a scalar potential: curl = (dphi/dy, -dphi/dx). +vec2 curl_noise(vec2 p) { + float e = 0.05; + float dx = vnoise(p + vec2(e, 0.0)) - vnoise(p - vec2(e, 0.0)); + float dy = vnoise(p + vec2(0.0, e)) - vnoise(p - vec2(0.0, e)); + return vec2(dy, -dx) / (2.0 * e); +} + +// Rodrigues rotation about the grey axis — a hue turn that needs no RGB<->HSV +// round trip. The palette arrives as ONE album-derived triad, so without this +// every element in the scene is a shade of the same colour; turning a copy of +// it is how the worms and the beat arcs end up in different families whatever +// album is playing. +vec3 hueRot(vec3 c, float a) { + const vec3 k = vec3(0.57735026); + float ca = cos(a); + float sa = sin(a); + return c * ca + cross(k, c) * sa + k * dot(k, c) * (1.0 - ca); +} + +// Soft gaussian blob — an ink emitter. +float blob(vec2 p, vec2 c, float radius) { + vec2 d = p - c; + return exp(-dot(d, d) / max(radius * radius, 1e-4)); +} + +// -- WHY THIS LOOKS THE WAY IT DOES (2026-08-19 rework) -------------------- +// +// The reference is Butterchurn (MilkDrop in JS) on a percussion-heavy track, +// and the frame-by-frame gives the recipe in three parts: +// +// 1. CONCENTRIC RINGS. They are not drawn as rings. ONE thin annulus is +// injected per frame, and the feedback warp marches it inward — so the +// rings you see ARE the last N frames of audio, laid out in space. Time +// becomes geometry. That only works if a trail survives long enough to +// stack, which is the second part. +// 2. HIGH RETENTION. MilkDrop presets hold ~0.97+ per frame. This shader +// held 0.85: a trail died in ~15 frames, so nothing ever accumulated and +// four soft blobs orbited an empty field — "a Windows 98 wallpaper", +// accurately. +// 3. ANGULAR STRUCTURE. The spokes come from reading the spectrum BY ANGLE, +// so each direction carries its own band, and from folding the plane into +// mirrored sectors. +// +// Deliberately NOT here: a preset engine, per-preset equations, custom +// shapes/waves. This is Plasma made intense, not MilkDrop reimplemented. + +// The spectrum sampled AT AN ANGLE (0..1 around the circle), interpolated +// between the 8 bands so a spoke sweeps rather than steps. This is what turns +// a flat field into radial structure. +float bandAt(float a01, vec4 lo, vec4 hi) { + float x = fract(a01) * 8.0; + int i = int(x) % 8; + int j = (i + 1) % 8; + float f = fract(x); + float b[8] = float[8](lo.x, lo.y, lo.z, lo.w, hi.x, hi.y, hi.z, hi.w); + return mix(b[i], b[j], f * f * (3.0 - 2.0 * f)); +} + +void main() { + float t = u.time; + float aspect = u.resolution.x / max(u.resolution.y, 1.0); + + // SLOW (energy, x0.85/frame ~ 473 ms) drives STRUCTURE; FAST (bands, + // x0.65 ~ 178 ms) drives MOTION. The bands used to arrive zeroed — they + // were published and parsed but never handed to this item — which is why + // everything ran off the slowest signals in the pipeline. + float sub = clamp(u.energyLo.x, 0.0, 1.0); + float bass = clamp(u.energyLo.y, 0.0, 1.0); + float mids = clamp(u.energyLo.z, 0.0, 1.0); + float pres = clamp(u.energyLo.w, 0.0, 1.0); + float air = clamp(u.energyHi.x, 0.0, 1.0); + + vec4 fLo = clamp(u.bandsLo, vec4(0.0), vec4(1.0)); + vec4 fHi = clamp(u.bandsHi, vec4(0.0), vec4(1.0)); + // Sharpened copies: squaring drops the quiet floor without touching the + // peaks, so a dense mix reads as PEAKS instead of a lit plateau. + vec4 sLo = fLo * fLo; + vec4 sHi = fHi * fHi; + + // `beat` is the AC-COUPLED onset (PlasmaScene.qml): 0 at the local density + // floor, 1 on a hit above it, so it still reads as a hit at any tempo. + // `transientAmp` is the raw, shorter spike — the crack over the punch. + float beat = clamp(u.beat, 0.0, 1.0); + float hit = clamp(u.transientAmp, 0.0, 1.0); + float level = clamp(u.level, 0.0, 1.0); + float lvl = clamp(u.levelSmooth, 0.0, 1.0); + + vec2 c = (uv - vec2(0.5)) * vec2(aspect, 1.0); + float r = length(c); + float ang = atan(c.y, c.x); + + // TWO COLOUR FAMILIES out of the one album triad. The worms keep the + // palette as published; the beat arcs are turned ~130 degrees off it and + // the ambient wash sits opposite both. One triad rendered everything in a + // single hue before this — the scene was a teal disc with a purple hairline + // and nothing else. + vec3 wormA = u.primary.rgb; + vec3 wormB = u.secondary.rgb; + vec3 arcA = hueRot(u.accent.rgb, 2.27); + vec3 arcB = hueRot(u.secondary.rgb, 1.85); + vec3 ambA = hueRot(u.primary.rgb, 3.9); + + // === Feedback advection — the ring conveyor ============================ + // A firm inward march: this is what turns one injected annulus per frame + // into a stack of concentric rings. Bass widens the step (the tunnel + // breathes) and an onset shoves it. + float zoom = 1.018 + (sub + bass) * 0.020 + beat * 0.030 + hit * 0.014; + // Rotation on the FAST mid — the term that follows a line instead of + // averaging it — plus a radius-dependent shear so the rings twist into + // spirals rather than sitting concentric and dead. + float rot = (0.004 + mids * 0.020 + sLo.w * 0.045) * sin(t * 0.4 + r * 5.0) + + beat * 0.05; + float ca = cos(rot); + float sa = sin(rot); + vec2 w = c * zoom; + w = vec2(w.x * ca - w.y * sa, w.x * sa + w.y * ca); + // Curl octaves keep the rings from being perfect circles — the lattice + // texture in the reference. Coarse follows fast presence, fine follows + // fast brilliance, so filaments appear ON the attack. + w += curl_noise(c * 2.5 + vec2(t * 0.12, -t * 0.10)) * (0.002 + sHi.y * 0.014); + w += curl_noise(c * 6.5 - vec2(t * 0.09, t * 0.08)) * (0.001 + sHi.z * 0.010); + vec2 warpUv = w / vec2(aspect, 1.0) + vec2(0.5); + + vec3 field = texture(prev_tex, warpUv).rgb; + + // RETENTION — the single biggest change. 0.85 -> 0.945..0.975. Below ~0.93 + // nothing stacks and there is no structure to look at; the loud end stays + // short of 1.0 so the field always eventually forgets. + field *= 0.945 + lvl * 0.030; + + // === Mirrored sectors — the kaleidoscope =============================== + // Fold the angle into N mirrored wedges. The count rides the SLOW mids so + // it changes with the section instead of flickering per frame. + float sectors = floor(5.0 + mids * 5.0); + float aFold = abs(fract(ang / 6.2831853 * sectors) * 2.0 - 1.0); + + // === BEAT ARCS — not a circumference =================================== + // The Tauri reference this borrows from was BEAT DETECTION, and a closed + // ring is not that: a perfect inner circumference reads as a drawn shape, + // hard-edges the field, and at this retention the successive rings packed + // into one solid disc — which is exactly what killed the ambient feel. + // + // So the annulus is GATED BY ANGLE. Only the sectors whose gate opens emit, + // so a hit paints a few arcs that the conveyor then carries inward; the + // circle never closes and the space between arcs stays open for the + // background to show through. The gate threshold falls as the onset rises, + // so a big hit opens more of the circle and a quiet passage barely any. + float gateN = vnoise(vec2(ang * 2.9 - 3.0, t * 0.6)); + float gate = smoothstep(0.72 - beat * 0.34, 0.86 - beat * 0.30, gateN); + + float ringR = 0.20 + bass * 0.10 + beat * 0.18 + sub * 0.06; + float spoke = bandAt(ang / 6.2831853 + 0.5 + t * 0.02, fLo, fHi); + float sharp = spoke * spoke; + + // MADE OF PLASMA, NOT OF COMPASS: a slow wobble bends the arc off-round, a + // fast one frays its edge, and the thickness breathes around the circle so + // it is never a uniform band. + float wobLo = vnoise(vec2(ang * 1.7, t * 0.30)) - 0.5; + float wobHi = vnoise(vec2(ang * 6.5, t * 0.90)) - 0.5; + ringR += wobLo * (0.045 + mids * 0.070) + wobHi * (0.010 + sHi.x * 0.024); + float ringW = 0.005 + level * 0.008 + hit * 0.010; + ringW *= 0.55 + vnoise(vec2(ang * 3.1 + 11.0, t * 0.55)) * 1.1; + float ring = exp(-pow(r - ringR, 2.0) / max(ringW * ringW, 1e-5)); + // Injection is well down from the closed-ring version: it no longer has to + // carry the scene, and at 0.945 retention a strong one silts up into a disc. + field += mix(arcA, arcB, aFold) * ring * gate * (0.04 + sharp * 0.75 + beat * 0.65); + + // The outer filigree, gated the same way and on the high bands. + float gate2 = smoothstep(0.66, 0.88, vnoise(vec2(ang * 4.7 + 9.0, t * 0.85))); + float ring2R = 0.40 + air * 0.10 + sHi.w * 0.08 + + (vnoise(vec2(ang * 3.3 + 21.0, t * 0.45)) - 0.5) * 0.060; + float ring2 = exp(-pow(r - ring2R, 2.0) / 1.225e-5); + field += arcB * ring2 * gate2 * (sHi.x * 0.8 + sHi.z * 0.6); + + // === THE WORMS — all four emitters, back at full strength ============== + // The filaments are these blobs stretched by the curl advection over many + // frames. The rework demoted them to faint accents and the rings then + // buried them; they are the ORIGINAL character of this scene and they run + // at full amplitude again. The higher retention actually makes their + // trails longer than they ever were — the only thing that had gone wrong + // was how little ink they were injecting. + // + // Each orbit angle carries a fast band term, so a worm accelerates on its + // own voice instead of all four drifting at one rate. + float p1 = t * 0.50 + sLo.z * 2.4; + float p2 = -t * 0.42 + 2.1 - sLo.w * 2.0; + float p3 = t * 0.74 + 1.0 + sHi.y * 2.8; + float p4 = t * 0.33 + 4.0 + sHi.z * 3.0; + // ORBITS REACH PAST THE ARCS on purpose. They used to sit inside the ring + // radius, so the worms were trapped in the disc and never crossed its edge; + // now they run from ~0.30 out to ~0.62, i.e. through the arc band and + // beyond it, and the field outside the arcs stops being empty. + vec2 e1 = vec2(sin(p1), cos(p1 * 1.2)) * (0.34 + fLo.y * 0.24); + vec2 e2 = vec2(sin(p2), cos(p2 * 0.9)) * (0.44 + sLo.w * 0.20); + vec2 e3 = vec2(sin(p3), cos(-p3 * 0.9 + 3.0)) * (0.30 + sHi.y * 0.26); + vec2 e4 = vec2(cos(p4), sin(p4 * 1.75 + 0.5)) * (0.50 + sHi.z * 0.20); + // DEFINITION: tighter cores and a smoothstep contrast curve. A raw gaussian + // is all shoulder — it reads as haze at any brightness. Squeezing the + // falloff and then pushing the mid-range gives an edge the advection can + // stretch into an actual filament instead of a smear. + float w1 = blob(c, e1, 0.022 + fLo.y * 0.024); + float w2 = blob(c, e2, 0.024 + sLo.w * 0.022); + float w3 = blob(c, e3, 0.018 + sHi.y * 0.020); + float w4 = blob(c, e4, 0.015 + sHi.z * 0.018); + w1 = smoothstep(0.06, 0.75, w1); + w2 = smoothstep(0.06, 0.75, w2); + w3 = smoothstep(0.05, 0.70, w3); + w4 = smoothstep(0.05, 0.70, w4); + field += wormA * w1 * (0.16 + sLo.x * 0.95); + field += wormB * w2 * (0.16 + sLo.z * 0.90); + field += hueRot(wormA, 0.9) * w3 * (0.13 + sHi.x * 0.95); + field += hueRot(wormB, -0.8) * w4 * (0.11 + sHi.z * 0.95); + + // === Onset flash — a bright core the conveyor drags outward ============ + // Injected at the CENTRE on purpose: the inward warp then carries it as a + // shrinking ring, so every hit leaves a visible wavefront. + float b1 = 0.03 + beat * 0.09; + float b2 = 0.014 + hit * 0.04; + field += u.accent.rgb * exp(-r * r / max(b1 * b1, 1e-5)) * beat * 1.9; + field += u.secondary.rgb * exp(-r * r / max(b2 * b2, 1e-5)) * hit * 1.4; + + // === AMBIENT WASH — the background stops being black =================== + // A slow, low-amplitude curl field across the WHOLE frame, in the hue + // turned furthest from both foreground families. Two jobs: it gives the + // arcs and worms something to read against (they were bright objects on + // void, which is what made the disc look like a cut-out), and it fills the + // space the arc gate deliberately leaves open. The owner wants the Plasma + // field materially darker than the other scenes, so this wash stays around + // half its original injection while the foreground arcs/worms retain their + // full music-reactive range. + vec2 aw = c * 1.6 + vec2(t * 0.045, -t * 0.038); + float amb = vnoise(aw + curl_noise(aw) * 0.35); + float vign = 1.0 - smoothstep(0.30, 0.95, r); + field += ambA * (0.016 + lvl * 0.030) * (0.35 + amb * 0.65) * (0.25 + vign * 0.75); + + // Treble sparkle on the crests — fast brilliance, so it fires with the + // cymbals instead of glowing with the average. + float luma = dot(field, vec3(0.33, 0.34, 0.33)); + field += u.accent.rgb * (sHi.z * 0.26 + sHi.w * 0.18) * smoothstep(0.35, 0.9, luma); + + // SOFT LIMITER, and it is load-bearing at this retention. A hard clamp + // against 0.945+ feedback pins the middle to white and the structure + // disappears into a flat blob; this compresses instead, so bright areas + // keep their gradient and the rings stay readable through a loud passage. + field = field / (vec3(1.0) + field * 0.35); + field = clamp(field, vec3(0.0), vec3(1.0)); + fragColor = vec4(field, 1.0); +} diff --git a/crates/qbz-qt/qml/assets/shaders/plasma.frag.qsb b/crates/qbz-qt/qml/assets/shaders/plasma.frag.qsb new file mode 100644 index 000000000..3cc317d66 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/plasma.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/plasma_blit.frag b/crates/qbz-qt/qml/assets/shaders/plasma_blit.frag new file mode 100644 index 000000000..26fffdf85 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/plasma_blit.frag @@ -0,0 +1,19 @@ +#version 440 + +// Plasma (immersive scene, mode 1) — BLIT stage: copy the just-rendered +// history texture into the item's render target. The feedback pass renders +// into an owned ping-pong texture (never into the item target directly), +// so the item never has to be read back — this 1:1 textured triangle is the +// composite step. Same vertex stage as plasma.vert. +// +// QSB-SKIP-GLES100 — paired with the plasma passes (the tier gate keeps the +// scene off ES 2.0 hardware; a blit this small needs no ES 2.0 variant). + +layout(binding = 1) uniform sampler2D src_tex; + +layout(location = 0) in vec2 uv; +layout(location = 0) out vec4 fragColor; + +void main() { + fragColor = texture(src_tex, uv); +} diff --git a/crates/qbz-qt/qml/assets/shaders/plasma_blit.frag.qsb b/crates/qbz-qt/qml/assets/shaders/plasma_blit.frag.qsb new file mode 100644 index 000000000..bfe85e69c Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/plasma_blit.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/rhi_triangle.vert b/crates/qbz-qt/qml/assets/shaders/rhi_triangle.vert new file mode 100644 index 000000000..1e89f6f1e --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/rhi_triangle.vert @@ -0,0 +1,25 @@ +#version 440 + +// Shared fullscreen-triangle VERTEX stage for the QQuickRhiItem scenes +// (Plasma A2: the feedback AND blit passes; Spectral Ribbon A3). The Slint +// references derive the triangle from @builtin(vertex_index) +// (plasma.wgsl:42-53, spectral_ribbon.wgsl:44-51); GLES 3.0 has no +// gl_VertexID, so the C++ side uploads the three clip positions as a vertex +// buffer (the linebed.vert idiom). +// +// QSB-SKIP-BATCH — a QQuickRhiItem shader, NOT a Qt Quick ShaderEffect +// stage: the `-b` batching rewrite is only valid for scene-graph batched +// items and would corrupt the custom attribute layout. + +layout(location = 0) in vec2 pos; // clip-space fullscreen triangle + +layout(location = 0) out vec2 uv; + +out gl_PerVertex { vec4 gl_Position; }; + +void main() { + // plasma.wgsl:51 — uv = p * 0.5 + 0.5. Self-consistent for every pass + // that renders and samples with this convention. + uv = pos * 0.5 + vec2(0.5); + gl_Position = vec4(pos, 0.0, 1.0); +} diff --git a/crates/qbz-qt/qml/assets/shaders/rhi_triangle.vert.qsb b/crates/qbz-qt/qml/assets/shaders/rhi_triangle.vert.qsb new file mode 100644 index 000000000..96fc239d8 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/rhi_triangle.vert.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/scene_pack.vert b/crates/qbz-qt/qml/assets/shaders/scene_pack.vert new file mode 100644 index 000000000..78919019f --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/scene_pack.vert @@ -0,0 +1,45 @@ +#version 440 + +// PAIRED with tunnel.frag AND aurora.frag (their uniform layouts are +// identical, so one vertex stage serves both). +// +// THE PAIRING RULE (2026-08-15 black-Tunnel report): a ShaderEffect's two +// stages are LINKED into one program under the OpenGL RHI backend, and GLSL +// refuses one block name with two layouts ("uniform `_22' declared as type +// `buf' and type `buf'"). Vulkan never links stages, so the shared +// spectrum.vert (block = qt_Matrix + qt_Opacity) paired with these wider +// fragment blocks rendered fine there and died only on GL. The fix is Qt's +// own documented pattern: BOTH stages declare the SAME block, member for +// member, in the same order — the vertex stage simply never reads the +// fragment-only fields. Keep this block byte-identical to tunnel.frag's. + +layout(location = 0) in vec4 qt_Vertex; +layout(location = 1) in vec2 qt_MultiTexCoord0; +layout(location = 0) out vec2 qt_TexCoord0; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 primary; + vec4 secondary; + vec4 accent; + vec4 energyLo; + vec4 energyHi; + vec4 bandsLo; + vec4 bandsHi; + float qt_Opacity; + float time; + float phase; + float beat; + float level; + float resX; + float resY; + float levelSmooth; + float transientAmp; +}; + +out gl_PerVertex { vec4 gl_Position; }; + +void main() { + qt_TexCoord0 = qt_MultiTexCoord0; + gl_Position = qt_Matrix * qt_Vertex; +} diff --git a/crates/qbz-qt/qml/assets/shaders/scene_pack.vert.qsb b/crates/qbz-qt/qml/assets/shaders/scene_pack.vert.qsb new file mode 100644 index 000000000..4f3a85a2a Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/scene_pack.vert.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/spectral_ribbon.frag b/crates/qbz-qt/qml/assets/shaders/spectral_ribbon.frag new file mode 100644 index 000000000..9fa1178ea --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/spectral_ribbon.frag @@ -0,0 +1,106 @@ +#version 440 + +// Spectral Ribbon (immersive scene, mode 4) — FRAGMENT stage. Line-faithful +// GLSL 440 port of `fs_main` in crates/qbz-ui/ui/shaders/spectral_ribbon.wgsl +// (the paint-as-you-play sonogram: frequency on Y, time on X, the Spek +// purple→orange heatmap). The CPU side keeps the persistent R8 spectrogram +// (512 freq bands × 2048 time columns, shader_underlay.rs:44-45); the Qt +// port's Rust half publishes one 512-byte row per viz tick and the C++ +// RibbonItem uploads it at the playback-time column with the same gap-fill +// (shader_underlay.rs:897-927). +// +// Mechanical differences from the WGSL, enumerated: +// * textureSample(spectrogram, samp, uv) -> texture(spectrogram, uv) +// (combined image sampler, binding 3 like the reference). +// * log(x)/log(10.0) stays as-is (GLSL has no log10 on all targets; the +// two-log spelling IS the reference's). +// The plot-rect Y convention (PLOT_Y0/Y1 already flipped vs the Slint +// overlay, spectral_ribbon.wgsl:77-81) is UNCHANGED — the QML overlay +// (SpectralOverlay.qml) keeps the overlay's top-down fractions, and the +// item's mirrorVertically makes the GL composite match wgpu's display. + +layout(std140, binding = 0) uniform buf { + float time; + float phase; + float beat; + float level; + vec2 resolution; + float levelSmooth; + float transientAmp; + vec4 energyLo; + vec4 energyHi; + vec4 bandsLo; + vec4 bandsHi; + vec4 primary; + vec4 secondary; + vec4 accent; +} u; + +layout(binding = 3) uniform sampler2D spectrogram; + +layout(location = 0) in vec2 uv; +layout(location = 0) out vec4 fragColor; + +// Spek-like 7-stop purple→magenta→orange ramp (Tauri spekColor), linear RGB. +vec3 spek_color(float x) { + float t = clamp(x, 0.0, 1.0); + // stops: 0.00 black, 0.36 deep-blue, 0.60 indigo, 0.78 purple, + // 0.92 magenta, 0.98 red-orange, 1.00 orange. + if (t < 0.36) { + return mix(vec3(0.0, 0.0, 0.0), vec3(0.0, 0.0, 0.243), t / 0.36); + } else if (t < 0.60) { + return mix(vec3(0.0, 0.0, 0.243), vec3(0.055, 0.0, 0.392), (t - 0.36) / 0.24); + } else if (t < 0.78) { + return mix(vec3(0.055, 0.0, 0.392), vec3(0.361, 0.0, 0.439), (t - 0.60) / 0.18); + } else if (t < 0.92) { + return mix(vec3(0.361, 0.0, 0.439), vec3(0.745, 0.0, 0.282), (t - 0.78) / 0.14); + } else if (t < 0.98) { + return mix(vec3(0.745, 0.0, 0.282), vec3(0.863, 0.188, 0.0), (t - 0.92) / 0.06); + } else { + return mix(vec3(0.863, 0.188, 0.0), vec3(0.933, 0.471, 0.125), (t - 0.98) / 0.02); + } +} + +// Plot rectangle in 0..1 screen space (leaves a margin for axes; the bottom +// band clears the song card / time axis). MUST match SpectralOverlay.qml. +const float PLOT_X0 = 0.055; +const float PLOT_X1 = 0.970; +const float PLOT_Y0 = 0.220; +const float PLOT_Y1 = 0.930; + +void main() { + vec3 bg = vec3(0.012, 0.027, 0.047); // #03070c + + // Inside the plot rectangle? + if (uv.x < PLOT_X0 || uv.x > PLOT_X1 || uv.y < PLOT_Y0 || uv.y > PLOT_Y1) { + fragColor = vec4(bg, 1.0); + return; + } + + // Plot-local coords. tf = time fraction (left→right), ff = freq + // fraction (band 0/bass at the BOTTOM, 511/Nyquist at the TOP). + float tf = (uv.x - PLOT_X0) / (PLOT_X1 - PLOT_X0); + float ff = (uv.y - PLOT_Y0) / (PLOT_Y1 - PLOT_Y0); + + // Sample the spectrogram: u = frequency band (0..1 over 512), v = time + // column. Un-played columns are zero -> background. + float amp = texture(spectrogram, vec2(ff, tf)).r; + + // dB + gamma (Tauri): db in [-120,0] -> normalized -> ^2.15. + float db = 20.0 * log(max(1e-6, amp)) / log(10.0); + float db_norm = clamp((db + 120.0) / 120.0, 0.0, 1.0); + float toned = pow(db_norm, 2.15); + vec3 col = spek_color(toned); + float a = (10.0 + toned * 156.0) / 255.0; + + // Composite the heatmap over the dark background. + vec3 outc = mix(bg, col, a); + + // Real-time ceiling line: a horizontal line at the highest active + // frequency (u.energyHi.y = smoothed peak band fraction), spanning the + // full plot width, in the axis green. + float on_line = 1.0 - smoothstep(0.0, 0.006, abs(ff - u.energyHi.y)); + outc = mix(outc, vec3(0.373, 0.722, 0.478), on_line * 0.65); + + fragColor = vec4(outc, 1.0); +} diff --git a/crates/qbz-qt/qml/assets/shaders/spectral_ribbon.frag.qsb b/crates/qbz-qt/qml/assets/shaders/spectral_ribbon.frag.qsb new file mode 100644 index 000000000..dfa1665b6 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/spectral_ribbon.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum.vert b/crates/qbz-qt/qml/assets/shaders/spectrum.vert new file mode 100644 index 000000000..4cb546b6f --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/spectrum.vert @@ -0,0 +1,17 @@ +#version 440 + + layout(location = 0) in vec4 qt_Vertex; + layout(location = 1) in vec2 qt_MultiTexCoord0; + layout(location = 0) out vec2 qt_TexCoord0; + + layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + }; + + out gl_PerVertex { vec4 gl_Position; }; + + void main() { + qt_TexCoord0 = qt_MultiTexCoord0; + gl_Position = qt_Matrix * qt_Vertex; + } diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum.vert.qsb b/crates/qbz-qt/qml/assets/shaders/spectrum.vert.qsb new file mode 100644 index 000000000..4d514aa6c Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/spectrum.vert.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_bars.frag b/crates/qbz-qt/qml/assets/shaders/spectrum_bars.frag new file mode 100644 index 000000000..8e06ddd81 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/spectrum_bars.frag @@ -0,0 +1,70 @@ +#version 440 + + layout(location = 0) in vec2 qt_TexCoord0; + layout(location = 0) out vec4 fragColor; + + layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 topColor; + vec4 bottomColor; + float qt_Opacity; + float bandW; + float bandH; + float b0; + float b1; + float b2; + float b3; + float b4; + float b5; + float b6; + float b7; + float b8; + float b9; + float b10; + float b11; + float b12; + float b13; + }; + + float amp(int i) { + if (i == 0) return b0; + if (i == 1) return b1; + if (i == 2) return b2; + if (i == 3) return b3; + if (i == 4) return b4; + if (i == 5) return b5; + if (i == 6) return b6; + if (i == 7) return b7; + if (i == 8) return b8; + if (i == 9) return b9; + if (i == 10) return b10; + if (i == 11) return b11; + if (i == 12) return b12; + return b13; + } + + float rrectAlpha(vec2 p, float w, float h, float r) { + float rr = min(r, 0.5 * min(w, h)); + vec2 q = abs(p - 0.5 * vec2(w, h)) - 0.5 * vec2(w, h) + rr; + float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - rr; + return 1.0 - smoothstep(-0.5, 0.5, d); + } + + void main() { + vec2 px = qt_TexCoord0 * vec2(bandW, bandH); + float slot = bandW / 28.0; + int col = int(floor(px.x / slot)); + col = clamp(col, 0, 27); + int m = col < 14 ? col : 27 - col; + float h = max(2.0, amp(m) * bandH); + float w = slot - 2.0; + vec2 lp = vec2(px.x - (float(col) * slot + 1.0), px.y - (bandH - h)); + float shape = rrectAlpha(lp, w, h, 1.0); + if (shape <= 0.0) { + fragColor = vec4(0.0); + return; + } + vec4 g = mix(topColor, bottomColor, clamp(lp.y / h, 0.0, 1.0)); + float a = g.a * shape * qt_Opacity; + fragColor = vec4(g.rgb * a, a); + } diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_bars.frag.qsb b/crates/qbz-qt/qml/assets/shaders/spectrum_bars.frag.qsb new file mode 100644 index 000000000..c2b7c34fa Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/spectrum_bars.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_bars.vert b/crates/qbz-qt/qml/assets/shaders/spectrum_bars.vert new file mode 100644 index 000000000..63b7c8fda --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/spectrum_bars.vert @@ -0,0 +1,41 @@ +#version 440 + + // PAIRED with spectrum_bars.frag (SpectrumBand). THE PAIRING RULE (see + // scene_pack.vert): both stages must declare the SAME uniform block, + // member for member, or the OpenGL program link rejects the two layouts + // (Vulkan never links, so it tolerated the mismatch). Keep this block + // byte-identical to spectrum_bars.frag's. + + layout(location = 0) in vec4 qt_Vertex; + layout(location = 1) in vec2 qt_MultiTexCoord0; + layout(location = 0) out vec2 qt_TexCoord0; + + layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 topColor; + vec4 bottomColor; + float qt_Opacity; + float bandW; + float bandH; + float b0; + float b1; + float b2; + float b3; + float b4; + float b5; + float b6; + float b7; + float b8; + float b9; + float b10; + float b11; + float b12; + float b13; + }; + + out gl_PerVertex { vec4 gl_Position; }; + + void main() { + qt_TexCoord0 = qt_MultiTexCoord0; + gl_Position = qt_Matrix * qt_Vertex; + } diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_bars.vert.qsb b/crates/qbz-qt/qml/assets/shaders/spectrum_bars.vert.qsb new file mode 100644 index 000000000..dc164e384 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/spectrum_bars.vert.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_energy.frag b/crates/qbz-qt/qml/assets/shaders/spectrum_energy.frag new file mode 100644 index 000000000..16839419b --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/spectrum_energy.frag @@ -0,0 +1,51 @@ +#version 440 + + layout(location = 0) in vec2 qt_TexCoord0; + layout(location = 0) out vec4 fragColor; + + layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 topColor; + vec4 bottomColor; + float qt_Opacity; + float bandW; + float bandH; + float b0; + float b1; + float b2; + float b3; + float b4; + }; + + float amp(int i) { + if (i == 0) return b0; + if (i == 1) return b1; + if (i == 2) return b2; + if (i == 3) return b3; + return b4; + } + + float rrectAlpha(vec2 p, float w, float h, float r) { + float rr = min(r, 0.5 * min(w, h)); + vec2 q = abs(p - 0.5 * vec2(w, h)) - 0.5 * vec2(w, h) + rr; + float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - rr; + return 1.0 - smoothstep(-0.5, 0.5, d); + } + + void main() { + vec2 px = qt_TexCoord0 * vec2(bandW, bandH); + float slot = bandW / 5.0; + int col = int(floor(px.x / slot)); + col = clamp(col, 0, 4); + float h = max(2.0, amp(col) * bandH); + float w = slot - 6.0; + vec2 lp = vec2(px.x - (float(col) * slot + 3.0), px.y - (bandH - h)); + float shape = rrectAlpha(lp, w, h, 1.0); + if (shape <= 0.0) { + fragColor = vec4(0.0); + return; + } + vec4 g = mix(topColor, bottomColor, clamp(lp.y / h, 0.0, 1.0)); + float a = g.a * shape * qt_Opacity; + fragColor = vec4(g.rgb * a, a); + } diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_energy.frag.qsb b/crates/qbz-qt/qml/assets/shaders/spectrum_energy.frag.qsb new file mode 100644 index 000000000..1110a134e Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/spectrum_energy.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_energy.vert b/crates/qbz-qt/qml/assets/shaders/spectrum_energy.vert new file mode 100644 index 000000000..c602ae208 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/spectrum_energy.vert @@ -0,0 +1,31 @@ +#version 440 + + // PAIRED with spectrum_energy.frag (SpectrumBand). THE PAIRING RULE (see + // scene_pack.vert): both stages must declare the SAME uniform block, + // member for member, or the OpenGL program link rejects the two layouts. + // Keep this block byte-identical to spectrum_energy.frag's. + + layout(location = 0) in vec4 qt_Vertex; + layout(location = 1) in vec2 qt_MultiTexCoord0; + layout(location = 0) out vec2 qt_TexCoord0; + + layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 topColor; + vec4 bottomColor; + float qt_Opacity; + float bandW; + float bandH; + float b0; + float b1; + float b2; + float b3; + float b4; + }; + + out gl_PerVertex { vec4 gl_Position; }; + + void main() { + qt_TexCoord0 = qt_MultiTexCoord0; + gl_Position = qt_Matrix * qt_Vertex; + } diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_energy.vert.qsb b/crates/qbz-qt/qml/assets/shaders/spectrum_energy.vert.qsb new file mode 100644 index 000000000..5728079d1 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/spectrum_energy.vert.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_wave.frag b/crates/qbz-qt/qml/assets/shaders/spectrum_wave.frag new file mode 100644 index 000000000..e68a31375 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/spectrum_wave.frag @@ -0,0 +1,135 @@ +#version 440 + + layout(location = 0) in vec2 qt_TexCoord0; + layout(location = 0) out vec4 fragColor; + + layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 topColor; + float qt_Opacity; + float bandW; + float bandH; + float b0; + float b1; + float b2; + float b3; + float b4; + float b5; + float b6; + float b7; + float b8; + float b9; + float b10; + float b11; + float b12; + float b13; + float b14; + float b15; + float b16; + float b17; + float b18; + float b19; + float b20; + float b21; + float b22; + float b23; + float b24; + float b25; + float b26; + float b27; + float b28; + float b29; + float b30; + float b31; + float b32; + float b33; + float b34; + float b35; + float b36; + float b37; + float b38; + float b39; + float b40; + float b41; + float b42; + float b43; + float b44; + float b45; + float b46; + float b47; + }; + + float amp(int i) { + if (i == 0) return b0; + if (i == 1) return b1; + if (i == 2) return b2; + if (i == 3) return b3; + if (i == 4) return b4; + if (i == 5) return b5; + if (i == 6) return b6; + if (i == 7) return b7; + if (i == 8) return b8; + if (i == 9) return b9; + if (i == 10) return b10; + if (i == 11) return b11; + if (i == 12) return b12; + if (i == 13) return b13; + if (i == 14) return b14; + if (i == 15) return b15; + if (i == 16) return b16; + if (i == 17) return b17; + if (i == 18) return b18; + if (i == 19) return b19; + if (i == 20) return b20; + if (i == 21) return b21; + if (i == 22) return b22; + if (i == 23) return b23; + if (i == 24) return b24; + if (i == 25) return b25; + if (i == 26) return b26; + if (i == 27) return b27; + if (i == 28) return b28; + if (i == 29) return b29; + if (i == 30) return b30; + if (i == 31) return b31; + if (i == 32) return b32; + if (i == 33) return b33; + if (i == 34) return b34; + if (i == 35) return b35; + if (i == 36) return b36; + if (i == 37) return b37; + if (i == 38) return b38; + if (i == 39) return b39; + if (i == 40) return b40; + if (i == 41) return b41; + if (i == 42) return b42; + if (i == 43) return b43; + if (i == 44) return b44; + if (i == 45) return b45; + if (i == 46) return b46; + return b47; + } + + float rrectAlpha(vec2 p, float w, float h, float r) { + float rr = min(r, 0.5 * min(w, h)); + vec2 q = abs(p - 0.5 * vec2(w, h)) - 0.5 * vec2(w, h) + rr; + float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - rr; + return 1.0 - smoothstep(-0.5, 0.5, d); + } + + void main() { + vec2 px = qt_TexCoord0 * vec2(bandW, bandH); + float slot = bandW / 48.0; + int col = int(floor(px.x / slot)); + col = clamp(col, 0, 47); + float h = max(2.0, amp(col) * bandH); + float w = slot - 2.0; + vec2 lp = vec2(px.x - (float(col) * slot + 1.0), px.y - (bandH - h) * 0.5); + float shape = rrectAlpha(lp, w, h, 1.0); + if (shape <= 0.0) { + fragColor = vec4(0.0); + return; + } + float a = topColor.a * shape * qt_Opacity; + fragColor = vec4(topColor.rgb * a, a); + } diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_wave.frag.qsb b/crates/qbz-qt/qml/assets/shaders/spectrum_wave.frag.qsb new file mode 100644 index 000000000..35db04078 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/spectrum_wave.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_wave.vert b/crates/qbz-qt/qml/assets/shaders/spectrum_wave.vert new file mode 100644 index 000000000..02e014acd --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/spectrum_wave.vert @@ -0,0 +1,73 @@ +#version 440 + + // PAIRED with spectrum_wave.frag (SpectrumBand). THE PAIRING RULE (see + // scene_pack.vert): both stages must declare the SAME uniform block, + // member for member, or the OpenGL program link rejects the two layouts. + // Keep this block byte-identical to spectrum_wave.frag's. + + layout(location = 0) in vec4 qt_Vertex; + layout(location = 1) in vec2 qt_MultiTexCoord0; + layout(location = 0) out vec2 qt_TexCoord0; + + layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 topColor; + float qt_Opacity; + float bandW; + float bandH; + float b0; + float b1; + float b2; + float b3; + float b4; + float b5; + float b6; + float b7; + float b8; + float b9; + float b10; + float b11; + float b12; + float b13; + float b14; + float b15; + float b16; + float b17; + float b18; + float b19; + float b20; + float b21; + float b22; + float b23; + float b24; + float b25; + float b26; + float b27; + float b28; + float b29; + float b30; + float b31; + float b32; + float b33; + float b34; + float b35; + float b36; + float b37; + float b38; + float b39; + float b40; + float b41; + float b42; + float b43; + float b44; + float b45; + float b46; + float b47; + }; + + out gl_PerVertex { vec4 gl_Position; }; + + void main() { + qt_TexCoord0 = qt_MultiTexCoord0; + gl_Position = qt_Matrix * qt_Vertex; + } diff --git a/crates/qbz-qt/qml/assets/shaders/spectrum_wave.vert.qsb b/crates/qbz-qt/qml/assets/shaders/spectrum_wave.vert.qsb new file mode 100644 index 000000000..8bd248528 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/spectrum_wave.vert.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/tunnel.frag b/crates/qbz-qt/qml/assets/shaders/tunnel.frag new file mode 100644 index 000000000..096dce4bc --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/tunnel.frag @@ -0,0 +1,197 @@ +#version 440 + +// Tunnel (immersive scene, mode 2) — a line-faithful GLSL 440 port of +// crates/qbz-ui/ui/shaders/tunnel.wgsl (DOOM TUNNEL, original clean-room +// shader). Every constant and every formula below is the reference's number; +// the differences are mechanical and are enumerated here: +// +// * uniform block instead of the bind group (the ShaderEffect contract): +// qt_Matrix/qt_Opacity are Qt-supplied, everything else is a property on +// the effect item, resolved BY NAME out of the .qsb reflection data (see +// ambient.frag). `resolution: vec2` arrives as resX/resY floats, the +// energy/bands vec4s as energyLo/energyHi/bandsLo/bandsHi — the QML +// property names are spelled identically on both sides. +// * uv = qt_TexCoord0 replaces in.uv DIRECTLY (no y-flip) — the same +// convention the ambient.frag port uses and the owner validated against +// the reference frame. Vertically this mirrors the wgpu frame; the scene +// is y-symmetric except the wind/sway phases, so the picture is the +// reference's mirrored, consistent with the Ambient port. +// * `let` -> local float decls; `array(...)` -> `float[8](...)`; +// `atan2(y,x)` -> `atan(y,x)`; `select(f,t,cond)` -> ternary; +// `u32(ringId & 7)` -> int index, `b[ringId & 7]` — two's-complement +// `& 7` on a negative ringId wraps identically to WGSL i32&u32 in both +// languages (-1 & 7 = 7), and the 4096 phase wrap (host side) keeps the +// ring math seamless exactly like the reference. +// * Output is premultiplied (`col * qt_Opacity`), like every other effect +// in this tree; the layer mounts it with blending: false, so opacity is +// 1.0 and the picture equals the reference's `vec4(col, 1.0)`. +// +// QSB-SKIP-GLES100 — build.rs drops the `100 es` variant for this file: GLSL +// ES 1.00 has no integer bitwise ops (band_at's `& 7`), and there is no +// Canvas fallback arm for scenes — on a tier that cannot run the effect the +// picker gate (QbzShell.shaderScenesAvailable) never offers the scene, and a +// load failure makes ShaderSceneLayer hand the background back to the +// atmosphere. +// +// The algorithm (tunnel.wgsl:78-184): NOT a raymarcher — a single fragment +// pass. Chebyshev/box distance r = max(|p.x|, |p.y|) gives SQUARE rings; the +// curvature lives in the PATH (the corridor snakes), never in the +// cross-section; the host forward-motion clock (phase, wrapped at 4096) is +// amplified by FLIGHT_SPEED; colors come from the album-art palette. + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + vec4 primary; + vec4 secondary; + vec4 accent; + vec4 energyLo; + vec4 energyHi; + vec4 bandsLo; + vec4 bandsHi; + float qt_Opacity; + float time; + float phase; + float beat; + float level; + float resX; + float resY; + float levelSmooth; + // Named transientAmp, not `transient`: the uniform is matched BY NAME to + // a QML property and `transient` is a RESERVED QML keyword (qmlcachegen + // rejects it). Same field, same JSON pack key (`transient`) — only the + // QML-side identifier changes. + float transientAmp; +}; + +// One of the 8 log FFT bands, selected by ring index (per-ring spectral +// sweep). WGSL masks with `& 7u`; the int two's-complement equivalent wraps +// negatives the same way. +float band_at(int i) { + float b[8] = float[8]( + bandsLo.x, bandsLo.y, bandsLo.z, bandsLo.w, + bandsHi.x, bandsHi.y, bandsHi.z, bandsHi.w + ); + return b[i & 7]; +} + +// --- Tunables (the reference's numbers, tunnel.wgsl:65-75) ----------------- +// Forward-flight multiplier on the host phase clock. The clock wraps at 4096 +// (an integer multiple of 8), so any INTEGER multiplier keeps floor()/fract()/ +// `& 7` ring math seamless across the (≈hourly) wrap. +const float FLIGHT_SPEED = 6.0; +// How hard the centerline snakes. The bend is applied to the PATH, so the +// rectangles only translate — they never warp. +const float BEND_AMT = 0.14; +// Number of radial speed-lines fanning out of the vanishing point (even, so +// they stay continuous across the atan seam at ±π). +const float SPOKES = 16.0; + +void main() { + float t = time; + vec2 uv = qt_TexCoord0; + + // FFT aggregates from the 8 log bands (match the Tauri canvas crossovers: + // bars 0-3 / 4-9 / 10-15 land on even band-pair boundaries). + float bass = clamp((bandsLo.x + bandsLo.y) * 0.5, 0.0, 1.0); + float mid = clamp((bandsLo.z + bandsLo.w + bandsHi.x) / 3.0, 0.0, 1.0); + float high = clamp((bandsHi.y + bandsHi.z + bandsHi.w) / 3.0, 0.0, 1.0); + float beatC = clamp(beat, 0.0, 1.0); + + // FAST flight: the host forward-motion clock (base 0.012 + level + beat + // per frame, wrapped at 4096) amplified. + float flight = phase * FLIGHT_SPEED; + + // Corridor cross-section is WIDER than tall (a hallway, not a square + // shaft): wider-than-tall scale derived from the viewport aspect. + float viewAspect = resX / max(resY, 1.0); + float scaleX = clamp(1.04 + viewAspect * 0.25, 1.22, 1.58); + float scaleY = clamp(1.38 - scaleX * 0.45, 0.62, 0.82); + + // Vanishing point: a gentle Lissajous sway (from `time` only, so its own + // phase never jumps). Kept small — the real motion is the depth-wind. + float cphase = t * 0.18; + vec2 center = vec2(0.5, 0.5); + center.x += sin(cphase) * 0.03 + sin(cphase * 2.08 + 1.2) * 0.012; + center.y += cos(cphase * 0.82 + 0.7) * 0.02; + + // First pass: unbent cross-section + depth, so we know how DEEP this + // pixel sits before bending the path. + vec2 p0 = (uv - center) / vec2(scaleX, scaleY); + float r0 = max(abs(p0.x), abs(p0.y)) + 1e-4; + float depth0 = 1.0 / r0; + float z0 = log2(depth0) - flight; + + // WINDING CENTERLINE — the curvature is in the PATH, not the rectangle. + // The wave scrolls toward you with `flight`; the near mouth barely bends, + // deep sections swing the most. Louder music = a livelier road. + float windE = 0.75 + levelSmooth * 0.6 + beatC * 0.3; + float windX = sin(z0 * 0.65 + t * 0.6) + 0.5 * sin(z0 * 1.27 - t * 0.9); + float windY = 0.6 * cos(z0 * 0.85 + t * 0.5) + 0.35 * sin(z0 * 1.6 + t * 0.7); + float bendDepth = smoothstep(0.0, 2.6, depth0); // ~0 at the mouth -> 1 deep in + vec2 bend = vec2(windX, windY) * (bendDepth * BEND_AMT * windE); + + // Second pass: BENT cross-section coords; BOX distance (Chebyshev) -> + // SQUARE rings that ride the winding path. + vec2 p = p0 - bend; + float r = max(abs(p.x), abs(p.y)) + 1e-4; + float depth = 1.0 / r; + float z = log2(depth) - flight; // minus -> rings grow outward (forward) + int ringId = int(floor(z)); + float ringFrac = fract(z); + + // Rectangle outline: bright on the frame edge, dark in the gap. Thickness + // breathes with bass + beat. + float lineW = 0.06 + bass * 0.16 + beatC * 0.10; + float edge = smoothstep(0.0, lineW, ringFrac) * smoothstep(0.0, lineW, 1.0 - ringFrac); + float frame = 1.0 - edge; + + // Per-ring spectral pulse — the band sweeping bass->treble into depth. + float ringPulse = band_at(ringId); + + // Four corridor corner lines (|p.x| == |p.y|) converging to the portal — + // the strongest "hallway" cue. + float corner = 1.0 - smoothstep(0.0, 0.05, abs(abs(p.x) - abs(p.y))); + + // RADIAL SPEED-LINES fanning OUT of the vanishing point: thin spokes at + // SPOKES angles, invisible at the portal, fading in just outside it and + // streaming toward the mouth. They rotate very slowly and shimmer hard + // with treble + the beat, so they read as light rushing past. + float ang = atan(p.y, p.x); + float spokeWave = 0.5 + 0.5 * cos(ang * SPOKES + t * 0.25); + float spoke = pow(spokeWave, 12.0) + * smoothstep(0.02, 0.14, r) // emanate from the vanishing point + * (1.0 - smoothstep(0.85, 1.3, r)); // ease off at the near mouth + + // Depth shading: black square portal at the center, lit corridor outward. + float depthShade = smoothstep(0.04, 0.55, r); + float nearWeight = smoothstep(0.35, 1.1, r); + + // Palette: rings recede primary (near mouth) -> secondary -> accent (far). + float palT = 1.0 - smoothstep(0.05, 0.6, r); + vec3 ringCol = mix(primary.rgb, secondary.rgb, smoothstep(0.0, 0.5, palT)); + ringCol = mix(ringCol, accent.rgb, smoothstep(0.5, 1.0, palT)); + + // Walls: lit gray gradient distinguishing left/right from top/bottom. + float wallLit = (abs(p.x) > abs(p.y)) ? 0.16 : 0.10; + vec3 col = vec3(wallLit) * depthShade * (0.6 + mid * 0.8); + + // Ring frames (palette + spectral pulse + bass) and the corner lines. + float ringBright = frame * depthShade * (0.45 + ringPulse * 1.3 + bass * 0.5); + col += ringCol * ringBright; + col += accent.rgb * corner * depthShade * (0.35 + high * 0.7) * frame; + + // Radial speed-lines (toward `primary` so they read as bright streaks). + col += primary.rgb * spoke * (0.5 + high * 1.4 + beatC * 0.8); + + // Beat punch + a treble rim sparkle on the nearest ring (toward accent). + col *= 1.0 + beatC * 0.6; + col += accent.rgb * high * nearWeight * frame * 0.25; + + col = clamp(col, vec3(0.0), vec3(1.0)); + // Premultiplied, like every other effect in this tree (blending: false at + // the mount, so qt_Opacity is 1 and this equals the reference frame). + fragColor = vec4(col * qt_Opacity, qt_Opacity); +} diff --git a/crates/qbz-qt/qml/assets/shaders/tunnel.frag.qsb b/crates/qbz-qt/qml/assets/shaders/tunnel.frag.qsb new file mode 100644 index 000000000..093a170f6 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/tunnel.frag.qsb differ diff --git a/crates/qbz-qt/qml/assets/shaders/tunnel_flow.frag b/crates/qbz-qt/qml/assets/shaders/tunnel_flow.frag new file mode 100644 index 000000000..19b3498f2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/tunnel_flow.frag @@ -0,0 +1,533 @@ +#version 440 + +// Tunnel Flow (immersive shader scene, mode 8 — Qt-only) — FRAGMENT stage. +// Block B1 of the 2026-08-15 immersive-completion contract (spec +// 02-tauri-tunnel-port.md). A REWRITE of the legacy Tauri Canvas2D panel +// (qbz-worktrees/legacy-tauri .../panels/TunnelFlowPanel.svelte) as a +// feedback fragment shader: 18 warped square ring shells traveling outward, +// feedback trails (history sampled with the inverse zoom/rotation/drift +// transform at alpha ~0.74, then the near-black fade fill), the background +// wash, the portal (black hole + throat ring + halo), and the corner +// vignette. The feedback history lives in two RGBA8 ping-pong textures owned +// by the C++ TunnelFlowItem (cxx/tunnelflow_item.* — the PlasmaItem shape). +// +// Mechanical differences from the Canvas2D source, enumerated: +// * Vector polygon fills/strokes become PER-PIXEL ANALYTIC evaluation: each +// ring is a square SHELL measured with the Chebyshev distance +// (max(|x|/hx, |y|/hy)) to the warped square. The per-corner jitter of +// makeWarpedSquare is kept as four scalars per square and interpolated +// piecewise-linearly along the perimeter toward the pixel (the side the +// pixel direction hits, between that side's two corners); the boundary +// moves by 0.64*jitter — the outward-normal component of the source's +// (sx*0.6+sy*0.22, sy*0.6-sx*0.22) diagonal displacement. +// * Canvas2D blend modes become explicit color math: `screen` is +// mix(dst, 1-(1-dst)(1-src), srcAlpha) per layer, `multiply` is +// dst *= 1-alpha (black source), and `soft-light` (the diagonal haze) is +// the W3C per-pixel soft-light formula composited with the source alpha +// (spec 02 §3 sanctions "the standard soft-light formula applied +// per-pixel" — this is that choice). +// * The strut sort (rings by perspective) needs no sort: perspective is +// monotonic in travel, so the sorted order is a MODULAR ROTATION of the +// ring indices (sorted rank j = ringIndex (i0+j) % 18, i0 = the ring +// whose travel just wrapped). The gate/span/lane/start formulas are +// ported verbatim — the "simplified deterministic pick" spec 02 §3 +// allows, with zero visual approximation beyond the sort itself. +// * drawSpeedMarks is dead code in the source (STREAK_COUNT = 0) and is +// NOT ported (spec 02 §3). +// * The source rendered at 0.58-0.7x CSS size and was upscaled; Qt renders +// at physical size (x DPR, capped 2560x1440 — the plasma_item pattern). +// Stroke widths are multiplied by WIDTH_COMP (1.56 ~ mean 1/scale) so +// the lines keep their on-screen weight; every other constant is +// verbatim. +// * Canvas y is DOWN, texture uv y is UP (uv = pos*0.5+0.5, the plasma +// convention; the GL composite flip is mirrorVertically on the item). +// All motion terms are symmetric sinusoids, so only fixed asymmetries +// would differ — there are none that read as orientation. +// * The 36x36-artwork palette arrives as four vec4 uniforms (Rust port of +// extractLinePaletteFromArtwork, src/tunnelflow_qt.rs); the audio +// smoothing/kick/phase accumulators run in TunnelFlowScene.qml on the +// pulse edge (the pulse law — nothing ticks off-pulse). +// +// No uint arithmetic -> the full qsb variant bake (no QSB-SKIP-GLES100). + +layout(std140, binding = 0) uniform buf { + float time; // seconds (the shader converts to the source's ms) + float phase; // the TAURI phase accumulator (TunnelFlowScene.qml) + float bass; // mean of smoothed Viz16 bands 0..3 + float mid; // bands 4..9 + float high; // bands 10..15 + float kick; // kickPulse (decays 0.9/pulse) + vec2 resolution; // history target px (capped 2560x1440) + vec4 palette0; + vec4 palette1; + vec4 palette2; + vec4 palette3; +} u; + +layout(binding = 1) uniform sampler2D prev_tex; + +layout(location = 0) in vec2 uv; +layout(location = 0) out vec4 fragColor; + +// Stroke-width compensation for the source's 0.58-0.7 render scale (see the +// header): 1/0.7 .. 1/0.58 = 1.43..1.72, midpoint ~1.56. +const float WIDTH_COMP = 1.56; +// (e^3.2 - 1) denominator of the spacing curve. +const float SPACING_DENOM = 23.5325301971; // exp(3.2) - 1 +const int RING_COUNT = 18; + +// Frame-scope values the ring/portal helpers share (set once in main). +float gT; // time, MILLISECONDS (the source's timeMs domain) +float gPhase; +float gBass, gMid, gHigh, gKick; +float gMinDim; +vec2 gTC; // wandering tunnel center, px +vec2 gTScale; // tunnelScaleX/Y (the elliptical cross-section) +vec2 gPx; // the pixel being shaded, px + +vec4 pal(int i) { + int k = i - (i / 4) * 4; // i % 4 without the % int pitfalls + if (k == 0) return u.palette0; + if (k == 1) return u.palette1; + if (k == 2) return u.palette2; + return u.palette3; +} + +float clamp01(float x) { return clamp(x, 0.0, 1.0); } + +// Canvas2D `screen` with source alpha (dst is opaque). +vec3 screenOver(vec3 dst, vec3 src, float a) { + return mix(dst, vec3(1.0) - (vec3(1.0) - dst) * (vec3(1.0) - src), clamp(a, 0.0, 1.0)); +} + +// W3C soft-light, one channel. +float softLightCh(float s, float d) { + if (s <= 0.5) + return d - (1.0 - 2.0 * s) * d * (1.0 - d); + float g = d <= 0.25 ? ((16.0 * d - 12.0) * d + 4.0) * d : sqrt(d); + return d + (2.0 * s - 1.0) * (g - d); +} +vec3 softLight(vec3 src, vec3 dst) { + return vec3(softLightCh(src.r, dst.r), softLightCh(src.g, dst.g), softLightCh(src.b, dst.b)); +} + +// makeWarpedSquare's per-corner jitter scalar (verbatim waves). +float cornerJitter(float halfSize, float warp, float seed, int k) { + float fk = float(k); + float waveA = sin(gT * 0.0015 + seed * 0.9 + fk * 1.7); + float waveB = cos(gT * 0.0012 - seed * 0.7 + fk * 1.3); + return halfSize * warp * (waveA * 0.6 + waveB * 0.4); +} + +// Warped-square corner position (verbatim displacement). +vec2 cornerPos(vec2 c, float halfSize, float jitter, int k) { + vec2 s = vec2((k == 0 || k == 3) ? -1.0 : 1.0, (k < 2) ? -1.0 : 1.0); + return c + vec2(s.x * halfSize * gTScale.x + (s.x * 0.6 + s.y * 0.22) * jitter * gTScale.x, + s.y * halfSize * gTScale.y + (s.y * 0.6 - s.x * 0.22) * jitter * gTScale.y); +} + +// Piecewise-linear interpolation of the four corner jitters along the +// perimeter, toward the pixel direction nq (square-normalized coords). +float perimJitter(vec2 nq, float j0, float j1, float j2, float j3) { + if (abs(nq.x) >= abs(nq.y)) { + float s = clamp(nq.y / max(abs(nq.x), 1e-5), -1.0, 1.0); + return nq.x > 0.0 ? mix(j1, j2, s * 0.5 + 0.5) : mix(j0, j3, s * 0.5 + 0.5); + } + float s = clamp(nq.x / max(abs(nq.y), 1e-5), -1.0, 1.0); + return nq.y > 0.0 ? mix(j3, j2, s * 0.5 + 0.5) : mix(j0, j1, s * 0.5 + 0.5); +} + +float segDist(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a; + vec2 ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0); + return length(pa - ba * h); +} + +// Antialiased stroke coverage: width w px centered on the path. +float strokeCov(float d, float w) { + float aa = max(fwidth(d), 0.6); + return 1.0 - smoothstep(w * 0.5 - aa, w * 0.5 + aa, d); +} + +// Per-ring derived geometry (drawTunnelLayers, verbatim constants). +struct Ring { + vec2 c; // bent center, px + float outerHalf; + float innerHalf; + float warpO; // outer warp + float warpI; // inner warp (x0.72) + float fade; + float travel; + float spacing; +}; + +Ring ringAt(int i, float travelRate) { + float fi = float(i); + float travel = fract(fi / float(RING_COUNT) + travelRate); + float persp = pow(travel, 1.42); + float spacing = (exp(persp * 3.2) - 1.0) / SPACING_DENOM; + float appear = clamp01((persp - 0.18) / 0.26); + float vanish = 1.0 - clamp01((persp - 0.9) / 0.1); + Ring r; + r.fade = pow(max(appear * vanish, 0.0), 0.82); + r.outerHalf = gMinDim * (0.084 + spacing * 0.92); + float thickness = gMinDim * (0.005 + (1.0 - spacing) * (0.028 + gBass * 0.016) + gKick * 0.0036); + r.innerHalf = max(gMinDim * 0.028, r.outerHalf - thickness); + r.warpO = 0.0025 + (1.0 - spacing) * 0.013 + gHigh * 0.004; + r.warpI = r.warpO * 0.72; + float bendStrength = gMinDim * (0.018 + gHigh * 0.01 + gKick * 0.008) * (1.0 - spacing); + r.c = gTC + vec2(sin(gT * 0.0011 + fi * 0.31 + gPhase * 0.12) * bendStrength, + cos(gT * 0.00086 + fi * 0.24 + gPhase * 0.1) * bendStrength * 0.66); + r.travel = travel; + r.spacing = spacing; + return r; +} + +void main() { + vec2 res = u.resolution; + float W = res.x; + float minDim = min(res.x, res.y); + float aspect = res.x / max(res.y, 1.0); + + gT = u.time * 1000.0; + gPhase = u.phase; + gBass = u.bass; + gMid = u.mid; + gHigh = u.high; + gKick = u.kick; + gMinDim = minDim; + gPx = uv * res; // texture space, y-up (see the header) + + float tunnelScaleX = clamp(1.04 + aspect * 0.25, 1.22, 1.58); + float tunnelScaleY = clamp(1.38 - tunnelScaleX * 0.45, 0.62, 0.82); + gTScale = vec2(tunnelScaleX, tunnelScaleY); + + // --- Tunnel center wander (render(), verbatim) ------------------------- + vec2 canvasCenter = res * 0.5; + float curvePhase = gT * 0.0006 + gPhase * 0.14; + gTC = vec2( + canvasCenter.x + sin(curvePhase) * (W * (0.14 + gHigh * 0.05 + gKick * 0.04)) + + sin(curvePhase * 2.08 + 1.2) * W * 0.042, + canvasCenter.y + cos(curvePhase * 0.82 + 0.7) * (minDim * (0.036 + gBass * 0.018 + gKick * 0.014)) + + cos(curvePhase * 1.7 + 0.4) * minDim * 0.012); + float lateralCurve = clamp((gTC.x - canvasCenter.x) / max(1.0, W * 0.25), -1.0, 1.0); + + // --- 1. Feedback (drawFeedback) ----------------------------------------- + // The source draws the previous frame with + // M = T(tc) * R(a) * S(zoom) * T(-tc + drift) + // so destination pixel p shows the history at M^-1(p) = + // tc - drift + R(-a) * (p - tc) / zoom. + float fbAngle = sin(gT * 0.0011) * 0.0022 + (gHigh - gBass) * 0.003; + float zoom = 1.004 + gBass * 0.014 + gHigh * 0.01 + gKick * 0.011; + float driftRadius = minDim * (0.006 + gBass * 0.016 + gHigh * 0.01 + gKick * 0.012); + vec2 drift = vec2(cos(gT * 0.001 + gPhase * 0.18), sin(gT * 0.0012 + gPhase * 0.16)) * driftRadius; + vec2 d = gPx - gTC; + float ca = cos(fbAngle); + float sa = sin(fbAngle); + vec2 rd = vec2(d.x * ca + d.y * sa, -d.x * sa + d.y * ca) / zoom; // R(-a)*d + vec2 histUv = (gTC - drift + rd) / res; + vec3 col = texture(prev_tex, histUv).rgb * (0.74 + gKick * 0.08); + + // --- 2. Dark fade fill: rgba(3,0,5, 0.28+high*0.08), source-over -------- + float fadeA = 0.28 + gHigh * 0.08; + col = col * (1.0 - fadeA) + vec3(3.0, 0.0, 5.0) / 255.0 * fadeA; + + // --- 3. Background wash (drawBackground) -------------------------------- + // Radial center wash, source-over. + float washT = clamp01((length(gPx - gTC) - minDim * 0.06) / (minDim * 1.16 - minDim * 0.06)); + float washA; + vec3 washC; + float a0 = 0.008 + gBass * 0.008; + float a1 = 0.006 + gHigh * 0.006; + if (washT < 0.45) { + float k = washT / 0.45; + washC = mix(vec3(1.0), vec3(180.0 / 255.0), k); + washA = mix(a0, a1, k); + } else { + float k = (washT - 0.45) / 0.55; + washC = vec3(180.0 / 255.0) * (1.0 - k); + washA = a1 * (1.0 - k); + } + col = mix(col, washC, washA); + // Diagonal haze, soft-light (see the header for the formula choice). + float hazeT = clamp01((gPx.x * res.x + gPx.y * res.y) / (res.x * res.x + res.y * res.y)); + float hazeA; + vec3 hazeC; + float h0 = 0.004 + gHigh * 0.005; + float h1 = 0.005 + gBass * 0.007; + if (hazeT < 0.5) { + float k = hazeT * 2.0; + hazeC = vec3(1.0 - k); + hazeA = h0 * (1.0 - k); + } else { + float k = (hazeT - 0.5) * 2.0; + hazeC = vec3(180.0 / 255.0) * k; + hazeA = h1 * k; + } + col = mix(col, softLight(hazeC, col), hazeA); + + // --- 4. Rings (drawTunnelLayers, screen composite) ---------------------- + float travelRate = gPhase * (1.28 + gBass * 0.24 + gKick * 0.34); + Ring prevRing; + float prevJI[4]; + for (int i = 0; i < RING_COUNT; ++i) { + Ring r = ringAt(i, travelRate); + float jI[4]; + for (int k = 0; k < 4; ++k) + jI[k] = cornerJitter(r.innerHalf, r.warpI, float(i) + 1.7, k); + + if (r.fade > 0.0008) { + float jO[4]; + for (int k = 0; k < 4; ++k) + jO[k] = cornerJitter(r.outerHalf, r.warpO, float(i) + 0.3, k); + + // Warped shell distances (Chebyshev + perimeter jitter). + vec2 q = gPx - r.c; + vec2 nqO = q / vec2(r.outerHalf * gTScale.x, r.outerHalf * gTScale.y); + float jOP = perimJitter(nqO, jO[0], jO[1], jO[2], jO[3]); + float chebO = max(abs(nqO.x), abs(nqO.y)) - 0.64 * jOP / r.outerHalf; + vec2 nqI = q / vec2(r.innerHalf * gTScale.x, r.innerHalf * gTScale.y); + float jIP = perimJitter(nqI, jI[0], jI[1], jI[2], jI[3]); + float chebI = max(abs(nqI.x), abs(nqI.y)) - 0.64 * jIP / r.innerHalf; + + // Wall gradient quad (inner->outer): gray a*0.22 -> white a*1.16 + // at 44% -> transparent, masked to the shell. + float rI = (r.innerHalf + 0.64 * jIP) / r.outerHalf; + float gRing = (chebO - rI) / max(1.0 - rI, 1e-4); + float alpha = r.fade * (0.2 + gBass * 0.08 + gKick * 0.06); + float wallA; + vec3 wallC; + if (gRing < 0.44) { + float k = clamp(gRing / 0.44, 0.0, 1.0); + wallC = mix(vec3(210.0 / 255.0), vec3(1.0), k); + wallA = mix(alpha * 0.22, min(alpha * 1.16, 1.0), k); + } else { + float k = clamp((gRing - 0.44) / 0.56, 0.0, 1.0); + wallC = vec3(1.0); + wallA = min(alpha * 1.16, 1.0) * (1.0 - k); + } + float aa = max(fwidth(gRing), 0.02); + wallA *= smoothstep(0.0, aa, gRing); + col = screenOver(col, wallC, wallA); + + // Sparse wall stains ((i + side) % 3 == 0), radial palette blobs. + for (int side = 0; side < 4; ++side) { + if ((i + side) - ((i + side) / 3) * 3 != 0) + continue; + vec2 nrm = vec2(side == 1 ? 1.0 : (side == 3 ? -1.0 : 0.0), + side == 2 ? 1.0 : (side == 0 ? -1.0 : 0.0)); + vec2 tng = vec2(side == 0 ? 1.0 : (side == 2 ? -1.0 : 0.0), + side == 1 ? 1.0 : (side == 3 ? -1.0 : 0.0)); + float lane = sin(float(i) * 1.13 + float(side) * 1.7 + gT * 0.0012 + r.travel * 22.0); + vec2 radial = vec2(r.outerHalf * gTScale.x, r.outerHalf * gTScale.y); + vec2 bc = gTC + nrm * radial * 0.82 + tng * radial * 0.58 * lane; + float blobSize = minDim * (0.008 + r.fade * (0.018 + gBass * 0.01)); + float bd = length(gPx - bc); + float bt = clamp01((bd - blobSize * 0.14) / (blobSize * 0.94)); + vec3 bcC; + float bcA; + if (bt < 0.62) { + float k = bt / 0.62; + bcC = mix(pal(i + side).rgb, pal(i + side + 1).rgb, k); + bcA = mix(alpha * (0.56 + gMid * 0.18), alpha * (0.3 + gHigh * 0.12), k); + } else { + float k = (bt - 0.62) / 0.38; + bcC = pal(i + side + 1).rgb; + bcA = alpha * (0.3 + gHigh * 0.12) * (1.0 - k); + } + float edge = 1.0 - smoothstep(blobSize - max(fwidth(bd), 0.6), + blobSize + max(fwidth(bd), 0.6), bd); + col = screenOver(col, bcC, bcA * edge); + } + + // Palette-colored edge stroke on the inner square. + float edgeAlpha = r.fade * (0.34 + gBass * 0.12 + gKick * 0.08); + float edgeWidth = 0.86 + r.fade * (1.78 + gBass * 0.72 + gKick * 0.56); + vec3 edgeColor = pal(i).rgb; + float pxScale = abs(nqI.x) >= abs(nqI.y) ? r.innerHalf * gTScale.x + : r.innerHalf * gTScale.y; + float edgeDist = abs(chebI - 1.0) * pxScale; + col = screenOver(col, edgeColor, + edgeAlpha * strokeCov(edgeDist, edgeWidth * WIDTH_COMP)); + + // Corner connectors to the previous ring's inner square (same + // stroke style, width x0.88, min 0.74). + if (i > 0) { + float connW = max(0.74, edgeWidth * 0.88) * WIDTH_COMP; + for (int k = 0; k < 4; ++k) { + vec2 pa = cornerPos(prevRing.c, prevRing.innerHalf, prevJI[k], k); + vec2 pb = cornerPos(r.c, r.innerHalf, jI[k], k); + col = screenOver(col, edgeColor, + edgeAlpha * strokeCov(segDist(gPx, pa, pb), connW)); + } + } + } + + prevRing = r; + for (int k = 0; k < 4; ++k) + prevJI[k] = jI[k]; + } + + // --- Struts (the sorted cross-ring lines) ------------------------------- + // The sort is a modular rotation (see the header); all gate math verbatim. + { + float audioDrive = clamp01(gBass * 0.38 + gHigh * 0.28 + gKick * 1.04); + int linesPerSide = 1 + int(floor(audioDrive * 1.6)); + float eventStepMs = max(92.0, 172.0 - audioDrive * 84.0); + float bucket = floor(gT / eventStepMs); + // i0: the ring with the smallest travel (sorted rank 0). + float w = fract(travelRate) * float(RING_COUNT); + int i0 = int(ceil(float(RING_COUNT) - w)) % RING_COUNT; + for (int side = 0; side < 4; ++side) { + int nextSide = (side + 1) % 4; + for (int tr = 0; tr < 2; ++tr) { + if (tr >= linesPerSide) + break; + float seed = float(side) * 11.37 + float(tr) * 3.83 + bucket * 0.41; + float gate = (sin(bucket * 0.47 + gPhase * 2.2 + seed) + 1.0) * 0.5; + if (gate < 0.6 - audioDrive * 0.32) + continue; + int span = gate > 0.76 ? 3 : 2; + int maxStart = RING_COUNT - span - 1; + float startSel = (sin(gT * 0.0014 + gPhase * 1.8 + seed * 0.9) + 1.0) * 0.5; + int startIndex = min(maxStart, int(floor(startSel * float(maxStart + 1)))); + int endIndex = min(RING_COUNT - 1, startIndex + span); + float laneSel = clamp01((sin(bucket * 0.73 + seed * 1.3) + 1.0) * 0.5); + float lane = laneSel < 0.333333 ? 0.2 : (laneSel < 0.666667 ? 0.5 : 0.8); + Ring rs = ringAt((i0 + startIndex) % RING_COUNT, travelRate); + Ring re = ringAt((i0 + endIndex) % RING_COUNT, travelRate); + float lineFade = clamp01((rs.fade + re.fade) * 0.7); + vec2 a0 = cornerPos(rs.c, rs.innerHalf, + cornerJitter(rs.innerHalf, rs.warpI, + float((i0 + startIndex) % RING_COUNT) + 1.7, side), + side); + vec2 b0 = cornerPos(rs.c, rs.innerHalf, + cornerJitter(rs.innerHalf, rs.warpI, + float((i0 + startIndex) % RING_COUNT) + 1.7, nextSide), + nextSide); + vec2 a1 = cornerPos(re.c, re.innerHalf, + cornerJitter(re.innerHalf, re.warpI, + float((i0 + endIndex) % RING_COUNT) + 1.7, side), + side); + vec2 b1 = cornerPos(re.c, re.innerHalf, + cornerJitter(re.innerHalf, re.warpI, + float((i0 + endIndex) % RING_COUNT) + 1.7, nextSide), + nextSide); + vec2 p0 = mix(a0, b0, lane); + vec2 p1 = mix(a1, b1, lane); + float strutAlpha = (0.32 + audioDrive * 0.34) * lineFade; + col = screenOver(col, pal(side * 3 + tr + startIndex).rgb, + strutAlpha * strokeCov(segDist(gPx, p0, p1), + (1.5 + audioDrive * 1.4) * WIDTH_COMP)); + } + } + } + + // --- 5. Portal (drawPortal) ---------------------------------------------- + { + float portalHalf = minDim * 0.057; + float throatOuterHalf = portalHalf * 1.58; + float warpO = 0.008 + gHigh * 0.006; + float warpI = 0.004 + gHigh * 0.004; + // Throat ring (screen), gradient white a -> gray a*0.24 at 50% -> 0. + float jO0 = cornerJitter(throatOuterHalf, warpO, 941.0, 0); + float jO1 = cornerJitter(throatOuterHalf, warpO, 941.0, 1); + float jO2 = cornerJitter(throatOuterHalf, warpO, 941.0, 2); + float jO3 = cornerJitter(throatOuterHalf, warpO, 941.0, 3); + float jI0 = cornerJitter(portalHalf, warpI, 942.0, 0); + float jI1 = cornerJitter(portalHalf, warpI, 942.0, 1); + float jI2 = cornerJitter(portalHalf, warpI, 942.0, 2); + float jI3 = cornerJitter(portalHalf, warpI, 942.0, 3); + vec2 q = gPx - gTC; + vec2 nqO = q / vec2(throatOuterHalf * gTScale.x, throatOuterHalf * gTScale.y); + float chebO = max(abs(nqO.x), abs(nqO.y)) + - 0.64 * perimJitter(nqO, jO0, jO1, jO2, jO3) / throatOuterHalf; + vec2 nqI = q / vec2(portalHalf * gTScale.x, portalHalf * gTScale.y); + float jIP = perimJitter(nqI, jI0, jI1, jI2, jI3); + float chebI = max(abs(nqI.x), abs(nqI.y)) - 0.64 * jIP / portalHalf; + float sideAlpha = 0.032 + gHigh * 0.015; + float rI = portalHalf / throatOuterHalf; + float gT2 = (chebO - rI) / (1.0 - rI); + float throatA; + vec3 throatC; + if (gT2 < 0.5) { + float k = clamp(gT2 * 2.0, 0.0, 1.0); + throatC = mix(vec3(1.0), vec3(185.0 / 255.0), k); + throatA = mix(sideAlpha, sideAlpha * 0.24, k); + } else { + float k = clamp((gT2 - 0.5) * 2.0, 0.0, 1.0); + throatC = vec3(185.0 / 255.0); + throatA = sideAlpha * 0.24 * (1.0 - k); + } + throatA *= smoothstep(0.0, max(fwidth(gT2), 0.02), gT2); + col = screenOver(col, throatC, throatA); + + // The black hole (0.82x the inner square), source-over a=0.98. + float holeDist = (chebI - 0.82) * min(portalHalf * gTScale.x, portalHalf * gTScale.y); + float holeCov = 1.0 - smoothstep(-max(fwidth(holeDist), 0.6), max(fwidth(holeDist), 0.6), holeDist); + col = mix(col, vec3(0.0), 0.98 * holeCov); + + // Multiply dark halo out to 4.8x portal (radial, circular). + float haloT = clamp01((length(q) - portalHalf * 0.6) / (portalHalf * 4.8 - portalHalf * 0.6)); + float haloA; + if (haloT < 0.32) { + haloA = mix(0.72, 0.48, haloT / 0.32); + } else if (haloT < 0.72) { + haloA = mix(0.48, 0.18, (haloT - 0.32) / 0.4); + } else { + haloA = 0.18 * (1.0 - (haloT - 0.72) / 0.28); + } + col *= 1.0 - haloA; + + // Directional front shadow on the curve-leading side (multiply). + float portalEdgeWidth = 0.96 + gBass * 0.26; + float lateralAbs = abs(lateralCurve); + if (lateralAbs > 0.06) { + float shadowStrength = clamp01((lateralAbs - 0.06) / 0.58); + // The curve-leading SIDE of the portal band: x-dominant (for the + // square, the side region is |x| >= |y|) on the sign the tunnel + // center wanders toward. + bool lead = abs(nqI.x) >= abs(nqI.y) && nqI.x * sign(lateralCurve) > 0.0; + float gSh = clamp01((chebI - 0.82) / 0.18); + float shA; + if (gSh < 0.72) { + shA = mix(0.56 + shadowStrength * 0.22, 0.22 + shadowStrength * 0.18, gSh / 0.72); + } else { + shA = (0.22 + shadowStrength * 0.18) * (1.0 - (gSh - 0.72) / 0.28); + } + float inBand = smoothstep(0.0, max(fwidth(gSh), 0.02), gSh) + * (1.0 - smoothstep(1.0 - max(fwidth(gSh), 0.02), 1.0, gSh)); + col *= 1.0 - shA * (lead ? inBand : 0.0); + // Dark stroke on the hole edge, leading side only. + float holeEdge = abs(chebI - 0.82) * min(portalHalf * gTScale.x, portalHalf * gTScale.y); + col *= 1.0 - (0.42 + shadowStrength * 0.22) + * (lead ? strokeCov(holeEdge, max(1.0, portalEdgeWidth * 1.2) * WIDTH_COMP) : 0.0); + } + + // Portal edge highlight + hole rim (source-over, palette0). + float pxScale = abs(nqI.x) >= abs(nqI.y) ? portalHalf * gTScale.x + : portalHalf * gTScale.y; + float edgeDist = abs(chebI - 1.0) * pxScale; + col = mix(col, pal(0).rgb, + (0.18 + gHigh * 0.06) * strokeCov(edgeDist, portalEdgeWidth * WIDTH_COMP)); + float rimDist = abs(chebI - 0.82) * pxScale; + col = mix(col, pal(0).rgb, + (0.14 + gHigh * 0.04) * strokeCov(rimDist, max(0.76, portalEdgeWidth * 0.8) * WIDTH_COMP)); + } + + // --- 6. Corner vignette (drawCornerVignette, multiply) ------------------- + for (int c = 0; c < 4; ++c) { + vec2 corner = vec2((c == 1 || c == 3) ? res.x : 0.0, (c < 2) ? 0.0 : res.y); + float vt = clamp01(length(gPx - corner) / (minDim * 0.86)); + float va; + if (vt < 0.44) { + va = mix(0.72, 0.38, vt / 0.44); + } else { + va = 0.38 * (1.0 - (vt - 0.44) / 0.56); + } + col *= 1.0 - va; + } + + fragColor = vec4(col, 1.0); +} diff --git a/crates/qbz-qt/qml/assets/shaders/tunnel_flow.frag.qsb b/crates/qbz-qt/qml/assets/shaders/tunnel_flow.frag.qsb new file mode 100644 index 000000000..7baba4959 Binary files /dev/null and b/crates/qbz-qt/qml/assets/shaders/tunnel_flow.frag.qsb differ diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml new file mode 100644 index 000000000..7e8a3a756 --- /dev/null +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -0,0 +1,999 @@ +// THE shared album card — QML replica of discover/AlbumCard.slint, used +// by BOTH the Home rails and the Library All grid (the Slint mounts the +// same component in both places). +// +// 200x246: 200px artwork (Radius.sm) + placeholder, hover scrim with +// genre/year meta, Quick View + pin group (top-right), favorite / play / more +// overlay buttons, award ribbon, source badge (opt-in), then the title/artist +// lines with the icon-only quality badge. +// +// Live wiring: play (art click + overlay play), favorite heart (optimistic +// + signal), pin badge (pinned store), ⋯ context menu — and the SAME menu +// on a right press anywhere on the artwork or the title. +// +// --- Menu inventory vs discover/AlbumCard.slint ------------------------ +// Open album · Play · Play next · Play later · Add to queue · +// Add to/Remove from Library (show-favorite) · +// Add to playlist · Add to mixtape · Make available offline (catalog, not +// pulled — QoL round + 2026-08-31 additions over the .slint inventory) · +// Block this album (source != local && source != plex) +// (the first entry's NOUN is overridable per host — see `openLabel`; the last +// two are gated on `catalogAffordances`) +// …plus whatever the host appends through `extraMenuEntries` (My QBZ's +// "Remove from collection" — see that property). +// All but the last are live. "Block this album" is gated OFF by +// `hasBlacklistSeam` below: the Qt bridge exposes the blacklist COUNTERS +// (settings_qt/devtools.rs) but no block-album invokable, and the row used +// to render and do nothing at all. + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Rectangle { + id: root + + // --- card data (the ONE contract both hosts fill) -------------------- + property string albumId: "" + property string title: "" + property string artist: "" + property string artistId: "" + // Hosts with only a display snapshot (Home's persisted Pinned row) can + // still make the artist line a link and resolve the destination lazily. + // Normal catalog cards keep the direct artistId path and pay no lookup. + property bool hostArtistLink: false + signal artistRequested() + property string genre: "" + property string year: "" + property string qualityTier: "" + /// Exact stored quality ("24-bit / 96 kHz"), shown by the badge's hover + /// tooltip. Hosts whose model has no detail (un-hydrated Plex/remote + /// rows) may pass the bare filetype ("FLAC") — known limitation, and + /// still truthful; empty shows no tooltip at all. + property string qualityDetail: "" + property string ribbon: "" + property string ribbonKind: "" + // Artwork image source (file://… or "") — the host's cache lookup. + property string artSource: "" + // REMOTE cover url, for the pin payload only (the AlbumCard.slint pin + // TouchArea passes `album.artwork-url`). The pinned store keeps a + // denormalized display snapshot taken at pin time, so a host that + // leaves this empty pins a row the Pinned rail can only draw as a + // placeholder — `artSource` is NOT a substitute, it is a local + // file:// path that means nothing on another machine or after a + // cache wipe. + property string artworkUrl: "" + property bool isFavorite: false + property bool isPinned: false + // Explicit catalog withdrawal. Absence/false means the producer made no + // unavailability claim. A complete offline copy keeps the album live. + property bool qobuzUnavailable: false + // Library tombstones opt in to release replacement. Kept separate from + // qobuzUnavailable because a missing local/server source also uses the + // card's pulled presentation but has no Qobuz release to search for. + property bool replacementAffordance: false + property int cacheStatus: 0 + readonly property bool pulled: root.qobuzUnavailable + readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3 + // The row's SOURCE word — "local" | "offline" | "plex" | "jellyfin" | + // a Subsonic brand spelling (the Local Library badge set, + // src/local_rows.rs `badge_source`) or "qobuz" (the Library + // ALL feed, library_qt.rs `FeedItem.source`). "" = a catalog surface that + // publishes no source at all. + // + // Read by TWO consumers, which is why it is NOT the badge's on/off switch: + // the badge below, and the "Block this album" menu gate + // (AlbumCard.slint:434 keeps the entry off local/plex rows). Hosts that + // want the badge hidden set `showSourceBadge`, never `source: ""`. + property string source: "" + // Local Library logical albums can have one physical copy on several + // sources. Other hosts leave this empty and retain the scalar badge. + property var sources: [] + readonly property var badgeSources: root.sources && root.sources.length > 0 + ? root.sources : (root.source !== "" ? [root.source] : []) + /// Source words that mean "this album does not live in the Qobuz catalog". + /// Same fold as `SourceIcon.isSubsonic`: a row stamped with a Subsonic + /// BRAND is still a server row, so it must not be offered a catalog-only + /// action. + readonly property bool serverAlbum: root.source === "local" + || root.source === "plex" || root.source === "jellyfin" + || root.source === "subsonic" || root.source === "navidrome" + || root.source === "gonic" || root.source === "airsonic" + || root.source === "astiga" + // Hosts can also show catalog badges. Local Library provenance is always + // visible, even when a host's optional catalog-badge toggle is off. + property bool showSourceBadge: false + readonly property bool hasLocalSourceBadge: root.badgeSources.some(function (source) { + return source !== "" && source !== "qobuz" + }) + // Local play count — rendered as the muted "{} plays" line under the + // artist, and ONLY there (discover/AlbumCard.slint:508-513). Every + // surface but Most Played Albums publishes 0, which is why that page's + // card is 286px tall and every other one is 266px: this line IS the + // 20px difference. + property int plays: 0 + + // --- Local Library mode (additive; default = the Qobuz behaviour) ---- + // The Local Library mounts THIS card, but its `albumId` is a group key + // (folder or metadata identity), not a Qobuz catalog id — routing it + // through QbzAlbum.openAlbum / QbzPlayer.playAlbum would fire a + // catalog fetch for a folder path. In localMode every action is emitted + // to the host instead. Nothing else about the card changes, so the two + // surfaces stay pixel-identical. + // + // ROUTING ONLY, since the My QBZ round (2026-08-01). It used to ALSO hide + // the card affordances — see the independent gates below for why routing, + // catalog actions, Quick View and pinning are different questions. + property bool localMode: false + + // --- Catalog-only affordances: heart + "Block this album" -------------- + // + // Split out of `localMode` because a host can need one answer and not the + // other, and My QBZ is that host. Its grid routes EVERY action through + // `QbzMyQbz` (only it knows how to open a Plex album, a track or a + // playlist), so it needs `localMode: true` — but a Qobuz ALBUM cell's + // `albumId` IS a catalog album id, and its heart / block are the same + // entity every other AlbumCard in the app talks about. The blanket flag + // hid the heart on those cells, which is the owner's report ("al overlay le + // falta el botón de favorite"): a container that is multi-source PER ITEM + // cannot be described by one grid-wide boolean. + // + // Defaults to `!localMode`, so the two pre-existing hosts (Local Library + // grid, Local album view) keep byte-identical behaviour and no caller has + // to be updated. A host that wants the split sets it explicitly. + // + // The heart still goes through `toggleFavorite()` -> the shared + // `QbzLibrary.libraryToggleFavorite` seam and still settles on + // `libraryFavoriteChanged`, so a heart flipped here and one flipped on the + // album page agree. Do NOT let a host paint its own heart on top of the + // card — see the `selectMode` note below for the same mistake's other half. + property bool catalogAffordances: !root.localMode + // Full-album ownership comes from the account-wide entitlement index, not + // from whichever producer happened to instantiate this shared card. The + // revision argument turns the otherwise read-only invokable into a live + // QML binding after refresh/account switch. + readonly property int purchaseEntitlementRev: QbzPurchases.entitlementRev + readonly property bool purchasedAlbum: root.catalogAffordances + && root.albumId !== "" + && QbzPurchases.isAlbumPurchased(root.albumId, root.purchaseEntitlementRev) + readonly property string purchasedQuality: root.purchasedAlbum + ? QbzPurchases.albumPurchasedQuality(root.albumId, root.purchaseEntitlementRev) + : "" + /// Local-mode mounts whose `albumId` is a Local Library group key opt in + /// to the album-level "Add to playlist" row (LocalAlbumCollection sets + /// it). My QBZ's heterogeneous cells stay out: their local ids are + /// container items the local bulk seam cannot resolve. + property bool localPlaylistAffordance: false + // Quick View is broader than the catalog-only heart/block family. + // Catalog cards keep it by default; local and heterogeneous hosts opt in + // only for rows whose id is genuinely an album key. + property bool quickViewAffordance: root.catalogAffordances + // Pin storage accepts string album keys too. Kept independent from the + // catalog family so a local album can be pinned without accidentally + // gaining the Qobuz heart or blacklist actions. + property bool pinAffordance: root.catalogAffordances + // Catalog hosts persist the remote snapshot URL. Local hosts override this + // with their resolved file/server artwork so Pinned does not draw blank. + property string pinArtworkUrl: root.artworkUrl + // A Local Library album uses the separate LocalFavoritesService rather + // than the Qobuz catalog seam. This additive flag exposes the same heart + // geometry/menu row while handing the write back to that host. + property bool hostFavorite: false + readonly property bool favoriteAffordance: root.catalogAffordances || root.hostFavorite + signal favoriteRequested() + + // --- Multi-select mode — discover/AlbumCard.slint:83, :179-197, :207, + // :239, :465. NOT an invention of this port and NOT a host concern: the + // reference card owns the whole presentation of select mode, and it owns + // it because three things have to move together — + // * the hover action buttons are HIDDEN (:239) — in select mode the + // gesture is "pick this card", and a live play button there plays an + // album the user was trying to tick; + // * the pin badge is HIDDEN (:207), because the checkbox takes its + // corner; + // * the checkbox is drawn in THAT corner (top-right, :179-197) and the + // card click toggles instead of opening (:169, :465). + // A host that paints a tick on top of the card can only do the third. + // `views/local/LocalAlbumCollection.qml` used to do exactly that — a third + // tick geometry, top-left, with the card's play button still live + // underneath it — and it was moved onto these members on 2026-08-01. Both + // grid hosts (Local Library, My QBZ) are on the card's select mode now; + // there is no host-painted tick left in the tree, and a new one is a + // regression, not a shortcut. + property bool selectMode: false + /// Park the select indicator bottom-right of the art instead of top-right. + property bool selectMarkBottom: false + property bool selected: false + signal selectToggled() + + // Album blacklist ("Block this album") — LIVE since QbzBlacklist landed + // (`blockAlbum(id, title, artist, coverUrl)`). Kept as a property, not + // inlined, because a host whose `albumId` is not a Qobuz catalog id must + // be able to turn it off; `localMode` already covers the two shipped + // cases. One-way, exactly like the .slint: the card drops on the grid's + // next reload, there is no live removal. + property bool hasBlacklistSeam: true + + // --- Host-supplied extras (additive; every default is the old behaviour) + // + // 1. `placeholderIcon` — the glyph drawn in the empty artwork well when + // `artSource` is "". The Qobuz/Local hosts leave it "" and get the bare + // `surface-elevated` square they always had. My QBZ needs it because its + // grid is HETEROGENEOUS (album / track / playlist) and the reference + // card for that grid draws a type glyph there + // (MixtapeDetailView.slint:522-533, 28px, text-muted) — dropping it when + // that grid moved onto this card would have been a visual regression. + property string placeholderIcon: "" + // 2. `extraMenuEntries` / `extraMenuAction` — a host-owned TAIL on the ONE + // menu this card already owns (the ⋯ overlay button AND the right press + // open the same `albumMenu`). My QBZ has to offer "Remove from + // collection", which is a CONTAINER action no album card can know about, + // and the alternative in the tree — `views/local/LocalAlbumCollection` + // mounting a SECOND CardMenu for the right press — leaves the ⋯ button + // showing a different menu than the right click. Entries use CardMenu's + // shape ({ label, icon, action, danger } / { sep: true }); an action that + // matches one of them is emitted here instead of being interpreted. + property var extraMenuEntries: [] + signal extraMenuAction(string action) + // 3. `openLabel` — the NOUN on the first menu entry. "" (default) keeps the + // card's own `tr("Open album")`, which is what every catalog surface + // wants. My QBZ's grid is heterogeneous: a PLAYLIST cell routes to the + // playlist page (`myqbz_detail_qt::open_item`), so "Open album" on it was + // simply the wrong word — the action was never wrong, only its label. + // A TRACK cell keeps "Open album" on purpose: `open_item` sends + // "album" | "track" to the same album page (1:1 with the reference, + // `qbz/src/main.rs:6275-6277`), so the noun is accurate there. + // It is a LABEL, not a second action: the entry's `action` stays "open" + // and still reaches `openRequested()` / `QbzAlbum.openAlbum`. + property string openLabel: "" + + signal openRequested() + signal playRequested() + signal enqueueRequested(string mode) + + QbzTheme { id: theme } + + /// Build the context popup on first use, then open it. + function openAlbumMenu(anchor, x, y) { + if (root.menuModel().length === 0) + return + albumMenuLoader.active = true + albumMenuLoader.item.openAtCursor(anchor, x, y) + } + /// A GridView recycling host must close window-overlay children before + /// this card is rebound to another album. + function releaseForReuse() { + if (albumMenuLoader.item) + albumMenuLoader.item.close() + albumMenuLoader.active = false + } + + width: 200 + // 246 normally; +20 for the "{} plays" line (see `plays`) — the same + // 266 -> 286 step MostPlayedAlbumsView.slint:23 takes for its grid. + height: 246 + (root.plays > 0 ? 20 : 0) + color: "transparent" + + readonly property bool overlayOn: artArea.containsMouse + || quickViewArea.containsMouse || pinArea.containsMouse + || purchaseArea.containsMouse + || favBtn.hovered || playBtn.hovered || moreBtn.hovered + + function toggleFavorite() { + root.isFavorite = !root.isFavorite + if (root.hostFavorite) + root.favoriteRequested() + else + QbzLibrary.libraryToggleFavorite("album", root.albumId) + } + function togglePin() { + root.isPinned = !root.isPinned + QbzLibrary.togglePin("album", root.albumId, root.title, root.artist, + root.pinArtworkUrl) + } + + // Pin fan-out. The pinned store has no change-notify, so `pinChanged` + // (key `{kind}:{id}`, emitted by main.rs::toggle_pin after the write) is + // the ONLY signal that this album's state moved — and it moves from + // anywhere: another rail, another tab, the album page header. This is the + // port's equivalent of the Slint `set_album_row_pinned` walk over every + // live model, and the reason no surface has to republish its document to + // keep a glyph honest. Assigning breaks the host's `isPinned` binding on + // purpose (the optimistic flip above already does). + Connections { + target: QbzLibrary + function onPinChanged(key, value) { + if (root.albumId !== "" && key === "album:" + root.albumId) + root.isPinned = value + } + // Favourite fan-out — the SAME shape, one signal later. `isFavorite` + // was the odd one out: the optimistic flip above breaks the host's + // binding (by design), and nothing settled it afterwards, so a write + // that FAILED stayed visibly wrong until the user navigated away, and + // a heart flipped on another surface (the album page header, a search + // result, the queue) never reached this card. `libraryFavoriteChanged` + // carries the value the write actually produced — the flipped one on + // success, the UNCHANGED one on failure — so this is both the + // cross-surface walk and the rollback. + function onLibraryFavoriteChanged(key, value) { + if (!root.hostFavorite && root.albumId !== "" + && key === "album:" + root.albumId) + root.isFavorite = value + } + } + Connections { + target: root.hostFavorite ? QbzLocal : null + function onLocalAlbumFavoriteChanged(id, value) { + if (root.hostFavorite && root.albumId !== "" && id === root.albumId) + root.isFavorite = value + } + } + + // AlbumCard.slint's album-menu, in its order. `catalogAffordances: false` + // drops the catalog-only rows (heart + block); `localMode` routes the five + // navigation/playback rows to the host's signals instead. The two are + // independent — see the property notes. + function menuModel() { + var t = QbzSession.tr + var r = QbzSession.trRev + var m = [] + if (!root.pulledDead) { + m.push({ "label": root.openLabel !== "" ? root.openLabel : t("Open album", r), + "icon": "library-big", "action": "open" }) + m.push({ "label": t("Play", r), "icon": "play-fill", "action": "play" }) + m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" }) + // #442 "Play later" — end of the manual block. + m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" }) + m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" }) + // Local albums get the row too (owner, 2026-08-31: playlists can + // be 100% local or mixed) — via the existing local bulk seam. + if (root.localMode && root.localPlaylistAffordance) + m.push({ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-playlist" }) + } + if (root.favoriteAffordance) { + // Keep removal reachable even after Qobuz withdraws the old id. + m.push({ "label": root.isFavorite ? t("Remove from Library", r) : t("Add to Library", r), + "icon": root.isFavorite ? "heart-filled" : "heart", "action": "favorite" }) + } + if (root.replacementAffordance && root.pulled) { + m.push({ "label": t("Look for better replacement", r), + "icon": "search", "action": "find-release" }) + } + if (root.catalogAffordances && !root.pulled) { + // QoL round: the album page's container + offline actions, on the + // card. `catalogAffordances` is the catalog-id guarantee (the + // add/cache invokables both run get_album), and a PULLED album is + // refused both — the container stores an id that resolves nowhere + // and the download arm has no stream url left (the same two + // refusals ArtistView's PopularTrackRow menu documents). + // Album-level "Add to playlist" (owner, 2026-08-31): the card + // only knows the album id — the Rust side fetches the track list + // and seeds the picker (playlist_picker_qt::open_for_album). + // Catalog-gated like mixtape: the picker carries catalog ids. + m.push({ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-playlist" }) + m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" }) + m.push({ "label": root.cacheStatus === 3 ? t("Refresh offline copy", r) : t("Make available offline", r), + "icon": root.cacheStatus === 3 ? "refresh-cw" : "cloud-download", "action": "cache-album" }) + } + if (root.catalogAffordances) { + // .slint gates this on a non-local/plex source as well — and the + // rule is "not a Qobuz catalog album", so every server source is + // in the same class as Plex. Blocking a Jellyfin album would write + // a blacklist entry against an id the Qobuz catalog never had. + if (root.hasBlacklistSeam && !root.serverAlbum) + m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" }) + } + // Host tail (see `extraMenuEntries`). `concat` so the host's array is + // never mutated — it is usually a binding's return value. + var extra = root.extraMenuEntries || [] + return extra.length > 0 ? m.concat(extra) : m + } + + function menuAction(a) { + // A host entry is HANDED BACK, never interpreted: the card has no idea + // what "remove from this collection" means. + var extra = root.extraMenuEntries || [] + for (var i = 0; i < extra.length; i++) { + if (extra[i].sep !== true && extra[i].action === a) { + root.extraMenuAction(a) + return + } + } + // CATALOG-only rows first, because they are orthogonal to routing: they + // are in the menu at all only when `catalogAffordances` is on, which + // means `albumId` IS a catalog album id — so they target the catalog + // whatever `localMode` says about open/play. Handling them after the + // `localMode` early-return (the old shape) made them unreachable for + // My QBZ, i.e. a menu row that rendered and did nothing. + // `artworkUrl` (the REMOTE url), never `artSource` — the store keeps a + // denormalized snapshot and a file:// cache path is dead on any other + // machine, the same reason the pin payload uses it. + if (a === "favorite") { root.toggleFavorite(); return } + if (a === "find-release") { + QbzTrackReplace.openRelease(JSON.stringify({ + "targetKind": "album", + "albumId": root.albumId || "", + "albumTitle": root.title || "", + "artist": root.artist || "", + "albumArtist": root.artist || "" + })) + return + } + if (a === "add-playlist") { + if (root.localMode) + // The bulk "album" scope resolves a local group key to its + // rows and opens the picker in LOCAL MODE (local_bulk.rs). + QbzLocal.bulkAction("album", + JSON.stringify([String(root.albumId)]), "add-to-playlist") + else + QbzPlaylistPicker.openForAlbum(root.albumId) + return + } + if (a === "mixtape") { QbzAlbum.addToMixtape(root.albumId); return } + if (a === "cache-album") { QbzAlbum.albumCacheOffline(root.albumId); return } + if (a === "block") { + QbzBlacklist.blockAlbum(root.albumId, root.title, root.artist, + root.artworkUrl) + return + } + if (root.pulledDead) + return + if (root.localMode) { + if (a === "open") root.openRequested() + else if (a === "play") root.playRequested() + else if (a === "next") root.enqueueRequested("next") + else if (a === "later") root.enqueueRequested("later") + else if (a === "queue") root.enqueueRequested("queue") + return + } + if (a === "open") QbzAlbum.openAlbum(root.albumId) + else if (a === "play") QbzPlayer.playAlbum(root.albumId) + else if (a === "next") QbzPlayer.enqueueAlbum(root.albumId, "next") + else if (a === "later") QbzPlayer.enqueueAlbum(root.albumId, "later") + else if (a === "queue") QbzPlayer.enqueueAlbum(root.albumId, "queue") + } + + Column { + spacing: 0 + + // --- Artwork + hover overlay ----------------------------------- + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + // No clip: every child is geometrically contained and the overlay + // Texts above are now bounded. One batch root per grid card. + + RoundedImage { + anchors.fill: parent + source: root.artSource + radius: theme.radiusSm + } + + // Empty-well glyph — opt-in, see `placeholderIcon`. Declared + // before every hit area so it never takes the pointer (it is an + // Image with no MouseArea, but z-order is declaration order and + // the scrim must still paint over it). + QbzIcon { + visible: root.placeholderIcon !== "" && root.artSource === "" + anchors.centerIn: parent + name: root.placeholderIcon + width: 28 + height: 28 + tintName: "muted" + } + + // Hover scrim. + Rectangle { + anchors.fill: parent + radius: theme.radiusSm + color: "#000000" + opacity: root.overlayOn && !root.pulledDead ? 0.6 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + + // Hover meta — genre + year, top-left. + Column { + x: 12 + y: 12 + spacing: 2 + opacity: root.overlayOn ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + Text { + visible: root.genre !== "" + text: root.genre + // Bounded because the tile's clip is gone. When the joined + // top-right group is present, stop 8px before it instead + // of letting a long genre run under both buttons. + width: (root.quickViewAffordance || root.purchasedAlbum) && !root.selectMode + && !root.pulledDead ? 118 : 176 + elide: Text.ElideRight + height: 20 + color: "#ebffffff" + font.pixelSize: 13 + font.weight: theme.weightBold + verticalAlignment: Text.AlignVCenter + } + Text { + visible: root.year !== "" + text: root.year + width: (root.quickViewAffordance || root.purchasedAlbum) && !root.selectMode + && !root.pulledDead ? 118 : 176 + elide: Text.ElideRight + height: 17 + color: "#ccffffff" + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + } + } + + // Card-open + hover detector (declared before the action + // buttons so those win the pointer). + MouseArea { + id: artArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor + // Right press opens the SAME menu as the ⋯ button. + acceptedButtons: Qt.LeftButton | Qt.RightButton + // Phase 8: the card opens the album view (the overlay play + // button carries the play affordance). + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) { + root.openAlbumMenu(artArea, mouse.x, mouse.y) + return + } + // .slint:169 — in select mode the card click TOGGLES. + if (root.selectMode) { + root.selectToggled() + return + } + if (root.pulledDead) + return + if (root.localMode) + root.openRequested() + else + QbzAlbum.openAlbum(root.albumId) + } + } + + // Multi-select checkbox — top-right of the art, select-mode only + // (.slint:179-197, number for number): 24px disc at + // `width - 24 - 8, 8`, accent when selected else `#00000099` + // (Slint #RRGGBBAA -> Qt #AARRGGBB = #99000000), 1.5px border, + // accent when selected else `#ffffffcc` -> `#ccffffff`, 120ms + // colour animation, and a 14px white check that only shows when + // selected. It is the INDICATOR: the card click above is what + // toggles, so it carries no MouseArea of its own — exactly like + // the .slint, which gives it no TouchArea either. + Rectangle { + visible: root.selectMode + x: parent.width - width - 8 + // Top-right by default (the Library bulk mode); the artist + // page's album-section "Play selected" parks it bottom-right + // inside the art (`selectMarkBottom`). + y: root.selectMarkBottom ? parent.height - height - 8 : 8 + width: 24 + height: 24 + radius: 12 + color: root.selected ? theme.accent : "#99000000" + border.width: 1.5 + border.color: root.selected ? theme.accent : "#ccffffff" + Behavior on color { ColorAnimation { duration: 120 } } + QbzIcon { + visible: root.selected + name: "check" + width: 14 + height: 14 + anchors.centerIn: parent + // .slint:196 hardcodes #ffffff here, and unlike + // SelectCheck's 8px glyph this one sits on a 24px disc — + // the contrast argument that made SelectCheck diverge does + // not reach this size, so the reference stands. + tintName: "white" + } + } + + // Quick View + optional pin + purchased ticket — one joined group in the old pin + // badge's top-right slot. Local album grids opt into BOTH without + // opting into unrelated catalog heart/block actions; a host that + // cannot pin may still expose Quick View alone. Always mounted + // (opacity), so either half's hover joins overlayOn and reveals it. + Rectangle { + id: cardTopGroup + readonly property bool expanded: root.overlayOn + readonly property bool actionsAvailable: !root.pulledDead + readonly property int expandedSegments: + (root.quickViewAffordance && cardTopGroup.actionsAvailable ? 1 : 0) + + (root.pinAffordance && cardTopGroup.actionsAvailable ? 1 : 0) + + (root.purchasedAlbum ? 1 : 0) + // Hidden in select mode too: the checkbox owns this corner. + // The Quick View controller uses the id router to load + // local/server keys from their physical source instead of the + // Qobuz endpoint. + visible: !root.selectMode + && (root.purchasedAlbum + || (cardTopGroup.actionsAvailable + && (root.quickViewAffordance || root.pinAffordance))) + x: parent.width - width - 8 + y: 8 + width: root.purchasedAlbum && !cardTopGroup.expanded + ? 27 : cardTopGroup.expandedSegments * 27 + height: 26 + radius: 13 + color: (quickViewArea.containsMouse || pinArea.containsMouse + || purchaseArea.containsMouse) + ? "#cc000000" : "#99000000" + opacity: root.purchasedAlbum || root.overlayOn ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + + QbzIcon { + visible: cardTopGroup.expanded && cardTopGroup.actionsAvailable + && root.quickViewAffordance + name: "picture-in-picture-2" + width: 14 + height: 14 + x: Math.round((27 - width) / 2) + anchors.verticalCenter: parent.verticalCenter + tintName: quickViewArea.containsMouse ? "accent" : "white" + } + Rectangle { + visible: cardTopGroup.expanded && cardTopGroup.actionsAvailable + && root.pinAffordance + && root.quickViewAffordance + x: 27 + y: 5 + width: 1 + height: 16 + color: "#45ffffff" + } + QbzIcon { + visible: cardTopGroup.expanded && cardTopGroup.actionsAvailable + && root.pinAffordance + name: root.isPinned ? "pin-filled" : "pin" + width: 14 + height: 14 + x: (root.quickViewAffordance && cardTopGroup.actionsAvailable ? 27 : 0) + + Math.round((27 - width) / 2) + anchors.verticalCenter: parent.verticalCenter + // On a #99000000/#cc000000 scrim — dark under every theme. + tintName: root.isPinned ? "accent" : "white" + } + MouseArea { + id: quickViewArea + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 27 + visible: cardTopGroup.expanded && cardTopGroup.actionsAvailable + && root.quickViewAffordance + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzAlbum.openQuickView(root.albumId) + ToolTip.visible: containsMouse + ToolTip.text: QbzSession.tr("Quick view", QbzSession.trRev) + ToolTip.delay: 350 + } + MouseArea { + id: pinArea + visible: cardTopGroup.expanded && cardTopGroup.actionsAvailable + && root.pinAffordance + x: root.quickViewAffordance && cardTopGroup.actionsAvailable ? 27 : 0 + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 27 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.togglePin() + ToolTip.visible: containsMouse + ToolTip.text: QbzSession.tr(root.isPinned ? "Unpin" : "Pin", + QbzSession.trRev) + ToolTip.delay: 350 + } + Rectangle { + visible: cardTopGroup.expanded && root.purchasedAlbum + && cardTopGroup.actionsAvailable + && (root.quickViewAffordance || root.pinAffordance) + x: (root.quickViewAffordance && cardTopGroup.actionsAvailable ? 27 : 0) + + (root.pinAffordance && cardTopGroup.actionsAvailable ? 27 : 0) + y: 5 + width: 1 + height: 16 + color: "#45ffffff" + } + QbzIcon { + visible: root.purchasedAlbum + name: "ticket-check" + width: 14 + height: 14 + x: cardTopGroup.expanded + ? (root.quickViewAffordance && cardTopGroup.actionsAvailable ? 27 : 0) + + (root.pinAffordance && cardTopGroup.actionsAvailable ? 27 : 0) + + Math.round((27 - width) / 2) + : Math.round((27 - width) / 2) + anchors.verticalCenter: parent.verticalCenter + tintName: "warning" + } + MouseArea { + id: purchaseArea + visible: root.purchasedAlbum + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 27 + hoverEnabled: true + cursorShape: Qt.ArrowCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function (mouse) { mouse.accepted = true } + ToolTip.visible: containsMouse + ToolTip.text: QbzSession.tr("Purchased", QbzSession.trRev) + + (root.purchasedQuality !== "" + ? " · " + root.purchasedQuality : "") + ToolTip.delay: 350 + } + } + + // Hover action buttons — favorite / play / more (y=120, h=44, + // centered, spacing 12). + CardOverlayRow { + // .slint:239 `visible: !root.select-mode` — in select mode the + // click toggles selection, so a live play/⋯ row here would act + // on a card the user is only trying to tick. + visible: !root.selectMode + y: 120 + width: parent.width + shown: root.overlayOn && !root.pulledDead + + CardOverlayButton { + id: favBtn + // ABSENT, not present-and-dead, when `albumId` is not a + // catalog album id — the same gate as the menu's heart row. + visible: root.favoriteAffordance + name: root.isFavorite ? "heart-filled" : "heart" + active: root.isFavorite + anchors.verticalCenter: parent.verticalCenter + onClicked: root.toggleFavorite() + } + CardOverlayButton { + id: playBtn + name: "play-fill" + primary: true + anchors.verticalCenter: parent.verticalCenter + onClicked: root.localMode ? root.playRequested() + : QbzPlayer.playAlbum(root.albumId) + } + CardOverlayButton { + id: moreBtn + name: "ellipsis" + anchors.verticalCenter: parent.verticalCenter + // CardOverlayButton.clicked() carries no mouse payload, + // so fall back to the disc's centre — the menu still + // opens under the ⋯ (worst case 18px off the pointer). + // Stays correct if the signal ever forwards the event. + onClicked: function (mouse) { + root.openAlbumMenu(moreBtn, + mouse ? mouse.x : moreBtn.width / 2, + mouse ? mouse.y : moreBtn.height / 2) + } + } + } + + // Context menu (AlbumCard.slint's album-menu) — the shared + // CardMenu surface, not a second copy of its delegate. + // LAZY. A CardMenu is a QtQuick.Controls Popup with a Repeater + // over its entries, and this card is the unit the catalog surfaces + // are built from: Discover Home mounts ~423 of them, and the Local + // Albums grid recycles a screenful of them on every scroll step. + // Constructing a popup per card — for a menu that only ever opens + // on a click — is paid on every one of those. + Loader { + id: albumMenuLoader + active: false + sourceComponent: CardMenu { + menuWidth: 196 + entries: root.menuModel() + onPicked: function (a) { root.menuAction(a) } + } + } + + // Award ribbon — content-width, capped at the card width. + Rectangle { + visible: root.ribbon !== "" + x: 0 + y: parent.height - height - 8 + height: 20 + width: Math.min(ribbonRow.width, 200) + color: root.ribbonKind === "press" ? "#d49511" : "#e0000000" + topRightRadius: 3 + bottomRightRadius: 3 + clip: true + Rectangle { + width: root.ribbonKind === "press" ? 0 : 3 + height: parent.height + color: root.ribbonKind === "qobuzissime" ? "#8b5cf6" : "#eab308" + } + Row { + id: ribbonRow + height: parent.height + leftPadding: 10 + rightPadding: 10 + width: ribbonText.implicitWidth + 20 + Text { + id: ribbonText + height: parent.height + text: root.ribbon + color: root.ribbonKind === "press" ? "#1f1407" : "#ffffff" + font.pixelSize: 9 + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + + // Always-visible source badge — bottom-right of the art, ON TOP of + // the scrim and the hover buttons (declaration order = z-order). + // 24x24 rounded SQUARE (ADR-008: not a pill), 6px inset. + // discover/AlbumCard.slint:326-354, number for number. + // + // The glyph goes through controls/SourceIcon.qml, never QbzIcon: + // the Qobuz and Plex marks are MULTI-COLOUR and a tint flattens + // them to a silhouette. This card used to draw an accent-tinted + // `hard-drive` for Plex — a blue hard drive where the design calls + // for the Plex mark. + Rectangle { + visible: (root.showSourceBadge || root.hasLocalSourceBadge) + && root.badgeSources.length > 0 + x: parent.width - width - 6 + y: parent.height - height - 6 + width: sourceBadgeRow.implicitWidth + height: 24 + color: "transparent" + Row { + id: sourceBadgeRow + height: 24 + spacing: 3 + Repeater { + model: root.badgeSources + delegate: Rectangle { + id: sourceBadge + required property string modelData + width: 24 + height: 24 + radius: 4 + color: modelData === "qobuz_purchase" ? "#d91e1400" + : (modelData === "offline" ? "transparent" : "#b3000000") + border.width: modelData === "qobuz_purchase" ? 1 : 0 + border.color: "#80eab308" + SourceIcon { + kind: sourceBadge.modelData + glyphSize: 14 + plexSize: 16 + qobuzSize: sourceBadge.modelData === "offline" ? 22 : 18 + localTint: "white" + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + } + } + } + } + } + + // A catalog-withdrawn album is explicit content, not a failed + // image load. Keep it above every hover affordance and label it. + Rectangle { + visible: root.pulledDead + anchors.fill: parent + radius: theme.radiusSm + color: theme.alphaTier(60) + Text { + anchors.centerIn: parent + width: parent.width - 20 + text: QbzSession.tr("Unavailable", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } + Item { width: 1; height: 6 } + + // --- Title / artist + quality badge ------------------------------ + Row { + width: 200 + height: 40 + (root.plays > 0 ? 20 : 0) + spacing: theme.spacingSm + opacity: root.pulledDead ? 0.5 : 1.0 + Column { + width: parent.width - (qBadge.visible ? qBadge.width + theme.spacingSm : 0) + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + height: 20 + text: root.title + color: titleArea.containsMouse && !root.pulledDead + ? theme.accent : theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + MouseArea { + id: titleArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) { + root.openAlbumMenu(titleArea, mouse.x, mouse.y) + return + } + // .slint:465 — the title carries the SAME target + // as the artwork, select mode included. + if (root.selectMode) { + root.selectToggled() + return + } + if (root.pulledDead) + return + if (root.localMode) + root.openRequested() + else + QbzAlbum.openAlbum(root.albumId) + } + } + } + Text { + width: parent.width + height: 18 + text: root.artist + color: (root.artistId !== "" || root.hostArtistLink) + && artistArea.containsMouse + ? theme.textPrimary : theme.textMuted + font.pixelSize: theme.fontLink - 1 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + MouseArea { + id: artistArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.artistId !== "" || root.hostArtistLink + ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (root.artistId !== "") + QbzArtist.openArtist(root.artistId) + else if (root.hostArtistLink) + root.artistRequested() + } + } + } + // Play count — Most Played Albums only (see `plays`). + Text { + visible: root.plays > 0 + width: parent.width + height: visible ? 18 : 0 + text: QbzSession.tr("{} plays", QbzSession.trRev).replace("{}", root.plays) + color: theme.textMuted + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + // Icon-only quality badge (QualityBadge.slint). `label` feeds the + // badge's own hover tooltip with the exact stored quality; the + // badge shows nothing when the host has no detail to give. + QualityMini { + id: qBadge + tier: root.qualityTier + label: root.qualityDetail + anchors.verticalCenter: parent.verticalCenter + } + } + } +} diff --git a/crates/qbz-qt/qml/cards/ArtistCard.qml b/crates/qbz-qt/qml/cards/ArtistCard.qml new file mode 100644 index 000000000..24bb34cb4 --- /dev/null +++ b/crates/qbz-qt/qml/cards/ArtistCard.qml @@ -0,0 +1,380 @@ +// ArtistCard — THE artist card (discover/ArtistGridCard.slint), shared by +// every surface since phase 21: Home/Editor/ForYou artist rails, the +// Pinned rail, the Library artist grids + All feed, the Search all-tab +// carousels + artists grid + most-popular artist hero. (The Slint mounts +// ArtistGridCard at 200x246 in ALL of these — the POC's old 160x220 +// Home/Search cards replicated the legacy ArtistCard the Slint only uses +// on surfaces the POC does not have.) +// +// 200x246: 200px surface-card frame + 190px circle (gradient + user glyph +// until the artwork resolves), hover scrim 0.55 CLIPPED TO THE CIRCLE, +// overlay row at y=113 (follow? / play / more), pin badge top-right of +// the frame, then the meta block (centered name, optional subtitle). +// +// item contract: { id, title, subtitle, following | isFavorite } plus the +// scalar artSource / artworkUrl / isPinned props (the AlbumCard pattern). +// The follow flag arrives under TWO names because the producers spell it +// differently and both mean the same row state: `following` (search_qt +// ArtistRow, derived from fav_cache) and `isFavorite` (home_qt HomeCard / +// library_qt FeedItem — for an artist, "in the library" IS "followed", they +// are the same `favorite/create?artist_ids=` write). +// +// Arms: +// - followMode: "toggle" (default, = ArtistGridCard.slint's own default) | +// "none" — the overlay follow button + the menu Follow row. The Slint +// reference passes "none" from exactly two places: the label page's +// artist carousel (LabelPageView.slint:524) and the Library artist grids +// (FavoritesView.slint:1831/1865). Everything else shows it. +// - followKind: "artist" (default) | "label" — ArtistGridCard.slint's +// `follow-kind`, the kind the follow write is sent as. +// - subtitle: meta switches to 1-line name + muted subtitle (the Slint +// "Similar to…"/search arm); empty = wrap-2 name. +// +// --- Menu inventory vs discover/ArtistGridCard.slint ------------------- +// Open artist · Play · Follow/Following (show-follow) · Not interested +// All four are live; "Not interested" rides `hasNotInterestedSeam` (default +// on) and writes the reco-dismissal store through QbzBlacklist. +// ⋯ and a right press on the portrait or the name open the same menu. + +import QtQuick +import "../kiosk" +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Rectangle { + id: root + property bool kioskHost: false + + property var item: ({}) + // Host-resolved artwork path (the AlbumCard artSource pattern). + property string artSource: "" + // Remote cover URL for the pin payload ("" when the host has none). + property string artworkUrl: "" + // Pinned state (AlbumCard pattern: scalar prop, optimistic flip on + // click — the model re-publish re-creates the delegate). + property bool isPinned: false + property string followMode: "toggle" + property string followKind: "artist" + + // --- "Not interested" (the reco-scoped dismissal) -------------------- + // `dismissArtist(id, name, imageUrl)` writes the reco-dismissal store (NOT + // the artist blacklist). Kept as a property, not inlined, so a host whose + // `item.id` is not a Qobuz artist id can turn it off. + // + // The card now LEAVES THE RAIL ON THE CLICK: `blacklist_qt::dismiss_artist` + // calls `recommendations_qt::apply_dismissal`, which drops it from the + // cached rails and republishes. What this comment used to promise — "the + // card leaves the rails on the next publish" — never happened, because + // nothing read the dismissal store on publish either; the artist came + // straight back. The ids are part of the reco exclusion set now. + // + // OWNER RULING (2026-08-16): the live drop is for the RECOMMENDATIONS + // rails and nothing else. "This is not a blacklist or a general hide, + // only for recommendations." This card is also mounted by Search, Label, + // Artist Scene, Home and the Library grid; dismissing from one of those + // persists and takes effect in Recommendations, but the card you clicked + // stays where it is — deliberately. Do NOT "finish" this by making the + // artist vanish from Search or Library; hiding an artist app-wide is the + // BLACKLIST, a different store and a different row. + // + // `hasFollowSeam` is GONE, and the header comment it carried ("There is + // NO artist-follow invokable on the Qt bridge") was simply wrong: + // `library_bridge.rs` declares `library_toggle_favorite(kind, id)`, + // `library_qt::toggle_favorite` routes "artist" (and "label") straight to + // `add_favorite` / `remove_favorite`, and ArtistView's own header button + // has been calling it all along. The constant made the follow affordance + // disappear from EVERY artist card in the app. + property bool hasNotInterestedSeam: true + + color: "transparent" + + QbzTheme { id: theme } + + // ArtistGridCard.slint:89 — `show-follow: follow-mode != "none"`. + readonly property bool showFollow: followMode !== "none" + readonly property bool overlayOn: agArea.containsMouse || pinArea.containsMouse + || agFollow.hovered || agPlay.hovered || agMore.hovered + + implicitWidth: 200 + implicitHeight: 246 + + // --- Follow state (a real QML property, never `item.following`) ------ + // Same reason as rows/TrackRow.qml's `favorite`: `item` is a plain JS + // object, so mutating a field on it fires no notifier and re-evaluates no + // binding — and `item: modelData` is a COPY, so the write does not reach + // the model either (both measured under qml6 6.11.1). The seed reads both + // spellings the producers publish (see the header note). + property bool following: root.item.following === true + || root.item.isFavorite === true + onItemChanged: root.following = Qt.binding(function () { + return root.item.following === true || root.item.isFavorite === true + }) + + function toggleFollow() { + root.following = !root.following + QbzLibrary.libraryToggleFavorite(root.followKind, root.item.id) + } + + function openMenu(anchor, x, y) { + agMenuLoader.active = true + agMenuLoader.item.openAtCursor(anchor, x, y) + } + function releaseForReuse() { + if (agMenuLoader.item) + agMenuLoader.item.close() + agMenuLoader.active = false + } + + Connections { + target: QbzLibrary + // Pin fan-out — the AlbumCard contract, artist key (see AlbumCard.qml: + // the store has no change-notify, so this signal is what keeps every + // OTHER card showing this artist honest without a republish). + function onPinChanged(key, value) { + var aid = (root.item && root.item.id !== undefined) ? root.item.id : "" + if (aid !== "" && key === "artist:" + aid) + root.isPinned = value + } + // Follow fan-out + rollback, the favourite twin of the above. The key + // is `{followKind}:{id}` so a label-follow card settles on its own + // key rather than on an artist one that happens to share the number. + function onLibraryFavoriteChanged(key, value) { + var aid = (root.item && root.item.id !== undefined) ? root.item.id : "" + if (aid !== "" && key === root.followKind + ":" + aid) + root.following = value + } + } + + Column { + spacing: 0 + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceCard + + // 190px round portrait, centered (5px surround = the + // album-tile frame). All overlay content lives inside so it + // clips to the circle. + Rectangle { + x: 5 + y: 5 + width: 190 + height: 190 + radius: 95 + // No clip. It never made the circle — QML's clip is a + // rectangular scissor and ignores `radius` + // (qquickclipnode.cpp:13-22); the round shape comes from each + // child's own `radius` / RoundedImage mask. Geometrically + // nothing leaves the 190x190 box. + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: theme.surfaceElevated } + GradientStop { position: 1.0; color: theme.surfaceCard } + } + // Placeholder glyph until the artwork resolves on top. + QbzIcon { + name: "user" + width: 54 + height: 54 + anchors.centerIn: parent + tintName: "muted" + } + Loader { + anchors.fill: parent + sourceComponent: root.kioskHost ? kioskCover : desktopCover + Component { + id: kioskCover + KioskArtwork { anchors.fill: parent; source: root.artSource; radius: 95 } + } + Component { + id: desktopCover + RoundedImage { anchors.fill: parent; source: root.artSource; radius: 95 } + } + } + // Hover scrim (clipped to the circle by the parent's clip). + Rectangle { + anchors.fill: parent + radius: 95 + color: "#000000" + opacity: root.overlayOn ? 0.55 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + // Card-open + hover detector (before the buttons so they + // win the pointer). + MouseArea { + id: agArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) + root.openMenu(agArea, mouse.x, mouse.y) + else + QbzArtist.openArtist(root.item.id) + } + } + // Hover overlay — follow? / play / more (y=113). + CardOverlayRow { + height: root.kioskHost ? 64 : 44 + y: root.kioskHost ? 104 : 113 + width: parent.width + shown: root.kioskHost || root.overlayOn + CardOverlayButton { + width: root.kioskHost ? (primary ? 64 : 44) : (primary ? 44 : 36) + height: width + id: agFollow + visible: root.showFollow + name: root.following ? "check" : "user-plus" + active: root.following + anchors.verticalCenter: parent.verticalCenter + onClicked: root.toggleFollow() + } + CardOverlayButton { + width: root.kioskHost ? (primary ? 64 : 44) : (primary ? 44 : 36) + height: width + id: agPlay + name: "play-fill" + primary: true + anchors.verticalCenter: parent.verticalCenter + onClicked: QbzPlayer.playArtistCard(root.item.id) + } + CardOverlayButton { + width: root.kioskHost ? (primary ? 64 : 44) : (primary ? 44 : 36) + height: width + id: agMore + name: "ellipsis" + anchors.verticalCenter: parent.verticalCenter + onClicked: function (mouse) { root.openMenu(agMore, mouse.x, mouse.y) } + } + } + Loader { + id: agMenuLoader + active: false + sourceComponent: CardMenu { + kioskHost: root.kioskHost + menuWidth: 196 + entries: root.menuModel() + onPicked: function (a) { root.menuAction(a) } + } + } + } + // Pin badge — top-right of the FRAME (outside the circle clip), + // opacity follows overlay-on (the AlbumCard convention). + Rectangle { + x: parent.width - width - 8 + y: 8 + width: root.kioskHost ? 44 : 26 + height: root.kioskHost ? 44 : 26 + radius: 13 + color: pinArea.containsMouse ? "#cc000000" : "#99000000" + opacity: root.kioskHost || root.overlayOn ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + QbzIcon { + name: root.isPinned ? "pin-filled" : "pin" + width: 14 + height: 14 + anchors.centerIn: parent + // On a #99000000/#cc000000 scrim — dark under every theme. + tintName: root.isPinned ? "accent" : "white" + } + MouseArea { + id: pinArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.isPinned = !root.isPinned + QbzLibrary.togglePin("artist", root.item.id, root.item.title, + root.item.subtitle || "", root.artworkUrl) + } + } + } + } + Item { width: 1; height: 6 } + // Meta: centered name; subtitle arm switches to 1-line + muted row. + Column { + width: 200 + height: 40 + spacing: 2 + Text { + width: parent.width + height: root.item.subtitle ? 20 : 40 + text: root.item.title || "" + color: agNameArea.containsMouse ? theme.accent : theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: root.item.subtitle ? Text.NoWrap : Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + MouseArea { + id: agNameArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) + root.openMenu(agNameArea, mouse.x, mouse.y) + else + QbzArtist.openArtist(root.item.id) + } + } + } + Text { + visible: !!root.item.subtitle + width: parent.width + height: 16 + text: root.item.subtitle || "" + color: theme.textMuted + font.pixelSize: theme.fontLink - 1 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + + // ArtistGridCard.slint's artist-menu, in its order. + function menuModel() { + var t = QbzSession.tr + var r = QbzSession.trRev + var m = [ + { "label": t("Open artist", r), "icon": "user", "action": "open" }, + { "label": t("Play", r), "icon": "play-fill", "action": "play" }, + ] + if (root.showFollow) + m.push({ "label": root.following ? t("Following", r) : t("Follow", r), + "icon": root.following ? "check" : "user-plus", "action": "follow" }) + // QoL round: the Discography Builder, reachable from the card — the + // same seam ArtistView's ⋯ overflow uses (`QbzDisco.open` takes the + // artist id, no open view required). + m.push({ "label": t("Create Artist Collection", r), "icon": "library-big", "action": "disco" }) + // Reco-scoped dismissal (NOT the blacklist) — the artist leaves the + // Recommendations rails only. + if (root.hasNotInterestedSeam) + m.push({ "label": t("Not interested", r), "icon": "thumbs-down", "action": "not-interested" }) + return m + } + + function menuAction(a) { + if (a === "open") QbzArtist.openArtist(root.item.id) + else if (a === "play") QbzPlayer.playArtistCard(root.item.id) + else if (a === "follow") root.toggleFollow() + else if (a === "disco") QbzDisco.open(root.item.id) + // THREE args: the store persists (id, name, image_url) and only + // backfills a field that is EMPTY, so a blank first write can never be + // repaired later. `artworkUrl` first because that is the host- + // normalized REMOTE url (some producers spell the key `imageUrl`, e.g. + // LibraryView) — `item.artUrl` is the HomeCard fallback, and never + // `artSource`, which is a local file:// cache path. + else if (a === "not-interested") + QbzBlacklist.dismissArtist(root.item.id, + root.item.name || root.item.title || "", + root.artworkUrl !== "" ? root.artworkUrl : (root.item.artUrl || "")) + } +} diff --git a/crates/qbz-qt/qml/cards/CollectionMosaic.qml b/crates/qbz-qt/qml/cards/CollectionMosaic.qml new file mode 100644 index 000000000..beaf8a1dd --- /dev/null +++ b/crates/qbz-qt/qml/cards/CollectionMosaic.qml @@ -0,0 +1,317 @@ +// CollectionMosaic — the artwork renderer for Mixtape / Collection cards, list +// rows, empty states and the detail hero (myqbz/CollectionMosaic.slint:1-121). +// Four sizes in use: 184 (grid card), 48 (list row), 160 (empty state), 186 +// (detail hero). +// +// SIBLING FILE: cards/PlaylistCollage.qml. Read that one first — the tile +// discipline, the URL-keyed artwork resolver, the 120ms dispatch debounce and +// the single outer mask are copied from it deliberately. Two differences: +// * this grid is UNIFORM (2x2 or 3x3) instead of the playlist collage's +// single / dual / 62-38 / 34-66 ratios, and +// * `urls` is INDEX-SIGNIFICANT (Slint's url1..url9), so PlaylistCollage's +// `dedup()` must NOT be copied: cell N is `urls[N]` and Rust already +// guarantees distinct covers. +// It is a separate file rather than a `layout` parameter on PlaylistCollage +// because the tiles are static children: nine of them would mount on every +// playlist card on every Home rail, every Library grid cell and every sidebar +// row, app-wide. +// +// ── HOW THE TILES ARE DRAWN (this file used to be a Canvas) ──────────────── +// Each cell is a `RoundedImage { radius: 0; fit: "crop" }`, i.e. a plain +// filtered `Image` that requests its own scaled derivative at its own device +// size through the existing `QbzSession.artScaled` seam. The first version of +// this file was a per-card `Canvas` whose `Context2D.drawImage` does NOT +// filter when it scales — a 150px cover drawn into a 23px list-row cell was +// EXACTLY nearest-neighbour (measured RMSE 0.000 against `-filter Point`, +// theme/RoundedImage.qml). That is the same defect perf FIX 1 / FIX 4 removed +// from RoundedImage and PlaylistCollage; writing it a third time here was the +// bug. Reuse over a third copy: RoundedImage already owns the intrinsic-size +// latch, the derivative request, the crop fit, the readiness latch, the +// per-item crop scissor (`clip` at radius 0, spec 04 §1.3) and the +// recycled-delegate invalidation. +// +// Only the mosaic's OUTER shape is rounded; the cells are square-cornered. +// That rounding is one `layer.enabled` + one `MultiEffect` mask on the whole +// mosaic (NOT one per cell), the mechanism theme/RoundedImage.qml documents. +// Effects need shaders: this port runs on the GPU (OpenGL RHI, measured +// 2026-07-29); the earlier "shader masks render nothing" note in this header +// came from an offscreen session, which forces the software renderer by +// definition. Where a software path is genuinely possible it is DETECTED +// (`GraphicsInfo.api`), and `QbzShell.forceCanvasArt` pins the same degrade by +// hand — in either case the mask is skipped and the mosaic reads +// square-cornered over the already-rounded placeholder fill, rather than +// blank. +// +// THE RULES, IN PRECEDENCE ORDER (CollectionMosaic.slint:61-120): +// 1. `hasCustomCover` -> ONE full-bleed crop-fitted cover. The gate is +// `hasCustomCover` ALONE (:62), NOT "and the path resolved": an +// unresolved custom cover paints the bare surface-elevated outer, exactly +// as Slint does. (Rust's card predicate and hero predicate differ on +// purpose — myqbz.rs:322-331 vs :367 — so a deleted cover file can give +// glyph-in-hero / mosaic-in-grid. Do not unify them here.) +// 2. else `coverCount == 0` -> the centred kind glyph at round(size * 0.4), +// muted, opacity 0.6 (:70-82). +// 3. else the collage. `cols = (effKind == "collection" && itemCount >= 9) +// ? 3 : 2` (:58), gap 2 (:59). Empty cells show the outer +// surface-elevated through the seam, which is what Slint's empty +// MosaicCell paints anyway (:32). +// +// EFFECTIVE KIND: `emptyKind !== "" ? emptyKind : kind` (:56). The override is +// NOT empty-state-only — it feeds BOTH the glyph choice and the 3x3 rule, so +// it is computed once and used in both places. +// +// Rust pre-decides `coverCount` (0 / 4 / 9) and pre-downscales each cell URL +// (_50 for the 3x3, _150 for the 2x2 — myqbz_qt.rs:403-417); this component +// only lays out. Never point a cell at a _600 source. + +import QtQuick +import QtQuick.Effects +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + /// 0..9 remote cover URLs, INDEX-SIGNIFICANT (Slint's url1..url9). + property var urls: [] + /// Optional pre-resolved `file://` paths from the document (GridDoc + /// `cellPaths` / DetailDoc `heroCellPaths`), same indexing; "" = pending, + /// in which case the URL-keyed resolver below fills it in. + property var paths: [] + property int coverCount: 0 + /// item.kind — mixtape | collection | artist_collection. + property string kind: "" + /// Caller override of the EFFECTIVE kind (glyph AND the 3x3 rule). + property string emptyKind: "" + /// Drives the 3x3 rule. + property int itemCount: 0 + property bool hasCustomCover: false + /// Resolved local file:// for the custom cover. + property string customCoverPath: "" + property real size: 184 + property real gap: 2 + property real radius: 8 + + QbzTheme { id: theme } + + width: root.size + height: root.size + + readonly property string effKind: root.emptyKind !== "" ? root.emptyKind : root.kind + readonly property int cols: (root.effKind === "collection" && root.itemCount >= 9) ? 3 : 2 + readonly property int cellCount: root.cols === 3 ? 9 : 4 + readonly property bool collage: !root.hasCustomCover && root.coverCount > 0 + + /// See theme/RoundedImage.qml: the ONLY case the retired "effects render + /// nothing" doctrine was ever true for is the software/Null renderer, and + /// `GraphicsInfo.api` answers it per window at runtime (`api` carries + /// `notify: "apiChanged"`, so this re-evaluates when the window arrives). + /// Test Software/Null negatively — under the RHI an OpenGL backend reports + /// `GraphicsInfo.OpenGL`. + /// + /// `QbzShell.forceCanvasArt` (env `QBZ_QT_ROUND_MODE=canvas`, default OFF) + /// is honoured HERE TOO, for the reason PlaylistCollage.qml:86-96 states: + /// the override exists for a machine where `GraphicsInfo` reports a GPU and + /// the mask still misbehaves, and if the mask draws nothing there then THIS + /// file's mask draws nothing either — the whole mosaic disappears. Pinning + /// the cells onto RoundedImage's canvas arm while leaving the mosaic masked + /// would defeat the override on exactly the surface it was added for. + /// Square outer corners (the same degrade the software path takes) is the + /// correct fallback. The `typeof` guard mirrors RoundedImage: eager + /// binding, and the file must stay loadable in an isolated qml6 scene. + readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software + || GraphicsInfo.api === GraphicsInfo.Null + || (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt) + + // url -> local cached path, filled by the artwork signal. + property var pathMap: ({}) + + function urlAt(index) { + const u = root.urls + return (u && index < u.length && u[index]) ? u[index] : "" + } + function pathAt(index) { + const seeded = (root.paths && index < root.paths.length) ? root.paths[index] : "" + if (seeded) + return seeded + const u = root.urlAt(index) + return u ? (root.pathMap[u] || "") : "" + } + + /// Uniform grid, transcribed from CollectionMosaic.slint: 2x2 cell + /// `(size - gap) / 2` (:89-95), 3x3 cell `(size - 2 * gap) / 3` (:104-118), + /// `gap` 2 (:59). Cell N sits at `col * (cell + gap)` / `row * (cell + gap)` + /// — the same arithmetic the deleted `onPaint` ran. These numbers do not + /// change. + function cellOf(index) { + const w = root.width + const h = root.height + const n = root.cols + if (w <= 0 || h <= 0 || index >= n * n) + return Qt.rect(0, 0, 0, 0) + const g = root.gap + const cell = (w - (n - 1) * g) / n + return Qt.rect((index % n) * (cell + g), + Math.floor(index / n) * (cell + g), + cell, cell) + } + + // --- Outer placeholder. A Rectangle's OWN rounded fill paints correctly; + // only its children would need the mask. + Rectangle { + anchors.fill: parent + radius: root.radius + color: theme.surfaceElevated + } + + // --- Rule 1: custom cover, full-bleed crop. Loader-gated so the mask is + // not instantiated on the (overwhelmingly common) cards that have no + // custom cover. + Loader { + anchors.fill: parent + active: root.hasCustomCover + sourceComponent: customCover + } + Component { + id: customCover + RoundedImage { + source: root.customCoverPath + radius: root.radius + fit: "crop" + } + } + + // --- Rule 2: the centred kind glyph. + QbzIcon { + visible: !root.hasCustomCover && root.coverCount === 0 + name: root.effKind === "mixtape" ? "cassette-tape" + : root.effKind === "artist_collection" ? "user" + : "library-big" + width: Math.round(root.size * 0.4) + height: width + anchors.centerIn: parent + tintName: "muted" + opacity: 0.6 + } + + // --- Artwork: URL-keyed through the SHARED resolver (the sidebar + // micro-collage channel), debounced so flinging a windowed grid never + // fetches covers for delegates that are already gone. Only cells whose + // path is still unknown are asked for. No new bridge member. + Timer { + id: dispatchDelay + interval: 120 + onTriggered: { + var pending = [] + for (var i = 0; i < root.cellCount; i++) { + var u = root.urlAt(i) + if (u !== "" && root.pathAt(i) === "") + pending.push(u) + } + if (pending.length > 0) + QbzShell.sidebarArtworkWindow(JSON.stringify(pending)) + } + } + function kick() { + if (root.collage) + dispatchDelay.restart() + } + onUrlsChanged: root.kick() + Component.onCompleted: root.kick() + + Connections { + target: QbzLibrary + // Shared with the feed's artKey-keyed emissions — ignore anything that + // is not one of OUR urls. + function onLibraryArtworkReady(key, path) { + if (root.pathMap[key] === path) + return + var mine = false + for (var i = 0; i < root.cellCount; i++) { + if (root.urlAt(i) === key) { mine = true; break } + } + if (!mine) + return + var m = root.pathMap + m[key] = path + // Rebind requires a NEW object reference. + root.pathMap = Object.assign({}, m) + } + } + + /// The rounded OUTER shape, rendered to its own FBO once (it changes only + /// with width/height/radius, never per frame). `visible: false` + layered + /// is the documented MultiEffect mask idiom — the layer renders regardless + /// of visibility, which is the whole point. + Item { + id: maskSrc + anchors.fill: parent + visible: false + // Also gated on `collage` — a layer renders even when its item is + // invisible (that is the mask idiom), so a glyph-only card would + // otherwise pay two FBOs to mask a glyph that is not inside them. + layer.enabled: !root._noShaders && root.radius > 0 && root.collage + layer.smooth: true + Rectangle { + anchors.fill: parent + radius: root.radius + color: "#ffffff" + } + } + + // --- Rule 3: the collage. ONE layer + ONE mask for the whole mosaic, not + // one per cell. `clip: true` bounds the outer rect; each cell's own crop + // overflow is scissored by RoundedImage itself (`clip` there covers exactly + // the radius-0 crop case this file creates — the deleted Canvas clipped per + // cell with `ctx.rect(); ctx.clip()`). + Item { + id: mosaic + anchors.fill: parent + // No clip: cellOf() tiles the parent rect exactly, and each tile is a + // RoundedImage that scissors itself when it needs to. + visible: root.collage + layer.enabled: !root._noShaders && root.radius > 0 && root.collage + layer.smooth: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: maskSrc + // 0.5 / 1.0 — MEASURED, see theme/RoundedImage.qml:491-519 for the + // full table. Qt's mask is a smoothstep CENTRED on maskThresholdMin + // of width maskSpreadAtMin, so (0.5, 1.0) is the only pair that maps + // mask alpha 0..1 onto 0..1 and therefore the only one that + // reproduces `ctx.clip()`'s antialiased corner. The comment that + // used to sit here had it backwards: (0.0, 0.0) does NOT "leave + // everything alpha-blending", it collapses the smoothstep to + // `step(0, alpha)` — every mask pixel with any alpha snaps fully + // opaque, so the corner comes back HARD (measured at radius 40 on + // this Qt 6.11.1: 0 antialiased pixels against the Canvas's 292, + // and 308 at 0.5/1.0 = Canvas parity, ramp [0,51,219,255]). + // (0.0, > 0) is worse: the low edge goes negative, everything + // clamps opaque and the mask is disabled outright. Do not "restore + // the defaults" here. + maskThresholdMin: 0.5 + maskSpreadAtMin: 1.0 + } + + Repeater { + // 4 for the 2x2, 9 for the 3x3, and ZERO when this card shows a + // custom cover or the kind glyph — an idle tile is still an Item. + model: root.collage ? root.cellCount : 0 + delegate: RoundedImage { + required property int index + readonly property rect cell: root.cellOf(index) + x: cell.x + y: cell.y + width: cell.width + height: cell.height + source: root.pathAt(index) + // Square-cornered: only the mosaic's outer shape is rounded. + // radius 0 also means this RoundedImage mounts a plain Image + // with NO layer and NO mask of its own, and takes the crop + // scissor instead (spec 04 §1.3). + radius: 0 + fit: "crop" + } + } + } +} diff --git a/crates/qbz-qt/qml/cards/LabelCard.qml b/crates/qbz-qt/qml/cards/LabelCard.qml new file mode 100644 index 000000000..f9030efc0 --- /dev/null +++ b/crates/qbz-qt/qml/cards/LabelCard.qml @@ -0,0 +1,220 @@ +// LabelCard — THE label card (discover/LabelCard.slint), promoted from +// LibraryView.LibLabelCard in phase 21: Library Labels grid + All feed. +// +// 200x246, the ArtistCard shape: 200px surface-card frame + 190px ROUND +// container, hover scrim CLIPPED TO THE CIRCLE, overlay row at y=113. +// The circle is the owner's directive ("a label should look like an +// artist, not like an album") — the Slint LabelCard is a 200x200 SQUARE; +// everything else here is the Slint 1:1 (see the divergence note below). +// +// From the Slint reference, kept verbatim: +// - no logo -> indigo(#6366f1)->violet(#8b5cf6) gradient + disc-3 glyph; +// - a logo -> surface-elevated behind a CONTAIN-fit logo, never cropped +// (label logos have varied/transparent backgrounds, unlike album art); +// - "a label is opened, not played" — the ONLY action a label exposes +// anywhere in the Slint is open ("Go to label", disc-3 icon, +// FavoritesActions.open-label). There is NO play, NO favorite, NO pin +// and NO ⋯ menu on a label card, so the overlay row carries exactly one +// disc (open) instead of the artist's follow/play/more triplet. +// +// The label landing page LANDED (qml/views/LabelView.qml + +// src/label_qt.rs), so `hasLabelViewSeam` is now true and every open path +// on this card — body, overlay disc, title — funnels through +// `QbzHome.openLabel(id)`. That also un-deadens the Library > Labels grid, +// which mounts this same card (LibraryView.qml:1191). +// +// The constant STAYS, deliberately: it is the one switch that turns the +// card's whole open affordance (overlay disc, pointing-hand cursor, hover +// scrim, title accent) on and off together, so the card can never drift +// back into looking clickable while doing nothing. +// +// item contract: { id, title, subtitle, imageUrl } plus the +// host-resolved artSource string prop. The artwork is driven by +// artSource ALONE (it used to be gated on item.imageUrl, which hid the +// logo on any mount that resolves covers without republishing the item); +// imageUrl only selects the gradient-vs-elevated placeholder arm. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Rectangle { + id: root + + property var item: ({}) + // Host-resolved artwork path (the AlbumCard artSource pattern). + property string artSource: "" + // Remote cover URL for the pin payload ("" when the host has none). + // Declared for contract parity with the other cards; a label cannot be + // pinned (the pinned store carries album/artist/playlist only). + property string artworkUrl: "" + + color: "transparent" + + QbzTheme { id: theme } + + // Everything that navigates is gated on this — see the header. + readonly property bool hasLabelViewSeam: true + + // No logo URL -> the indigo/violet glyph arm (the Slint condition). + readonly property bool noLogo: !root.item.imageUrl + readonly property bool overlayOn: root.hasLabelViewSeam + && (lblArea.containsMouse || lblOpen.hovered) + + // Single funnel for every open path (body, overlay disc, title). + function openLabel() { + if (!root.hasLabelViewSeam) + return + var id = (root.item && root.item.id) ? String(root.item.id) : "" + if (id === "") + return + QbzHome.openLabel(id) + } + + implicitWidth: 200 + implicitHeight: 246 + + Column { + spacing: 0 + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceCard + + // 190px round logo container, centered (5px surround = the + // album-tile frame, ArtistCard). All overlay content lives + // inside so it clips to the circle. + Rectangle { + x: 5 + y: 5 + width: 190 + height: 190 + radius: 95 + // No clip. It never clipped to the circle — QML's clip is a + // rectangular scissor and ignores `radius`; the round shape is + // each child's own `radius: 95` / RoundedImage mask. The logo + // is inset 28px with fit "contain" and the overlay row ends at + // y=157 < 190, so nothing overflows. + // Logo arm background (the Slint Theme.surface-elevated). + color: theme.surfaceElevated + + // No logo: indigo->violet gradient + disc glyph. + Rectangle { + visible: root.noLogo + anchors.fill: parent + radius: 95 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "#6366f1" } + GradientStop { position: 1.0; color: "#8b5cf6" } + } + QbzIcon { + name: "disc-3" + width: 56 + height: 56 + anchors.centerIn: parent + // On the #6366f1 -> #8b5cf6 gradient disc, which is a + // fixed brand colour, not a theme surface. + tintName: "white" + } + } + + // The logo itself — contain-fit inside the square INSCRIBED + // in the circle (190 / sqrt(2) ~= 134, so margin 28), which + // is what keeps a wide wordmark from being cut by the round + // container. radius 0: the inset box never reaches the edge, + // so it needs no clip of its own. + RoundedImage { + anchors.fill: parent + anchors.margins: 28 + source: root.artSource + radius: 0 + // Logos are contain-fit, never cropped (LabelCard). + fit: "contain" + } + + // Hover scrim (clipped to the circle by the parent's clip). + // 0.55, not the Slint's 0.25: the reference has no buttons + // over it, this one does and 0.25 leaves them unreadable + // over a light logo. + Rectangle { + anchors.fill: parent + radius: 95 + color: "#000000" + opacity: root.overlayOn ? 0.55 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + + // Card-open + hover detector (before the disc so it wins + // the pointer). + MouseArea { + id: lblArea + anchors.fill: parent + enabled: root.hasLabelViewSeam + hoverEnabled: root.hasLabelViewSeam + cursorShape: Qt.PointingHandCursor + onClicked: root.openLabel() + } + + // Hover overlay — open only (y=113, the card-family row). + CardOverlayRow { + y: 113 + width: parent.width + shown: root.overlayOn + CardOverlayButton { + id: lblOpen + visible: root.hasLabelViewSeam + name: "disc-3" + primary: true + anchors.verticalCenter: parent.verticalCenter + onClicked: root.openLabel() + } + } + } + } + Item { width: 1; height: 6 } + // Meta: centered under the circle (the ArtistCard block — the Slint + // LabelCard left-aligns because its art is a square; a centered + // name is what pairs with a round container, and it keeps labels + // aligned with artists in the mixed Library All grid). + Column { + width: 200 + height: 40 + spacing: 2 + Text { + width: parent.width + height: root.item.subtitle ? 20 : 40 + text: root.item.title || "" + color: lblTitleArea.containsMouse ? theme.accent : theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: root.item.subtitle ? Text.NoWrap : Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + MouseArea { + id: lblTitleArea + anchors.fill: parent + enabled: root.hasLabelViewSeam + hoverEnabled: root.hasLabelViewSeam + cursorShape: Qt.PointingHandCursor + onClicked: root.openLabel() + } + } + Text { + visible: !!root.item.subtitle + width: parent.width + height: 16 + text: root.item.subtitle || "" + color: theme.textMuted + font.pixelSize: theme.fontLink - 1 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } +} diff --git a/crates/qbz-qt/qml/cards/MixArtwork.qml b/crates/qbz-qt/qml/cards/MixArtwork.qml new file mode 100644 index 000000000..7f68e4aaa --- /dev/null +++ b/crates/qbz-qt/qml/cards/MixArtwork.qml @@ -0,0 +1,116 @@ +// The Qobuz-mix gradient square — ONE definition of the four mix identities +// (colours, badge, display name), used by BOTH surfaces that draw them: +// +// views/HomeView.qml MixesRail — 220px tile, badge + 22px name +// views/MixView.qml header — 224px art, no badge, 34px name +// +// In the Slint build those two live in different files and each re-declares +// the same four gradients (discover/QobuzMixesRow.slint:79-104 and +// mix/MixView.slint:63-70). Porting that duplication would mean a colour +// changed on the tile and not on the page it opens, so the port keeps one +// source (track rule 5). +// +// GRADIENT DELTA, inherited from the existing MixesRail: the .slint uses +// `@linear-gradient(135deg, a 0%, b 50%, c 100%)` and QML's Gradient has no +// angle — only Vertical / Horizontal. The vertical 3-stop plus a +// top-left-to-bottom-right darkening overlay is the approximation the POC +// already shipped for the tiles; the page header now uses the same one, so +// the two match each other exactly. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + /// "daily" | "weekly" | "fav" | "top". + property string kind: "daily" + property int size: 220 + property int titleSize: 22 + property int cornerRadius: 8 + property bool showBadge: true + /// Whether the square reacts to the pointer at all (the page header does + /// not — it is not a link to anywhere). + property bool interactive: true + + signal clicked() + + QbzTheme { id: theme } + + width: root.size + height: root.size + + // QobuzMixesRow.slint:79-104 — the badge is the SERVICE the mix comes + // from ("qobuz" = Qobuz's own dynamic mixes, "qbz" = built locally from + // the user's library / playlists), which is why it is not uniform. + readonly property var _spec: ({ + "daily": { "badge": "qobuz", "name": "DailyQ", + "c0": "#1e3a8a", "c1": "#6366f1", "c2": "#c084fc" }, + "weekly": { "badge": "qobuz", "name": "WeeklyQ", + "c0": "#065f46", "c1": "#10b981", "c2": "#fbbf24" }, + "fav": { "badge": "qbz", "name": "FavQ", + "c0": "#7f1d1d", "c1": "#ef4444", "c2": "#fb923c" }, + "top": { "badge": "qbz", "name": "TopQ", + "c0": "#1f2937", "c1": "#4b5563", "c2": "#fbbf24" } + }) + readonly property var spec: root._spec[root.kind] !== undefined + ? root._spec[root.kind] : root._spec["daily"] + readonly property string mixName: root.spec.name + readonly property string badge: root.spec.badge + + /// The four tile descriptions (QobuzMixesRow.slint:82-103). NOTE these + /// are NOT the mix PAGE's subtitles — `mix.rs::mix_meta` uses a different + /// string for `weekly` and `top`, and the page reads its own from Rust. + function tileDescription(k) { + return k === "daily" + ? QbzSession.tr("Elevate your day with a customized selection of music.", QbzSession.trRev) + : k === "weekly" + ? QbzSession.tr("Take a weekly journey with a fresh mix every Friday.", QbzSession.trRev) + : k === "fav" + ? QbzSession.tr("A fresh shuffle from your personal library.", QbzSession.trRev) + : QbzSession.tr("Discover new music from your most-played playlists.", QbzSession.trRev) + } + + Rectangle { + anchors.fill: parent + radius: root.cornerRadius + gradient: Gradient { + GradientStop { position: 0.0; color: root.spec.c0 } + GradientStop { position: 0.5; color: root.spec.c1 } + GradientStop { position: 1.0; color: root.spec.c2 } + } + // The 135deg sweep, approximated (see the header note). + Rectangle { + anchors.fill: parent + radius: root.cornerRadius + gradient: Gradient { + GradientStop { position: 0.0; color: "#00000000" } + GradientStop { position: 1.0; color: "#33000000" } + } + } + Text { + visible: root.showBadge + x: 12 + y: 12 + text: root.badge + color: "#ccffffff" + font.pixelSize: 10 + font.weight: theme.weightSemibold + font.letterSpacing: 1 + } + Text { + anchors.centerIn: parent + text: root.mixName + color: "#ffffff" + font.pixelSize: root.titleSize + font.weight: theme.weightBold + } + MouseArea { + anchors.fill: parent + enabled: root.interactive + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } + } +} diff --git a/crates/qbz-qt/qml/cards/PlaylistCard.qml b/crates/qbz-qt/qml/cards/PlaylistCard.qml new file mode 100644 index 000000000..cf49e1aa2 --- /dev/null +++ b/crates/qbz-qt/qml/cards/PlaylistCard.qml @@ -0,0 +1,412 @@ +// PlaylistCard — THE playlist card (discover/PlaylistCard.slint), shared +// by every surface since phase 21: Home/Editor/ForYou playlist rails, the +// Pinned rail, the Library playlist grid + All feed, the Search all-tab +// carousel + playlists grid. +// +// 200x246: 200px cover (Radius.sm) + hover scrim 0.6, overlay row at +// y=120 (fav tri-state / play / more), pin badge top-right, then the +// left-aligned meta: semibold title + (category accent eyebrow | muted +// subtitle). Body click OPENS the playlist (the Slint body-opens arm — +// every Slint mount sets it). +// +// item contract: { id, title, subtitle, category, playlistOwned, +// playlistFollowing, isFavorite } plus the scalar artSource / artworkUrl +// / isPinned props (the AlbumCard pattern). +// +// Artwork, two arms (1:1 with Tauri, which has TWO playlist cards): +// - `item.playlistOwnImage` — the playlist carries its OWN Qobuz graphic +// (`image_rectangle`). Those are landscape and cropping butchers them, +// so they render CONTAIN (QobuzPlaylistCard: `object-fit: contain`). +// - otherwise — the mosaic of member-track covers from `item.covers` +// (FavoritePlaylistCard -> PlaylistCollage). Rows without either get +// the collage's list-music placeholder. +// Surfaces that publish neither field (Home rails, Search) keep the single +// crop-fitted cover they already passed through `artSource`. +// +// Ownership tri-state (Slint): owned -> heart (library favorite); +// foreign followed -> check; foreign -> user-plus (Qobuz follow). +// +// SETTLE ASYMMETRY, deliberate and not a hole this file can close: the +// OWNED arm settles on `QbzLibrary.libraryFavoriteChanged` (rollback on a +// failed write included). The FOREIGN follow arm has no signal at all — +// `playlist_qt::set_follow_by_id` logs the error and otherwise settles only +// the caches (`follow_settled`: the ownership snapshot, the sidebar tree and +// the Library feed), so a FAILED subscribe leaves the optimistic tick up +// until the card's document is republished. Closing it needs a Rust-side +// settle emit, not a QML change. +// +// Offline the foreign arm is a WRITE that cannot land (`playlist/subscribe` +// is gate-refused), and this card offers it anyway — the detail header now +// disables its twin (`views/PlaylistView.qml`, `online`). Not fixed here +// because the same control also carries the OWNED heart, which is a local +// library.db write and MUST stay live offline; splitting the enablement is a +// change to this card's contract, not a one-liner. +// +// --- Menu inventory vs discover/PlaylistCard.slint --------------------- +// Play · Play next · Play later · Add to queue · +// Add to Library (is-owned) | Follow/Unfollow on Qobuz (!is-owned) · +// Add to mixtape · Make available offline (QoL round additions over the +// .slint inventory) · Copy to your library (!is-owned && !is-copied) +// All live except the last — see `hasCopyByIdSeam`. The ⋯ button and a +// right press on the cover or the title open the same menu. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Rectangle { + id: root + + property var item: ({}) + // Host-resolved artwork path (the AlbumCard artSource pattern). + property string artSource: "" + // Remote cover URL for the pin payload. Defaults to whatever the row + // carries — the playlist's own graphic, else its first member cover — + // so a pinned playlist keeps art in the Pinned rail; hosts that resolve + // it themselves may still override. + // `artUrl` is the Home / Search / Browse row field (home_qt::HomeCard), + // `imageUrl` the Library one — without both arms a playlist pinned from + // a Discover rail stored an EMPTY snapshot url and landed in the Pinned + // rail as a permanent placeholder. + property string artworkUrl: (root.item && root.item.imageUrl) + ? root.item.imageUrl + : ((root.item && root.item.artUrl) + ? root.item.artUrl + : (root.collageUrls.length > 0 ? root.collageUrls[0] : "")) + + // Member-track covers for the mosaic arm ([] on surfaces that publish a + // single pre-resolved cover instead). + readonly property var collageUrls: (root.item && root.item.covers) + ? root.item.covers : [] + // The row's image is the playlist's own Qobuz graphic -> contain. + readonly property bool ownImage: root.item + ? root.item.playlistOwnImage === true : false + // Pinned state (AlbumCard pattern: scalar prop, optimistic flip on + // click — the model re-publish re-creates the delegate). + property bool isPinned: false + + // "Copy to your library": QbzBridge.playlistCopy() copies the playlist + // that is currently OPEN — it takes no id, so a card cannot call it. The + // row stays out of the menu until a by-id copy invokable exists (and + // `item.playlistCopied` is published for the .slint's is-copied gate). + readonly property bool hasCopyByIdSeam: false + + color: "transparent" + + QbzTheme { id: theme } + + readonly property bool overlayOn: plArtArea.containsMouse || pinArea.containsMouse + || plFav.hovered || plPlay.hovered || plMore.hovered + + implicitWidth: 200 + implicitHeight: 246 + + // --- The tri-state's two mutable halves, as REAL QML properties ------ + // They used to be `root.item.isFavorite` / `root.item.playlistFollowing`, + // mutated in place on click. That is a plain JS object: no notifier, so + // the glyph binding never re-evaluated, and `item: modelData` is a COPY, + // so the write reached neither the delegate's model row nor the host's + // array (both measured under qml6 6.11.1 — see rows/TrackRow.qml). The + // card's overlay therefore never changed on click, on ANY surface. + // `owned` stays a plain read: nothing in the UI can change who owns a + // playlist. + readonly property bool owned: root.item.playlistOwned === true + property bool favorite: root.item.isFavorite === true + property bool following: root.item.playlistFollowing === true + onItemChanged: { + root.favorite = Qt.binding(function () { return root.item.isFavorite === true }) + root.following = Qt.binding(function () { return root.item.playlistFollowing === true }) + } + + /// The overlay button and the menu row — ONE implementation. Owned + /// playlists take the library heart (a qbz-LOCAL flag, library.db); a + /// foreign one takes the Qobuz follow (playlist/subscribe). Different + /// writes, different settle signals — see the Connections below. + function toggleFavorite() { + if (root.owned) { + root.favorite = !root.favorite + QbzLibrary.libraryToggleFavorite("playlist", root.item.id) + } else { + root.following = !root.following + QbzBridge.playlistSetFollowById(root.item.id, root.following) + } + } + + function openMenu(anchor, x, y) { + plMenuLoader.active = true + plMenuLoader.item.openAtCursor(anchor, x, y) + } + function releaseForReuse() { + if (plMenuLoader.item) + plMenuLoader.item.close() + plMenuLoader.active = false + } + + Connections { + target: QbzLibrary + // Pin fan-out — the AlbumCard contract, playlist key (see AlbumCard.qml). + function onPinChanged(key, value) { + var pid = (root.item && root.item.id !== undefined) ? root.item.id : "" + if (pid !== "" && key === "playlist:" + pid) + root.isPinned = value + } + // Heart fan-out + rollback. Only the OWNED arm settles from here: + // `library_qt::toggle_favorite` routes kind "playlist" to + // `toggle_playlist_favorite` (the library.db flag) and reports the + // result through the shared settle point, so the key is + // `playlist:{id}` exactly like every other kind. + function onLibraryFavoriteChanged(key, value) { + var pid = (root.item && root.item.id !== undefined) ? root.item.id : "" + if (pid !== "" && key === "playlist:" + pid) + root.favorite = value + } + } + + Column { + spacing: 0 + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + // No clip: the child RoundedImage confines itself on both arms, and + // a rectangular scissor never produced this radius. A clip is an + // unconditional batch root, so this one cost a draw call per item. + // Arm 1 — a resolved single cover. + // + // The playlist's own Qobuz graphic (`image_rectangle`) is a + // 2.11:1 banner — 800x380 on every such file in the local cache. + // Cropping it to this 200px square keeps the middle 380 of 800 + // px, i.e. throws away 53% of the width; that is what the owner + // reported, and it is what Slint does too + // (discover/PlaylistCollage.slint's single tile and + // playlist/PlaylistView.slint:481 are both `image-fit: cover`). + // `pad` keeps the ratio and fills the two uncovered bands with a + // gradient sampled from the image's own edges — see + // theme/RoundedImage.qml. + // + // `auto` on the other arm because the flag is not universal: + // only `library_qt.rs` publishes `playlistOwnImage`, while the + // Home / For You / Search / Browse rails map their playlist art + // from `image.rectangle` all the same (`home_qt.rs::map_playlist`) + // with no flag at all. `auto` measures the ratio instead, so + // those surfaces pad too, and a genuinely square cover + // (`image.covers[0]`, the fallback that map picks) still crops. + Loader { + anchors.fill: parent + sourceComponent: root.artSource !== "" ? singleArtwork : collageArtwork + } + Component { + id: singleArtwork + RoundedImage { + source: root.artSource + radius: theme.radiusSm + fit: root.ownImage ? "pad" : "auto" + } + } + // Arm 2 — the member-cover mosaic. A row that HAS its own + // graphic passes no urls, so it shows the placeholder while + // that graphic downloads instead of flashing a mosaic. + Component { + id: collageArtwork + PlaylistCollage { + urls: root.ownImage ? [] : root.collageUrls + radius: theme.radiusSm + } + } + Rectangle { + anchors.fill: parent + radius: theme.radiusSm + color: "#000000" + opacity: root.overlayOn ? 0.6 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + MouseArea { + id: plArtArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + // body-opens (every Slint mount); right press = the ⋯ menu. + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) + root.openMenu(plArtArea, mouse.x, mouse.y) + else + QbzBridge.openPlaylist(root.item.id) + } + } + // Hover overlay — fav / play / more (y=120, h=44, centered). + CardOverlayRow { + y: 120 + width: parent.width + shown: root.overlayOn + CardOverlayButton { + id: plFav + name: root.owned ? "heart" + : root.following ? "check" : "user-plus" + active: root.owned && root.favorite + anchors.verticalCenter: parent.verticalCenter + onClicked: root.toggleFavorite() + } + CardOverlayButton { + id: plPlay + name: "play-fill" + primary: true + anchors.verticalCenter: parent.verticalCenter + onClicked: QbzPlayer.playPlaylistById(root.item.id) + } + CardOverlayButton { + id: plMore + name: "ellipsis" + anchors.verticalCenter: parent.verticalCenter + onClicked: function (mouse) { root.openMenu(plMore, mouse.x, mouse.y) } + } + } + // Pin badge — top-right (opacity follows overlay-on). + Rectangle { + x: parent.width - width - 8 + y: 8 + width: 26 + height: 26 + radius: 13 + color: pinArea.containsMouse ? "#cc000000" : "#99000000" + opacity: root.overlayOn ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + QbzIcon { + name: root.isPinned ? "pin-filled" : "pin" + width: 14 + height: 14 + anchors.centerIn: parent + // On a #99000000/#cc000000 scrim — dark under every theme. + tintName: root.isPinned ? "accent" : "white" + } + MouseArea { + id: pinArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.isPinned = !root.isPinned + QbzLibrary.togglePin("playlist", root.item.id, root.item.title, + root.item.subtitle || "", root.artworkUrl) + } + } + } + Loader { + id: plMenuLoader + active: false + sourceComponent: CardMenu { + menuWidth: 196 + entries: root.menuModel() + onPicked: function (a) { root.menuAction(a) } + } + } + } + Item { width: 1; height: 6 } + // Meta: left-aligned, semibold title + (category eyebrow | subtitle). + Column { + width: 200 + height: 40 + spacing: 2 + leftPadding: theme.spacingXs + Text { + width: parent.width + height: 20 + text: root.item.title || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + visible: !!root.item.category + width: parent.width + height: 16 + text: (root.item.category || "").toUpperCase() + color: theme.accent + font.pixelSize: 11 + font.weight: theme.weightSemibold + font.letterSpacing: 0.5 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + visible: !root.item.category + width: parent.width + height: 16 + text: root.item.subtitle || "" + color: theme.textMuted + font.pixelSize: theme.fontLink - 1 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + + // Right-press catcher over the meta block (the cover has its own in + // plArtArea). Declared OUTSIDE the Column so it takes no layout space, + // and RightButton-only so left clicks still fall through to the text. + MouseArea { + id: plMetaArea + x: 0 + y: 206 + width: 200 + height: 40 + acceptedButtons: Qt.RightButton + onClicked: function (mouse) { root.openMenu(plMetaArea, mouse.x, mouse.y) } + } + + // PlaylistCard.slint's menu, in its order. + function menuModel() { + var t = QbzSession.tr + var r = QbzSession.trRev + var owned = root.owned + var following = root.following + var m = [ + { "label": t("Play", r), "icon": "play-fill", "action": "play" }, + { "label": t("Play next", r), "icon": "list-start", "action": "play-next" }, + // #442 "Play later" — end of the manual block. + { "label": t("Play later", r), "icon": "list-plus", "action": "play-later" }, + { "label": t("Add to queue", r), "icon": "list-end", "action": "queue" }, + ] + if (owned) + m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r), + "icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" }) + else + m.push({ "label": following ? t("Unfollow on Qobuz", r) : t("Follow on Qobuz", r), + "icon": following ? "check" : "user-plus", "action": "favorite" }) + // QoL round additions over the .slint inventory: the container and + // offline actions every card mount can honour — this card is only + // ever mounted on Qobuz playlist rows (its play/enqueue actions all + // go through the by-id catalog invokables), so neither needs a gate. + m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" }) + m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" }) + if (root.hasCopyByIdSeam && !owned && root.item.playlistCopied !== true) + m.push({ "label": t("Copy to your library", r), "icon": "copy", "action": "copy" }) + return m + } + + function menuAction(a) { + if (a === "play") QbzPlayer.playPlaylistById(root.item.id) + else if (a === "play-next") QbzPlayer.enqueuePlaylistById(root.item.id, "next") + else if (a === "play-later") QbzPlayer.enqueuePlaylistById(root.item.id, "later") + else if (a === "queue") QbzPlayer.enqueuePlaylistById(root.item.id, "queue") + else if (a === "favorite") root.toggleFavorite() + else if (a === "mixtape") { + // The payload is assembled at the call site (the SidebarRowMenu / + // PmGridCard idiom). `trackCount` is omitted on purpose — the card + // contract carries no member count and myqbz_add_qt PERSISTS the + // value, so absent (NULL) is the truthful "unknown". + QbzMyQbzAdd.open(JSON.stringify([{ + "itemType": "playlist", "source": "qobuz", + "sourceItemId": String(root.item.id), + "title": root.item.title || "", + "subtitle": root.item.subtitle || "", + "artworkUrl": root.artworkUrl || "" + }])) + } + else if (a === "cache") QbzOffline.cachePlaylist(String(root.item.id)) + } +} diff --git a/crates/qbz-qt/qml/cards/PlaylistCollage.qml b/crates/qbz-qt/qml/cards/PlaylistCollage.qml new file mode 100644 index 000000000..408cb8d4f --- /dev/null +++ b/crates/qbz-qt/qml/cards/PlaylistCollage.qml @@ -0,0 +1,327 @@ +// PlaylistCollage — the playlist cover mosaic (discover/PlaylistCollage.slint, +// itself the port of Tauri's PlaylistCollage.svelte). +// +// Qobuz hands back up to four MEMBER-ALBUM covers per playlist +// (images300 / images150 / images); the layout is picked by how many there +// are — single, dual (side by side), triple (one large left + two stacked +// right), quad (three stacked left + one large right), and a list-music +// glyph when the playlist has none. Tiles are always crop-fitted; the +// playlist's OWN Qobuz graphic is NOT drawn here (PlaylistCard and +// PlaylistView render that one through RoundedImage's `pad` fit). +// +// The crop is deliberate and stays: these tiles are member-ALBUM covers, +// which Qobuz ships SQUARE, and from two tiles up the cells are not square +// anyway — a mosaic cell is meant to be filled edge to edge, so letterboxing +// one would put gaps inside the grid. The image-derived padding added for +// the 800x380 playlist banner therefore has no case to answer here, and +// duplicating it into this file would only fork the one implementation. +// +// ── HOW THE TILES ARE DRAWN (this file used to be a Canvas) ──────────────── +// Each tile is a `RoundedImage { radius: 0; fit: "crop" }`, i.e. a plain +// filtered `Image` that requests its own scaled derivative at its own device +// size through the existing `QbzSession.artScaled` seam. The previous body was +// a per-card `Canvas` whose `Context2D.drawImage` does NOT filter when it +// scales, so a 600px member cover in a ~100px mosaic cell was EXACTLY +// nearest-neighbour (measured RMSE 0.000 against `-filter Point`) — the +// artwork fix that reached RoundedImage never reached this file. Reuse over a +// second copy: RoundedImage already owns the intrinsic-size latch, the +// derivative request, the crop fit, the readiness latch and the +// recycled-delegate invalidation. +// +// Only the collage's OUTER shape is rounded; the tiles are square-cornered. +// That rounding is one `layer.enabled` + one `MultiEffect` mask on the mosaic +// (NOT one per tile), the same mechanism theme/RoundedImage.qml documents. +// Effects need shaders: this port runs on the GPU (OpenGL RHI, measured +// 2026-07-29), and the earlier "shader masks render nothing" note in this +// header came from an offscreen session, which forces the software renderer by +// definition. Where a software path is genuinely possible it is DETECTED +// (`GraphicsInfo.api`) — there the mask is skipped and the mosaic reads +// square-cornered over the already-rounded placeholder fill, rather than +// blank. +// +// PARITY NOTE (pre-existing gap, not introduced here): Slint's `n == 1` arm +// (PlaylistCollage.slint:39-49) contain-fits the single cover over a +// `playlist.dominant-color` letterbox; this port crops it, because the Qt feed +// publishes no `dominantColor` field. Logged for the UX pass — a perf fix must +// not invent a producer field. +// +// Artwork is URL-keyed, not artKey-keyed (a playlist row has ONE artKey but +// up to four covers): the component hands its urls to the shared url-keyed +// resolver — `QbzShell.sidebarArtworkWindow`, the same entry point the +// sidebar tree's micro-collage uses — and listens for the +// `libraryArtworkReady` echo. Disk hits come back synchronously; misses are +// downloaded once and cached. The dispatch is debounced so flinging through +// a windowed grid never fetches covers for delegates that are already gone. + +import QtQuick +import QtQuick.Effects +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + // Remote cover urls (feed row `covers`); only the first four distinct + // entries are drawn. + property var urls: [] + property real radius: 8 + // Mosaic seam, 1:1 with PlaylistCollage.slint. + property real gap: 2 + + /// `"mosaic"` (discover/PlaylistCollage.slint — the default everywhere) or + /// `"pm"` (primitives/PmCollage.slint, the Playlist Manager's thumbnail). + /// + /// They are genuinely different layout FUNCTIONS, which is the whole delta: + /// the mosaic splits 2 and 3 covers into cells, while the manager collapses + /// anything under four to ONE full-bleed cover and only splits at four, with + /// a 1 px seam instead of 2. Everything else — the url → path resolution + /// through QbzShell.sidebarArtworkWindow + QbzLibrary.libraryArtworkReady, + /// the 120 ms debounce, the MultiEffect rounding with the measured + /// maskThresholdMin 0.5 / maskSpreadAtMin 1.0, the three-way `_noShaders` + /// test and the recycled-delegate invalidation — is identical and must not + /// be forked into a second file. + property string layout: "mosaic" + + /// The manager pins its seam at 1 px (PmCollage.slint:27). + readonly property real effGap: root.layout === "pm" ? 1 : root.gap + + /// How many of the (up to four) tiles this layout actually draws. The + /// mosaic draws every cover it has; `"pm"` draws ONE below four. + readonly property int drawnTiles: root.layout === "pm" + ? (root.tiles.length >= 4 ? 4 : (root.tiles.length >= 1 ? 1 : 0)) + : root.tiles.length + + QbzTheme { id: theme } + + // De-duplicated + capped at four (Tauri dedupes the same way: repeated + // albums in a playlist must not paint the same tile twice). + readonly property var tiles: root.dedup(root.urls) + // url -> local cached path, filled by the artwork signal. + property var pathMap: ({}) + + /// See theme/RoundedImage.qml: the ONLY case the retired "effects render + /// nothing" doctrine was ever true for is the software/Null renderer, and + /// `GraphicsInfo.api` answers it per window at runtime (`api` carries + /// `notify: "apiChanged"`, so this re-evaluates when the window arrives). + /// Test Software/Null negatively — under the RHI an OpenGL backend reports + /// `GraphicsInfo.OpenGL`. + /// + /// `QbzShell.forceCanvasArt` (env `QBZ_QT_ROUND_MODE=canvas`, default OFF) + /// is honoured HERE TOO, and that is not cosmetic symmetry: the override + /// exists for a machine where `GraphicsInfo` reports a GPU and the mask + /// still misbehaves. If the mask draws nothing there, this file's mask + /// draws nothing either and the whole mosaic disappears — pinning the + /// tiles onto RoundedImage's canvas arm while leaving the mosaic masked + /// would defeat the override on exactly the surface it was added for. + /// Square outer corners (the same degrade the software path already takes, + /// see the header) is the correct fallback. `typeof` guard mirrors + /// RoundedImage: eager binding, and the file must stay loadable in an + /// isolated qml6 scene. + readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software + || GraphicsInfo.api === GraphicsInfo.Null + || (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt) + + function dedup(list) { + var out = [] + var seen = ({}) + var src = list || [] + for (var i = 0; i < src.length && out.length < 4; i++) { + var u = src[i] + if (!u || seen[u] === true) + continue + seen[u] = true + out.push(u) + } + return out + } + + function pathAt(index) { + var u = root.tiles[index] + return u ? (root.pathMap[u] || "") : "" + } + + /// The mosaic layout, transcribed from the deleted `onPaint` and confirmed + /// against discover/PlaylistCollage.slint line by line: `gap: 2px` (:19), + /// n==2 halves `(size - gap) / 2` (:57), n==3 `0.62` / `0.38` with + /// `(size - gap) / 2` rows (:74, :82-89), n>=4 `0.34` / `0.66` with + /// `(size - 2*gap) / 3` rows (:102-115, :121). These numbers do not + /// change. Returns an empty rect for a tile this playlist does not have. + function cellOf(index) { + var w = root.width + var h = root.height + var n = root.tiles.length + if (w <= 0 || h <= 0 || n <= 0 || index >= root.drawnTiles) + return Qt.rect(0, 0, 0, 0) + var g = root.effGap + // Playlist Manager: 1-3 covers -> one full-bleed tile, 4+ -> a 2×2 grid + // (PmCollage.slint:39-80). + if (root.layout === "pm") { + if (n < 4) + return Qt.rect(0, 0, w, h) + var cwPm = (w - g) / 2 + var chPm = (h - g) / 2 + return Qt.rect((index % 2) * (cwPm + g), + Math.floor(index / 2) * (chPm + g), cwPm, chPm) + } + if (n === 1) + return Qt.rect(0, 0, w, h) + if (n === 2) { + // Side-by-side halves. + var half = (w - g) / 2 + return index === 0 ? Qt.rect(0, 0, half, h) + : Qt.rect(half + g, 0, half, h) + } + if (n === 3) { + // One large left (62%), two stacked right (38%). + var big3 = (w - g) * 0.62 + var small3 = (w - g) * 0.38 + var half3 = (h - g) / 2 + if (index === 0) return Qt.rect(0, 0, big3, h) + if (index === 1) return Qt.rect(big3 + g, 0, small3, half3) + return Qt.rect(big3 + g, half3 + g, small3, half3) + } + // Three stacked left (34%), one large right (66%). + var col4 = (w - g) * 0.34 + var big4 = (w - g) * 0.66 + var row4 = (h - 2 * g) / 3 + if (index === 0) return Qt.rect(0, 0, col4, row4) + if (index === 1) return Qt.rect(0, row4 + g, col4, row4) + if (index === 2) return Qt.rect(0, 2 * (row4 + g), col4, row4) + return Qt.rect(col4 + g, 0, big4, h) + } + + // Placeholder surface. A Rectangle's OWN rounded fill paints correctly — + // only its children need a mask. + Rectangle { + anchors.fill: parent + radius: root.radius + color: theme.surfaceElevated + } + + // No covers at all — the list-music glyph (PlaylistCollage.slint n <= 0). + QbzIcon { + visible: root.tiles.length === 0 + name: "list-music" + width: Math.round(root.width * 0.32) + height: width + anchors.centerIn: parent + tintName: "muted" + } + + // Debounced url dispatch: one call per settled delegate, only for tiles + // whose path is still unknown. + Timer { + id: dispatchDelay + interval: 120 + onTriggered: { + var pending = [] + for (var i = 0; i < root.tiles.length; i++) { + var u = root.tiles[i] + if (!root.pathMap[u]) + pending.push(u) + } + if (pending.length > 0) + QbzShell.sidebarArtworkWindow(JSON.stringify(pending)) + } + } + + onTilesChanged: { + if (root.tiles.length > 0) + dispatchDelay.restart() + } + Component.onCompleted: { + if (root.tiles.length > 0) + dispatchDelay.restart() + } + + Connections { + target: QbzLibrary + // Shared with the feed's artKey-keyed emissions — ignore anything + // that is not one of OUR urls. + function onLibraryArtworkReady(key, path) { + if (root.pathMap[key] === path) + return + var mine = false + for (var i = 0; i < root.tiles.length; i++) { + if (root.tiles[i] === key) { + mine = true + break + } + } + if (!mine) + return + var m = root.pathMap + m[key] = path + // Rebind requires a NEW object reference. + root.pathMap = Object.assign({}, m) + } + } + + /// The rounded OUTER shape, rendered to its own FBO once (it changes only + /// with width/height/radius, never per frame). `visible: false` + layered + /// is the documented MultiEffect mask idiom — the layer renders regardless + /// of visibility, which is the whole point. + Item { + id: maskSrc + anchors.fill: parent + visible: false + // Also gated on `tiles.length` — a layer renders even when its item is + // invisible (that is the whole mask idiom), so a coverless card would + // otherwise pay two FBOs to mask a glyph that is not inside them. + layer.enabled: !root._noShaders && root.radius > 0 && root.tiles.length > 0 + layer.smooth: true + Rectangle { + anchors.fill: parent + radius: root.radius + color: "#ffffff" + } + } + + // ONE layer + ONE mask for the whole mosaic, not one per tile. `clip: true` + // bounds the outer rect; each tile's own crop overflow is scissored by + // RoundedImage itself (`clip` there covers exactly the radius-0 crop case + // this file creates — the deleted Canvas clipped per tile). + Item { + id: mosaic + anchors.fill: parent + // No clip: cellOf() tiles the parent rect exactly, and each tile is a + // RoundedImage that scissors itself when it needs to. + visible: root.tiles.length > 0 + layer.enabled: !root._noShaders && root.radius > 0 && root.tiles.length > 0 + layer.smooth: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: maskSrc + // 0.5 / 1.0 — MEASURED, see theme/RoundedImage.qml for the full + // table. Qt's mask is a smoothstep CENTRED on maskThresholdMin of + // width maskSpreadAtMin, so (0.5, 1.0) is the only pair that maps + // mask alpha 0..1 onto 0..1 and therefore the only one that + // reproduces `ctx.clip()`'s antialiased corner. (0.0, 0.0) + // collapses to step(0, alpha) and the outer corners come back + // HARD (0 antialiased pixels vs the Canvas's 292); (0.0, > 0) + // clamps everything opaque and disables the mask outright. Do not + // "restore the defaults" here. + maskThresholdMin: 0.5 + maskSpreadAtMin: 1.0 + } + + Repeater { + model: 4 + delegate: RoundedImage { + required property int index + readonly property rect cell: root.cellOf(index) + visible: index < root.drawnTiles + x: cell.x + y: cell.y + width: cell.width + height: cell.height + source: root.pathAt(index) + // Square-cornered: only the mosaic's outer shape is rounded. + // radius 0 also means this RoundedImage mounts a plain Image + // with NO layer and NO mask of its own. + radius: 0 + fit: "crop" + } + } + } +} diff --git a/crates/qbz-qt/qml/cards/RadioCard.qml b/crates/qbz-qt/qml/cards/RadioCard.qml new file mode 100644 index 000000000..36afd82b1 --- /dev/null +++ b/crates/qbz-qt/qml/cards/RadioCard.qml @@ -0,0 +1,203 @@ +// Radio / Top-Tracks card — QML port of discover/RadioCard.slint. +// +// A 200x246 tile matching AlbumCard / PlaylistCard: a 200px "display" panel +// with the seed artwork centered (126px square, nudged 8px up) and a wordmark +// ("RADIO" / "TOP TRACKS") over it, then a title + subtitle below. Hover dims +// the panel (#000000 @ 0.45, 150ms) and reveals a 44px white play disc. +// +// Numbers come from RadioCard.slint, not from taste: +// panel 200x200 radius Radius.sm · display 126x126 radius 4, centered, +// y offset -8 · wordmark 15px bold letter-spacing 2 at bottom+16 · +// play disc 44px, glyph 18px black · meta: body/medium then legal/muted, +// both elided at 200px. +// +// WHY THIS IS A CARD AND NOT A PARAMETER ON AlbumCard (track rule 5): the +// two share the 200x246 footprint and nothing else. The album card is +// artwork-first with a hover action ROW (play / favourite / pin / more), a +// quality badge, an award ribbon and a context menu keyed on an album id; +// this one has no id, no menu, no badges, and its artwork is a small inset +// "display" on a panel rather than the tile itself. Bending AlbumCard into +// it would mean disabling five features and re-laying out the art. +// +// DELTA vs the .slint, inherited from it: the Tauri original derives the +// panel colour from the artwork's dominant palette. The Slint port stands a +// neutral elevated panel in its place and says so; this follows the Slint. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Item { + id: root + + /// The line under the panel (the seed's name). + property string seedTitle: "" + /// The muted second line ("" hides it, like the .slint `if`). + property string seedSubtitle: "" + /// The wordmark over the display. + property string label: QbzSession.tr("RADIO", QbzSession.trRev) + /// file:// path to the seed artwork (already resolved by Rust). + property string artSource: "" + + /// Whole-card activation (the .slint `clicked`). + signal activated() + /// The hover play disc (the .slint `play`) — the same action in both + /// call sites today, kept separate so a future caller can split them. + signal playRequested() + + /// True while THIS card's radio is being built (QbzHome.radioPending). + /// The disc stops hiding on pointer-out and its glyph becomes a spinner: + /// a station takes a fetch + an enrich + a queue write to start, and the + /// tile's own hover affordance vanishing the moment you move the mouse is + /// what made the click read as lost. + property bool loading: false + + QbzTheme { id: theme } + + width: 200 + height: 246 + + Column { + anchors.fill: parent + spacing: 8 + + // --- display panel ------------------------------------------------ + Rectangle { + id: panel + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + // No clip: with the wordmark bounded below, nothing in this tile + // can cross its edge. + + // Centered artwork "display", nudged up 8px to leave room for + // the wordmark. + Rectangle { + width: 126 + height: 126 + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) - 8 + radius: 4 + color: theme.surfaceCard + // No clip: RoundedImage confines itself on both arms; a clip is an + // unconditional batch root, one per visible card. + RoundedImage { + anchors.fill: parent + source: root.artSource + radius: 4 + } + } + + // Wordmark over the display, bottom. + Text { + text: root.label + color: "#e6ffffff" + font.pixelSize: 15 + font.weight: theme.weightBold + font.letterSpacing: 2 + // Bounded + centred-in-box instead of centred-by-width: this + // wordmark is the ONLY thing that could leave the tile (the PT + // string "PRINCIPAIS FAIXAS" at 15px bold with 2px letter + // spacing reaches the 200px edge), and bounding it is what + // lets both of this card's clips go. Worst case is now a right + // ellipsis instead of today's chop at both edges. + width: parent.width - 24 + x: 12 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + y: parent.height - height - 16 + } + + // Hover scrim + play disc. + Rectangle { + anchors.fill: parent + color: "#000000" + opacity: (root.loading || panelArea.containsMouse || playArea.containsMouse) + ? 0.45 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + Rectangle { + id: spinDisc + width: 44 + height: 44 + radius: 22 + color: "#ffffff" + anchors.centerIn: parent + // `root.loading` pins it up: the hover-only rule is right for + // an idle tile and wrong for one that is working, because the + // pointer leaves the moment the click lands. + opacity: (root.loading || panelArea.containsMouse || playArea.containsMouse) + ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + // Spinner on the SHARED SHELL PULSE — 12 degrees per ~30 Hz + // tick, one turn a second. Never a RotationAnimation: a + // continuous animator presents the whole window at display + // rate (THE PULSE LAW, qt-frontend/2026-08-11-scenegraph- + // batches §9), and a Discover rail can hold several of these. + // The Connections is disabled unless this card is the one + // loading, so an idle rail writes nothing. + property real spinPhase: 0 + Connections { + target: QbzShell + enabled: root.loading && root.visible + function onPulseMsChanged() { + spinDisc.spinPhase = (spinDisc.spinPhase + 12) % 360 + } + } + QbzIcon { + name: root.loading ? "loader-circle" : "play-fill" + width: 18 + height: 18 + anchors.centerIn: parent + rotation: root.loading ? spinDisc.spinPhase : 0 + tintName: "black" + } + MouseArea { + id: playArea + anchors.fill: parent + hoverEnabled: true + // The disc is invisible until the panel is hovered, so it + // must not swallow the first press before it appears — and + // while the station is building there is nothing a second + // press could do but start it again. + enabled: parent.opacity > 0 && !root.loading + cursorShape: Qt.PointingHandCursor + onClicked: root.playRequested() + } + } + + MouseArea { + id: panelArea + anchors.fill: parent + // BELOW the play disc in declaration order would eat its + // clicks; the disc is declared first, so this one is last and + // therefore ON TOP. z pushes it back under. + z: -1 + hoverEnabled: true + enabled: !root.loading + cursorShape: Qt.PointingHandCursor + onClicked: root.activated() + } + } + + // --- meta ------------------------------------------------------ + Text { + width: 200 + text: root.seedTitle + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + elide: Text.ElideRight + } + Text { + visible: root.seedSubtitle !== "" + width: 200 + text: root.seedSubtitle + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } +} diff --git a/crates/qbz-qt/qml/cards/SlimCard.qml b/crates/qbz-qt/qml/cards/SlimCard.qml new file mode 100644 index 000000000..9930ee04d --- /dev/null +++ b/crates/qbz-qt/qml/cards/SlimCard.qml @@ -0,0 +1,82 @@ +// SlimCard — THE slim row card (discover/SlimCard.slint — a DISTINCT +// component in Slint, NOT an AlbumCard variant: 60px row, optional rank + +// 44px thumb + title/subtitle, single whole-row click, no overlay). The +// Slint mounts it via SlimCarousel (Home recent/popular grids, ForYou, +// ExternalReco, offline rail); the POC has it on the Home popular grid +// (SlimGrid). Promoted from HomeView in phase 21. +// +// card contract: { id, title, artist, rank, artPath } — slim rows are +// albums in the POC: click opens the album view (phase 8). + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + property var card: ({}) + + QbzTheme { id: theme } + + height: 60 + radius: theme.radiusSm + color: slArea.containsMouse ? theme.surfaceHover : "transparent" + + Row { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 12 + Text { + visible: card.rank !== "" + width: 20 + anchors.verticalCenter: parent.verticalCenter + text: card.rank + color: theme.textMuted + font.pixelSize: theme.fontBody + horizontalAlignment: Text.AlignHCenter + } + Rectangle { + width: 44 + height: 44 + radius: 4 + anchors.verticalCenter: parent.verticalCenter + color: theme.surfaceElevated + // No clip: the child RoundedImage confines itself on both arms, and + // a rectangular scissor never produced this radius. A clip is an + // unconditional batch root, so this one cost a draw call per item. + RoundedImage { + anchors.fill: parent + source: card.artPath + radius: 4 + } + } + Column { + width: parent.width - (card.rank !== "" ? 20 : 0) - 44 - 2 * 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + text: card.title + color: theme.textPrimary + font.pixelSize: theme.fontLink + font.weight: theme.weightMedium + elide: Text.ElideRight + } + Text { + width: parent.width + text: card.artist + color: theme.textMuted + font.pixelSize: 12 + elide: Text.ElideRight + } + } + } + MouseArea { + id: slArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // Phase 8: slim rows are albums — click opens the album view. + onClicked: QbzAlbum.openAlbum(card.id) + } +} diff --git a/crates/qbz-qt/qml/cards/TrackCard.qml b/crates/qbz-qt/qml/cards/TrackCard.qml new file mode 100644 index 000000000..b7b8c5a47 --- /dev/null +++ b/crates/qbz-qt/qml/cards/TrackCard.qml @@ -0,0 +1,350 @@ +// TrackCard — THE track card (discover/TrackCard.slint), promoted from +// LibraryView.LibTrackCard in phase 21: Library Tracks grid + All feed. +// 200x246: 200px cover + hover scrim 0.6, overlay row (heart / play / +// more) at y=120, optional source badge, then the meta row: title + +// "Track • Artist" (artist links out) + the icon-only quality badge. +// Body click PLAYS (the TrackCard convention, unlike album/playlist). +// +// item contract: { id, title, artist, artistId, albumId, qualityTier, +// isFavorite, source } plus the host-resolved artSource string prop. +// +// NOTE the Search most-popular track hero is NOT this card (Slint: +// primitives/SearchTrackHero.slint — a distinct 160x220 filled-chrome +// hero); the POC keeps its own SearchTrackHero variant (200x246, centered +// play, quality as text) in SearchView.qml with a justifying comment. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Rectangle { + id: root + + property var item: ({}) + // Host-resolved artwork path (the AlbumCard artSource pattern). + property string artSource: "" + // Remote cover URL for the pin payload ("" when the host has none). + property string artworkUrl: "" + // ADR-008 source glyph (local/Plex) — the Library All-feed arm. + property bool showSourceBadge: false + // Library injects its shared confirmation host. Other TrackCard surfaces + // never receive `releaseUnavailable`, so the callback remains unused. + property var confirmReleaseRemoval: null + + color: "transparent" + + QbzTheme { id: theme } + + readonly property bool overlayOn: tcArtArea.containsMouse || favBtn.hovered + || playBtn.hovered || moreBtn.hovered + // Same pulled-track contract as rows/TrackRow.qml. A catalog withdrawal + // is inert, except when the user already owns a complete offline copy. + // `cacheStatus` is a live scalar because `item` is a plain JS snapshot. + readonly property bool pulled: root.item.qobuzUnavailable === true + property int cacheStatus: root.item.cacheStatus !== undefined + ? root.item.cacheStatus : 0 + readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3 + + implicitWidth: 200 + implicitHeight: 246 + + // --- Heart state (a real QML property, never `item.isFavorite`) ------ + // Identical reasoning to rows/TrackRow.qml: `item` is a plain JS object + // copied out of `modelData`, so mutating a field on it fires no notifier + // (the glyph never changed) and reaches no model. The binding is + // re-established on every new row object so the document stays + // authoritative. + property bool favorite: root.item.isFavorite === true + onItemChanged: { + root.favorite = Qt.binding(function () { + return root.item.isFavorite === true + }) + root.cacheStatus = Qt.binding(function () { + return root.item.cacheStatus !== undefined + ? root.item.cacheStatus : 0 + }) + } + + function toggleFavorite() { + root.favorite = !root.favorite + QbzLibrary.libraryToggleFavorite("track", root.item.id) + } + + function openMenu(anchor, x, y) { + if (root.menuModel().length === 0) + return + trackMenuLoader.active = true + trackMenuLoader.item.openAtCursor(anchor, x, y) + } + function playNow() { + if (!root.pulledDead) + QbzPlayer.playTrack(root.item.id) + } + function releaseForReuse() { + if (trackMenuLoader.item) + trackMenuLoader.item.close() + trackMenuLoader.active = false + } + + // Fan-out + rollback (the shape cards/AlbumCard.qml uses for pin). + Connections { + target: QbzLibrary + function onLibraryFavoriteChanged(key, value) { + var tid = (root.item && root.item.id !== undefined) ? root.item.id : "" + if (tid !== "" && key === "track:" + tid) + root.favorite = value + } + } + Connections { + target: QbzShell + function onTrackCacheStatusChanged(trackId, status, progress) { + if (trackId === (root.item.id || "")) + root.cacheStatus = status + } + } + + Column { + spacing: 0 + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + // No clip: the child RoundedImage confines itself on both arms, and + // a rectangular scissor never produced this radius. A clip is an + // unconditional batch root, so this one cost a draw call per item. + RoundedImage { + anchors.fill: parent + source: root.artSource + radius: theme.radiusSm + } + Rectangle { + anchors.fill: parent + radius: theme.radiusSm + color: "#000000" + opacity: root.overlayOn && !root.pulledDead ? 0.6 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + // Body click PLAYS the track (TrackCard hover); right press opens + // the SAME menu as the ⋯ button. + MouseArea { + id: tcArtArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) + root.openMenu(tcArtArea, mouse.x, mouse.y) + else + root.playNow() + } + } + // Hover overlay — favorite / play / more (y=120, h=44, + // centered, spacing 12). + CardOverlayRow { + y: 120 + width: parent.width + shown: root.overlayOn && !root.pulledDead + CardOverlayButton { + id: favBtn + name: root.favorite ? "heart-filled" : "heart" + active: root.favorite + anchors.verticalCenter: parent.verticalCenter + onClicked: root.toggleFavorite() + } + CardOverlayButton { + id: playBtn + name: "play-fill" + primary: true + anchors.verticalCenter: parent.verticalCenter + onClicked: root.playNow() + } + CardOverlayButton { + id: moreBtn + name: "ellipsis" + anchors.verticalCenter: parent.verticalCenter + onClicked: function (mouse) { root.openMenu(moreBtn, mouse.x, mouse.y) } + } + } + // Source badge (All feed, show-local): bottom-right of the art. + // 24x24 rounded SQUARE (ADR-008: not a pill), 6px inset — + // discover/TrackCard.slint:177-197. Simpler than the album card's: + // ONE chip colour (#000000b3 -> Qt #b3000000) and no purchase arm. + // + // The glyph goes through controls/SourceIcon.qml, never QbzIcon: + // the Plex and Qobuz marks are MULTI-COLOUR and a tint flattens + // them to a silhouette (this card used to draw an accent-tinted + // `hard-drive` for Plex — a blue hard drive). + Rectangle { + visible: root.showSourceBadge && (root.item.source || "") !== "" + x: parent.width - width - 6 + y: parent.height - height - 6 + width: 24 + height: 24 + radius: 4 + color: "#b3000000" + SourceIcon { + // `sourceRaw` FIRST (contract §D.1): local rows carry + // `qobuz_purchase` there, and `source` folds it into + // "offline" for the source chips. Feed rows publish no + // `sourceRaw` at all, so they fall through unchanged. + kind: root.item.sourceRaw || root.item.source || "" + // .slint:191-193 — plex 16, qobuz 18, local 14. "offline" + // is the Qt word for a Qobuz download (local_rows.rs + // `badge_source`); the .slint's local-library track rows + // reach the same arm spelled "qobuz" (local_library.rs:1047). + glyphSize: 14 + plexSize: 16 + qobuzSize: 18 + // .slint:190 — #ffffff on the near-black chip. + localTint: "white" + // Explicit pixel rounding (.slint:194-195). + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + } + } + // Honest dead-card state. The scrim sits above the hover controls + // (which are not mounted visually in this state) and carries a + // translated label instead of relying on a barely perceptible dim. + Rectangle { + visible: root.pulledDead + anchors.fill: parent + radius: theme.radiusSm + color: theme.alphaTier(60) + Text { + anchors.centerIn: parent + width: parent.width - 20 + text: QbzSession.tr("Unavailable", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + Loader { + id: trackMenuLoader + active: false + sourceComponent: CardMenu { + menuWidth: 196 + entries: root.menuModel() + onPicked: function (a) { root.trackAction(a) } + } + } + } + Item { width: 1; height: 6 } + // Title / "Track • Artist" + quality badge. + Row { + width: 200 + height: 40 + spacing: theme.spacingSm + opacity: root.pulledDead ? 0.5 : 1.0 + Column { + width: parent.width - (tcQ.visible ? tcQ.width + theme.spacingSm : 0) + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + height: 20 + text: root.item.title || "" + color: tcTitleArea.containsMouse && !root.pulledDead + ? theme.accent : theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + MouseArea { + id: tcTitleArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) + root.openMenu(tcTitleArea, mouse.x, mouse.y) + else + root.playNow() + } + } + } + Text { + width: parent.width + height: 18 + text: QbzSession.tr("Track", QbzSession.trRev) + " • " + (root.item.artist || "") + color: root.item.artistId && tcArtistArea.containsMouse + ? theme.textPrimary : theme.textMuted + font.pixelSize: theme.fontLink - 1 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + MouseArea { + id: tcArtistArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.item.artistId ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (root.item.artistId) QbzArtist.openArtist(root.item.artistId) + } + } + } + QualityMini { id: tcQ; tier: root.item.qualityTier || ""; anchors.verticalCenter: parent.verticalCenter } + } + } + + // Track context-menu model (TrackCard.slint track-menu) + dispatch. + // COMPLETE against the .slint: Play · Play next · Play later (#442, end + // of the manual block) · Add to queue · Go to artist (artist-id != "") · + // Go to album (album-id != "") · favorite. The card menu is deliberately + // shorter than the row menu — the .slint card has no radio / share / + // offline / track-info rows. + function menuModel() { + var t = QbzSession.tr + var r = QbzSession.trRev + var m = [] + var releaseGone = root.item.releaseUnavailable === true + if (!root.pulledDead) { + m.push({ "label": t("Play", r), "icon": "play-fill", "action": "play" }) + m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" }) + m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" }) + m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" }) + } + if (root.item.artistId) m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" }) + if (root.item.albumId && !releaseGone) + m.push({ "label": t("Go to album", r), "icon": "disc", "action": "go-album" }) + m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r), + "icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" }) + if (releaseGone) { + m.push({ "label": t("Look for better replacement", r), + "icon": "search", "action": "find-release" }) + m.push({ "label": t("Remove all favorites from this release", r), + "icon": "trash-2", "action": "remove-release-favorites" }) + } + return m + } + function trackAction(a) { + if ((a === "play" || a === "next" || a === "later" || a === "queue") + && root.pulledDead) + return + if (a === "play") QbzPlayer.playTrack(root.item.id) + else if (a === "next") QbzPlayer.enqueueTrack(root.item.id, "next") + else if (a === "later") QbzPlayer.enqueueTrack(root.item.id, "later") + else if (a === "queue") QbzPlayer.enqueueTrack(root.item.id, "queue") + else if (a === "go-artist") QbzArtist.openArtist(root.item.artistId) + else if (a === "go-album") QbzAlbum.openAlbum(root.item.albumId) + else if (a === "favorite") root.toggleFavorite() + else if (a === "find-release") QbzTrackReplace.openRelease(JSON.stringify({ + "targetKind": "track", + "albumId": root.item.albumId || "", + "albumTitle": root.item.album || "", + "trackId": root.item.id || "", + "trackTitle": root.item.title || "", + "artist": root.item.artist || root.item.albumArtist || "", + "albumArtist": root.item.albumArtist || root.item.artist || "", + "isrc": root.item.isrc || "", + "durationSecs": root.item.durationSecs || 0 + })) + else if (a === "remove-release-favorites" + && typeof root.confirmReleaseRemoval === "function") + root.confirmReleaseRemoval(root.item) + } +} diff --git a/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml b/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml new file mode 100644 index 000000000..92df0c61e --- /dev/null +++ b/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml @@ -0,0 +1,681 @@ +// AddToMixtapeModal — the app-wide "Add to Mixtape/Collection" picker +// (myqbz/AddToMixtapeModal.slint, 553 lines). A GLOBAL singleton mounted ONCE +// in AppShell.qml (Slint mounts it the same way at AppShell.slint:824) and +// self-gated on its own document's `open` flag, so every caller only has to +// invoke `QbzMyQbzAdd.open(itemsJson)`. +// +// It carries its OWN backdrop and card — NOT the shared modal chrome (:5) — +// and TWO mutually exclusive body panels: the PICKER (search row + collection +// list + the two create chips) and the CREATE-NEW panel (name + kind + +// footer). +// +// Chrome (:209-231): scrim `#00000099` that closes on click, card +// `min(width - 80, 480) x min(height - 120, 560)` centred, r12 surface-card, +// 1px border-subtle, and a click-swallowing area over the card so a press +// inside it never reaches the scrim. The scrim carries `radius: +// theme.radiusMd` for the reason controls/DiscoverConfigModal.qml:59-75 spells +// out: QML `clip` is a rectangular scissor, so an opaque full-bleed child +// paints into the shell frame's four bezel corners and the panel reads square. +// The .slint's `drop-shadow-blur: 32px` is dropped — shadows render nothing on +// this port's software path; the 1px border carries the separation. +// +// The header's title/subtitle are PRECOMPUTED IN RUST (single item -> +// title+subtitle; bulk -> "{count} items" + "{first} + N more"), so this file +// never inspects the pending items. +// +// WHAT MAY GO WHERE (R1) IS ALSO RUST'S ANSWER, not a rule this file re-derives +// — see myqbz_add_qt.rs `payload_accepts`. The picker LIST arrives already +// filtered (a container that cannot take the payload is never a row), and the +// two creatable kinds arrive as `allowMixtape` / `allowCollection`, which gate +// the footer chips AND the create-panel radios. That second half matters: with +// the old single `restrictToMixtape` flag the chips narrowed to one but the +// create panel still showed BOTH radios, so a track payload could still be +// flipped to "Collections" and written into one. Nothing here ever renders an +// action the payload cannot perform, which is the whole point — the backend +// block is a safety net that must stay invisible. +// +// The name typed into the create panel is QML-LOCAL: the bridge takes it as an +// argument (`createAndAdd(kind, name)`) and has no name property. Same for the +// create KIND, which the two radios flip locally over the doc's value — there +// is no `setCreateKind` invokable, and re-calling `showCreate` for a radio tap +// would be a round trip for a value the submit call already carries. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // Guarded parse — a raw JSON.parse in a binding throws on the pre-publish + // frame and takes the whole overlay down (PlaylistView.qml:55-61). + readonly property var doc: { + try { + return JSON.parse(QbzMyQbzAdd.addJson) + } catch (e) { + return ({}) + } + } + readonly property bool opened: root.doc.open === true + readonly property bool loading: root.doc.loading === true + readonly property bool creating: root.doc.creating === true + readonly property bool createBusy: root.doc.createBusy === true + // R1, as Rust computed it for the pending payload. Both may be true, one + // may be true; both false cannot reach a visible modal (Rust refuses to + // open the picker when nothing accepts the payload). + readonly property bool allowMixtape: root.doc.allowMixtape === true + readonly property bool allowCollection: root.doc.allowCollection === true + readonly property string busyId: root.doc.busyId || "" + readonly property string search: root.doc.search || "" + readonly property var rows: root.doc.rows || [] + + // Local create-panel state (see the header). + property string createName: "" + property string createKindOverride: "" + readonly property string docCreateKind: root.doc.createKind || "mixtape" + readonly property string createKind: root.createKindOverride !== "" + ? root.createKindOverride : root.docCreateKind + readonly property bool createMixtape: root.createKind === "mixtape" + // R1 for the kind the panel is currently on. The forbidden radio is not + // rendered, so this can only go false on a stale frame — but Create & Add + // must not be pressable in that frame either. + readonly property bool createKindAllowed: root.createKind === "collection" + ? root.allowCollection : root.allowMixtape + // :207 — the raw non-empty check, exactly as Slint gates it (Rust trims). + readonly property bool canCreate: root.createName !== "" && !root.createBusy + && root.createKindAllowed + + // A fresh panel starts empty and follows the kind Rust was asked for; a + // closed modal must not keep a half-typed name for the next caller. + onDocCreateKindChanged: root.createKindOverride = "" + onCreatingChanged: if (root.creating) root.createName = "" + onOpenedChanged: if (!root.opened) { root.createName = ""; root.createKindOverride = "" } + + visible: root.opened + enabled: root.opened + + // --- Scrim. Declared FIRST so the card (later in source order, hence + // above it) keeps its own presses. + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#99000000" + MouseArea { + anchors.fill: parent + onClicked: QbzMyQbzAdd.close() + } + } + + Rectangle { + id: card + width: Math.min(root.width - 80, 480) + height: Math.min(root.height - 120, 560) + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + // Swallow clicks so they never reach the scrim. + MouseArea { anchors.fill: parent } + + // ======================= HEADER (always) ====================== + Item { + id: header + width: parent.width + // :235 — the row's own height plus 20 top + 16 bottom. + height: Math.max(hdrText.implicitHeight, 28) + 36 + + Column { + id: hdrText + x: 20 + y: 20 + width: Math.max(0, card.width - 40 - 28) + spacing: 2 + + Text { + text: root.t("ADD TO MIXTAPE/COLLECTION") + color: theme.textMuted + font.pixelSize: 10 + font.weight: theme.weightSemibold + font.letterSpacing: 1.5 + } + Text { + width: parent.width + text: root.doc.headerTitle || "" + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightBold + elide: Text.ElideRight + } + Text { + visible: (root.doc.headerSubtitle || "") !== "" + width: parent.width + text: root.doc.headerSubtitle || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + Item { + x: card.width - 20 - 28 + y: 20 + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: hdrCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: hdrCloseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzMyQbzAdd.close() + } + } + // Bottom border on the block's LAST pixel (:285-289). + Rectangle { + y: parent.height - 1 + width: parent.width + height: 1 + color: theme.borderSubtle + } + } + + // ======================== PICKER PANEL ======================== + Item { + id: picker + visible: !root.creating + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + // --- Search row (:295-361). The leading glyph sits OUTSIDE the + // box, which is unique to this modal — QbzLineEdit puts its + // magnifier inside — so the box is inlined here rather than + // forking the shared control. + Item { + id: searchRow + width: parent.width + height: 46 + + QbzIcon { + x: 20 + anchors.verticalCenter: searchBox.verticalCenter + name: "search" + width: 14 + height: 14 + tintName: "muted" + } + Rectangle { + id: searchBox + x: 20 + 14 + 8 + width: Math.max(0, parent.width - x - 20) + height: 43 + y: Math.round((parent.height - height) / 2) + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle + + TextInput { + id: searchInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + text: root.search + onTextEdited: QbzMyQbzAdd.search(text) + // A republish re-seeds the field while it is not being + // edited (QbzLineEdit.qml:167-172). + Binding { + target: searchInput + property: "text" + value: root.search + when: !searchInput.activeFocus + } + } + Text { + visible: searchInput.text === "" + anchors.fill: searchInput + text: root.t("Search…") + color: theme.textMuted + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + Rectangle { + y: parent.height - 1 + width: parent.width + height: 1 + color: theme.borderSubtle + } + } + + // --- Footer: the two create chips (:403-432). 60px, elevated, a + // 1px rule at its TOP (:406-409 puts it at y=0), padding 12. + Rectangle { + id: pickerFooter + anchors.bottom: parent.bottom + width: parent.width + height: 60 + color: theme.surfaceElevated + + Rectangle { + width: parent.width + height: 1 + color: theme.borderSubtle + } + Row { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + // Both chips carry `horizontal-stretch: 1`, so one fills + // the row and two split it. R1 decides how many there are: + // one chip per CREATABLE container, never zero (a payload + // nothing accepts never opened this modal). The guard on + // `chips > 0` is only so a pre-publish frame cannot divide + // by zero. + readonly property int chips: + (root.allowMixtape ? 1 : 0) + (root.allowCollection ? 1 : 0) + readonly property real chipW: + chips > 0 ? (width - (chips - 1) * 8) / chips : 0 + + FooterIconButton { + anchors.verticalCenter: parent.verticalCenter + visible: root.allowMixtape + width: visible ? parent.chipW : 0 + label: root.t("Mixtapes") + onClicked: QbzMyQbzAdd.showCreate("mixtape") + } + FooterIconButton { + // Offered only when a Collection can take the payload: + // albums / singles / EPs, from any source. Tracks and + // playlists have no Collection slot (R1). + anchors.verticalCenter: parent.verticalCenter + visible: root.allowCollection + width: visible ? parent.chipW : 0 + label: root.t("Collections") + onClicked: QbzMyQbzAdd.showCreate("collection") + } + } + } + + // --- Between the two: spinner / empty band / the list. + Item { + anchors.top: searchRow.bottom + anchors.bottom: pickerFooter.top + width: parent.width + + // Loading (:364-368): centred, 24px padding, 30px spinner. + QbzSpinner { + visible: root.loading + anchors.horizontalCenter: parent.horizontalCenter + y: 24 + size: 30 + } + // Empty (:370-381). + Text { + visible: !root.loading && root.rows.length === 0 + width: parent.width + height: 80 + text: root.search !== "" ? root.t("No matches.") + : root.t("No mixtapes or collections yet.") + color: theme.textMuted + font.pixelSize: theme.fontBody + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + // List (:384-400). + ListView { + id: pickList + visible: !root.loading && root.rows.length > 0 + // The .slint's `padding: 8` on the list layout, on all + // FOUR sides. The horizontal half is an ANCHOR margin on + // the view, never an `x` on the delegate: a vertical + // ListView writes its delegates' `x`, so a literal `x: 8` + // there is silently clobbered to 0 and the rows sat flush + // against the card's left border with a 16px gap opening + // on the right (the hover fill and the zebra band touched + // the border on one side only). The vertical half stays a + // Flickable content margin. + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + topMargin: 8 + bottomMargin: 8 + spacing: 2 + cacheBuffer: 200 + reuseItems: true + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.rows + + delegate: AddPickRow { + required property var modelData + required property int index + width: pickList.width + rowData: modelData + rowIndex: index + } + } + } + } + + // ====================== CREATE-NEW PANEL ====================== + Item { + visible: root.creating + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + Column { + id: createTop + x: 20 + y: 20 + width: Math.max(0, parent.width - 40) + spacing: 16 + + // --- NAME + Column { + width: parent.width + spacing: 8 + Text { + text: root.t("NAME") + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + font.letterSpacing: 1.5 + } + Rectangle { + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle + + TextInput { + id: nameInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + enabled: !root.createBusy + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + // LOAD-BEARING, and the exact shape `searchInput` + // above uses (:242 + its Binding). With ONLY the + // focus-gated Binding below, a re-entered create + // panel kept the previously typed name on screen + // while `createName` had already been reset to "" + // — the field never re-seeds because it keeps + // activeFocus across the hide/show (the panel is + // only `visible: false` for a frame, focus comes + // straight back), so the `when:` guard never + // fires. `canCreate` was then false with a name + // visibly in the box: "Create & Add" sat at 50 % + // with a dead MouseArea and Enter did nothing. + // Repros: chip -> type -> Back -> chip; and + // type -> close -> reopen into the create panel. + // A declarative `text:` survives user editing (the + // edit does not break the binding), and + // `onTextEdited` writes the same value straight + // back, so typing is unaffected. + text: root.createName + onTextEdited: root.createName = text + onAccepted: { + if (root.canCreate) + QbzMyQbzAdd.createAndAdd(root.createKind, root.createName) + } + Binding { + target: nameInput + property: "text" + value: root.createName + when: !nameInput.activeFocus + } + } + Text { + visible: nameInput.text === "" + anchors.fill: nameInput + // NOT translated — verbatim literals in the .slint + // (:482). + text: root.createMixtape ? "90s Cassettes" : "Jazz Library" + color: theme.textMuted + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + + // --- KIND (16px between the radios here; the Create modal + // uses 12 — CreateMyQbzModal.slint:150). + Column { + width: parent.width + spacing: 8 + Text { + text: root.t("KIND") + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + font.letterSpacing: 1.5 + } + // R1 again, and this is the half the old flag missed: a + // radio for a kind the payload cannot go into is NOT + // rendered. A Row skips invisible children, so the single + // remaining (and already selected) radio simply reads as + // the kind being created. + Row { + spacing: 16 + QbzRadioOption { + visible: root.allowMixtape + label: root.t("Mixtapes") + selected: root.createMixtape + onClicked: if (!root.createBusy) root.createKindOverride = "mixtape" + } + QbzRadioOption { + visible: root.allowCollection + label: root.t("Collections") + selected: !root.createMixtape + onClicked: if (!root.createBusy) root.createKindOverride = "collection" + } + } + } + } + + // Footer, pushed to the bottom by the .slint's vertical stretch + // spacer (:526). + Row { + anchors.right: parent.right + anchors.rightMargin: 20 + anchors.bottom: parent.bottom + anchors.bottomMargin: 20 + spacing: 8 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: root.t("Back") + btnHeight: 38 + minWidth: 0 + enabled: !root.createBusy + onClicked: QbzMyQbzAdd.createBack() + } + QbzPrimaryButton { + anchors.verticalCenter: parent.verticalCenter + label: root.t("Create & Add") + btnHeight: 38 + btnEnabled: root.canCreate + onClicked: QbzMyQbzAdd.createAndAdd(root.createKind, root.createName) + } + } + } + } + + // One collection row in the picker list (:63-158). 52px, r8, zebra on ODD + // indices, dimmed and inert while ANOTHER row is being written to. + component AddPickRow: Rectangle { + id: pickRow + // NOT `data`: Item already owns a `data` default property. + property var rowData: ({}) + property int rowIndex: 0 + readonly property bool busy: root.busyId !== "" + + height: 52 + radius: 8 + opacity: pickRow.busy ? 0.5 : 1.0 + // TrackRow.qml:200-206's exact pair: the hover is the polarity-baked + // alpha ramp (so it is visible on light themes too) with the + // pre-publish fallback, and the zebra stays the dark literal. + color: (pickHover.containsMouse && !pickRow.busy) + ? (theme.alphaTiers.length > 0 ? theme.alphaTier(8) + : (theme.isDark ? "#14ffffff" : "#14000000")) + : (pickRow.rowIndex % 2 === 1 ? "#07ffffff" : "transparent") + + MouseArea { + id: pickHover + anchors.fill: parent + hoverEnabled: true + cursorShape: pickRow.busy ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: if (!pickRow.busy) QbzMyQbzAdd.pick(pickRow.rowData.id) + } + + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 12 + + Item { + anchors.verticalCenter: parent.verticalCenter + width: 32 + height: 32 + QbzIcon { + anchors.centerIn: parent + // Rust resolves the name (myqbz_add.rs:203-209). + name: pickRow.rowData.icon === "user" ? "user" + : pickRow.rowData.icon === "library-big" ? "library-big" + : "cassette-tape" + width: 18 + height: 18 + tintName: "muted" + } + } + Column { + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 32 - 12 - kindTag.width - 12) + Text { + width: parent.width + text: pickRow.rowData.name || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Row { + spacing: 6 + Text { + anchors.verticalCenter: parent.verticalCenter + text: pickRow.rowData.meta || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + } + // "Already added" — the collection already holds EVERY + // pending item (Rust's item_exists pass). + Row { + visible: pickRow.rowData.alreadyHas === true + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: "circle-check-big" + width: 12 + height: 12 + tintName: "accent" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.t("Already added") + color: theme.accent + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + } + } + } + } + Text { + id: kindTag + anchors.verticalCenter: parent.verticalCenter + text: pickRow.rowData.kindLabel || "" + color: theme.textMuted + font.pixelSize: 11 + font.weight: theme.weightSemibold + font.letterSpacing: 1.2 + } + } + } + + // The "+ Mixtapes" / "+ Collections" footer chips (:164-201): a 38px + // labelled icon button with a CENTRED [glyph · gap · label] group. Neither + // SettingsButton (34px, 160 min width) nor QbzToolButton (30px) is this + // shape, and both call sites are in this file, so it stays local. + component FooterIconButton: Rectangle { + id: chip + property string label: "" + signal clicked() + + height: 38 + radius: theme.radiusSm + color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Row { + anchors.centerIn: parent + spacing: 6 + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: "plus" + width: 14 + height: 14 + tintName: "textPrimary" + } + Text { + anchors.verticalCenter: parent.verticalCenter + // The leading `+` is the GLYPH above, not part of the msgid. + text: chip.label + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + } + } + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: chip.clicked() + } + } +} diff --git a/crates/qbz-qt/qml/controls/BrowseGenreButton.qml b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml new file mode 100644 index 000000000..d3210c09e --- /dev/null +++ b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml @@ -0,0 +1,133 @@ +// BrowseGenreButton — the "Filter by genre" trigger +// (discover/BrowseHeaderTools.slint:102-145): accent fill + "N genres" while +// the SHARED selection is non-empty, else a surface-elevated pill reading +// "Filter by genre". +// +// Extracted from HomeView.qml's inline copy so the Home toolbar and the two +// browse pages draw the identical control. The one difference the Slint keeps +// is the height: HomeView.slint:85 is 32px, BrowseHeaderTools.slint:108 is +// 34px (the ui-control-alignment-standard toolbar size) — hence `btnHeight`. +// +// THE BADGE READS THE BRIDGE, NOT THE POPUP. `GenreFilterPopup` is declared +// LAST in every host (declaration order is z-order) and a creation-time +// binding that dereferences a not-yet-created id registers NO dependency, so +// it would never re-evaluate. The count is parsed straight off +// `QbzBridge.genreFilterJson` here, per context. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + /// "discover" | "library-all" — whose selection this button reports. + property string context: "discover" + property int btnHeight: 34 + signal clicked() + + QbzTheme { id: theme } + + readonly property var genreDoc: { + try { + return JSON.parse(QbzBridge.genreFilterJson) + } catch (e) { + return {} + } + } + readonly property int count: (genreDoc.counts || {})[root.context] || 0 + readonly property bool active: root.count > 0 + + // APPLIED-FILTERS TOOLTIP. The selected genre NAMES are already on the wire + // per context (`genre_filter_qt.rs` publishes `names`, expanded to include + // tree descendants), so this surface costs nothing but the wiring — and + // wiring it HERE gives it to all four hosts at once: Home/Discover, the two + // browse pages and Library. + // + // `extraGroups` lets a host append the filters this button knows nothing + // about (a search term, a sort, the source toggles) so one bubble answers + // the whole toolbar instead of one control. + property var extraGroups: [] + readonly property var genreNames: (genreDoc.names || {})[root.context] || [] + readonly property var summaryGroups: { + var out = [] + if (root.genreNames.length > 0) + out.push({ + group: QbzSession.tr("Genre", QbzSession.trRev), + values: root.genreNames + }) + var ex = root.extraGroups || [] + for (var i = 0; i < ex.length; i++) + out.push(ex[i]) + return out + } + + QbzFilterTip { + id: genreTip + ownerKey: "genre-" + root.context + anchor: root + groups: root.summaryGroups + } + + width: genreRow.width + height: btnHeight + radius: 6 + // Resting fill translucent under the dynamic background + // (HomeView.slint:89, BrowseHeaderTools.slint:111, PlaylistTagFilter + // .slint:77 — the same chip in three mounts). Accent and hover keep theirs. + color: root.active ? theme.accent + : genreArea.containsMouse + ? theme.surfaceHover + : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + + Row { + id: genreRow + height: parent.height + leftPadding: 12 + rightPadding: 14 + spacing: 7 + QbzIcon { + name: "list-filter" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + // Both halves of this pill take the SAME decision now. They did + // not before: the glyph said "primary" (the legacy alias of a + // literal #ffffff) while the label below said accent-text, so on + // every pale-accent theme the pill drew a black label next to a + // white glyph. discover/BrowseHeaderTools.slint:123 and :132 are + // at least self-consistent — they are BOTH #ffffff — but that + // white measures 1.70:1 on high-contrast, 1.74 on ikari and + // 1.82 on wcag-dark, under 2.6:1 on 16 of the 35 palettes, so + // the port diverges from both lines at once, by measurement. + // See theme/QbzTheme.qml, "ON AN ACCENT FILL". + tintName: root.active ? theme.accentGlyphTint : "secondary" + } + Text { + text: root.count === 0 + ? QbzSession.tr("Filter by genre", QbzSession.trRev) + : root.count === 1 + ? QbzSession.tr("1 genre", QbzSession.trRev) + : QbzSession.tr("{} genres", QbzSession.trRev).replace("{}", root.count) + // The colour twin of the glyph's tint (see above) — accent-text + // on 34 of the 35 palettes, so this is a no-op everywhere except + // where accent-text itself falls under 3:1. + color: root.active ? theme.accentGlyphColor : theme.textSecondary + font.pixelSize: 13 + font.weight: root.active ? theme.weightMedium : theme.weightRegular + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: genreArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + onEntered: genreTip.enter() + onExited: genreTip.exit() + // A click opens the genre popup right over the bubble; drop it first. + onPressed: genreTip.exit() + } +} diff --git a/crates/qbz-qt/qml/controls/CardMenu.qml b/crates/qbz-qt/qml/controls/CardMenu.qml new file mode 100644 index 000000000..e30021635 --- /dev/null +++ b/crates/qbz-qt/qml/controls/CardMenu.qml @@ -0,0 +1,109 @@ +// CardMenu — THE shared ⋯ / right-click menu surface (promoted from +// LibraryView in phase 21, brought up to primitives/ContextMenuItem.slint +// in the menu-parity round). +// +// Driven by an `entries` model; emits picked(action). Entry shape: +// +// { label, icon, action } a normal row (33px) +// { ..., enabled: false } muted row, ignores clicks +// (ContextMenuItem.slint `enabled`) +// { ..., danger: true } destructive row, red label +// (ContextMenuItem.slint `danger`) +// { sep: true } 1px group separator + 3px air +// (AlbumContextMenu.slint's +// `Rectangle { height: 1px; }`) +// +// Row metrics are ContextMenuItem.slint's: 33px, icon 15px, label 13px, +// and BOTH go text-primary on hover (the .slint tints icon + label +// together; the old POC row kept them permanently secondary). +// +// DEVIATION, deliberate: a `danger` row reddens the LABEL only. Slint +// tints the icon with Theme.danger at render time; QML tinting here is +// pre-baked per tint directory (assets/icons//) and there is no +// `danger` bake — inventing one needs a new asset set + build.rs glue, and +// a missing pre-baked file renders NOTHING at runtime. The red label +// carries the signal on its own. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +QbzContextMenu { + id: cmRoot + + property var entries: [] + signal picked(string action) + + QbzTheme { id: theme } + + Repeater { + model: cmRoot.entries + delegate: Rectangle { + id: row + required property var modelData + + readonly property bool isSep: modelData.sep === true + readonly property bool rowEnabled: modelData.enabled !== false + readonly property bool isDanger: modelData.danger === true + readonly property bool hot: cmiArea.containsMouse && rowEnabled + + width: parent ? parent.width : 0 + height: isSep ? 7 : (cmRoot.kioskHost ? 44 : 33) + radius: isSep ? 0 : 5 + color: hot ? theme.surfaceHover : "transparent" + // ContextMenuItem.slint: `opacity: enabled ? 1.0 : 0.4`. + opacity: (isSep || rowEnabled) ? 1.0 : 0.4 + + // Separator: a 1px rule centred in the 7px band. + Rectangle { + visible: row.isSep + y: 3 + width: parent.width + height: 1 + color: theme.borderSubtle + } + + Row { + visible: !row.isSep + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 8 + QbzIcon { + name: row.modelData.icon || "" + width: 15 + height: 15 + anchors.verticalCenter: parent.verticalCenter + // Host is `surfaceHover`/transparent over a theme surface, + // so the glyph tracks the sibling label (textPrimary/ + // textSecondary below) rather than a fixed white. + tintName: row.hot ? "textPrimary" : "secondary" + } + Text { + height: parent.height + width: parent.width - 23 + text: row.modelData.label || "" + color: row.isDanger ? theme.danger + : (row.hot ? theme.textPrimary : theme.textSecondary) + font.pixelSize: cmRoot.kioskHost ? 16 : 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + MouseArea { + id: cmiArea + anchors.fill: parent + // A separator and a muted row take no input at all — a + // disabled MouseArea also reports no hover, so neither + // highlights. + enabled: !row.isSep && row.rowEnabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + cmRoot.close() + cmRoot.picked(row.modelData.action) + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/CardOverlayButton.qml b/crates/qbz-qt/qml/controls/CardOverlayButton.qml new file mode 100644 index 000000000..0ba5ebd4f --- /dev/null +++ b/crates/qbz-qt/qml/controls/CardOverlayButton.qml @@ -0,0 +1,47 @@ +// CardOverlayButton — the shared card hover-overlay button (the Slint +// per-card OverlayButton copies, homologated in the POC at +// LibraryView.LibOverlayBtn and promoted to shared in phase 21): +// primary = 44px white disc (black icon), ghost = 36px translucent disc +// (1.5px border); `active` tints the icon accent (follow/heart state). + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + property string name: "" + property bool primary: false + property bool active: false + // Forwards the MouseArea event. Three cards write + // `onClicked: function (mouse) { menu.openAtCursor(btn, mouse.x, mouse.y) }` + // against this signal; while it took no argument, `mouse` was undefined and + // those context menus threw a TypeError instead of opening. + signal clicked(var mouse) + + width: primary ? 44 : 36 + height: primary ? 44 : 36 + radius: width / 2 + color: primary ? (obArea.containsMouse ? "#d6ffffff" : "#ffffff") + : (obArea.containsMouse ? "#3dffffff" : "#24ffffff") + border.width: primary ? 0 : 1.5 + border.color: "#ccffffff" + QbzIcon { + name: parent.name + width: primary ? 18 : 16 + height: primary ? 18 : 16 + anchors.centerIn: parent + // Both non-accent arms are theme-INDEPENDENT by design: this button + // only ever sits on artwork. `primary` is a solid #ffffff disc (black + // glyph), the ghost arm is #24ffffff over the artwork itself (white + // glyph). "white" is the explicit spelling of what "primary" baked. + tintName: parent.active ? "accent" : (parent.primary ? "black" : "white") + } + property alias hovered: obArea.containsMouse + MouseArea { + id: obArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: function (m) { parent.clicked(m) } + } +} diff --git a/crates/qbz-qt/qml/controls/CardOverlayRow.qml b/crates/qbz-qt/qml/controls/CardOverlayRow.qml new file mode 100644 index 000000000..f79f6f189 --- /dev/null +++ b/crates/qbz-qt/qml/controls/CardOverlayRow.qml @@ -0,0 +1,49 @@ +// CardOverlayRow — the hover action row every card family shows over its +// artwork (favorite / play / more, plus follow on artists). +// +// WHY THIS EXISTS: the four cards had each hand-rolled this row and all four +// were misaligned, each in its own way. They centred the group with a manual +// spacer Item computed as `(cardWidth - 44 - 2*36 - 2*12) / 2` — the width +// left over once you account for the primary disc, two ghost discs and the +// two gaps between them: +// +// - AlbumCard set `spacing: 12` AND kept the spacer, so the spacer itself +// got a 12px gap after it and the group sat 12px RIGHT of centre. +// - ArtistCard, PlaylistCard and TrackCard left `spacing` unset, so the +// gaps the formula had already subtracted never materialised: the discs +// touched each other and the group sat 12px LEFT of centre. +// - ArtistCard additionally measured against 190px while its art is 200. +// +// The arithmetic was never needed. A Row centred on its parent computes its +// own width from its children, whatever they are — which also means a card +// that hides one button (artists without the follow action) stays centred for +// free, where the old formula needed a conditional term to keep up. +// +// Consumers give the row a `y` and the card's width, mount their buttons as +// children, and drive `shown` from the card's hover state. + +import QtQuick + +Item { + id: root + + /// Card hover state — the row cross-fades with the scrim. + property bool shown: false + /// Gap between the discs. 12px in every card family. + property int spacing: 12 + + default property alias buttons: row.data + + // The discs are 44px (primary) and 36px (ghost); the row is as tall as + // the tallest so children can centre vertically against it. + height: 44 + opacity: shown ? 1.0 : 0.0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 150 } } + + Row { + id: row + anchors.centerIn: parent + spacing: root.spacing + } +} diff --git a/crates/qbz-qt/qml/controls/CommandBlock.qml b/crates/qbz-qt/qml/controls/CommandBlock.qml new file mode 100644 index 000000000..053cefb4d --- /dev/null +++ b/crates/qbz-qt/qml/controls/CommandBlock.qml @@ -0,0 +1,142 @@ +// CommandBlock — a copy-paste shell command / config snippet. QML port of +// crates/qbz-ui/ui/primitives/CommandBlock.slint. +// +// Surface-elevated card (radiusSm, no pills — ADR-008) with an optional +// caption, a monospace body that WRAPS, and a copy button that flips to a +// green check for 2 s. READ-ONLY: nothing here is ever executed. The height is +// content-sized, so a multi-line command grows the block; callers place it in +// a Column and never set a fixed height. +// +// THE CLIPBOARD. QML has no clipboard API of its own and this port has no +// clipboard invokable, so the copy rides an off-screen TextEdit's own +// `copy()` — the same carrier SandboxSettings.qml already uses. It is +// duplicated there rather than shared because that file's block is a +// different shape (a one-line row with a labelled "Copy" button); this one is +// the wizard's, with a caption above and an icon button pinned to the top. +// +// WHY THE ICON IS `accent` AND NOT `success`. The check glyph after a copy is +// green in the reference (a literal #3fae6a). `tintName: "success"` has NO qrc +// bake directory, so whenever the runtime tint set is unreachable the glyph +// would render as NOTHING at all rather than in a fallback colour +// (theme/QbzIcon.qml, "the one way a bake still silently lies"). The port +// already made this exact call once — WarningBanner.qml maps its `success` +// variant's glyph to the `accent` tint for the same reason — so this follows +// that precedent instead of inventing a one-glyph `success/` directory. The +// TEXT colours in this modal are unaffected: `theme.success` is #3fae6a +// verbatim, which is what the stepper dots and the read-back tick use. + +import QtQuick +import "../theme" + +Rectangle { + id: root + + property string command: "" + // Optional caption rendered above the command (e.g. what it does). + property string caption: "" + property bool justCopied: false + + QbzTheme { id: theme } + + width: parent ? parent.width : 0 + height: col.implicitHeight + 24 + radius: theme.radiusSm + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 8 + + Text { + visible: root.caption !== "" + width: parent.width + text: root.caption + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + wrapMode: Text.WordWrap + } + + Item { + width: parent.width + height: Math.max(cmdText.implicitHeight, 30) + + // Monospace body — wraps, takes the width left by the button. + // WrapAnywhere, not WordWrap: these are shell lines and long paths + // with no spaces to break on, and WordWrap would push them out of + // the card instead of folding them. + Text { + id: cmdText + anchors.left: parent.left + anchors.right: copyBtn.left + anchors.rightMargin: 10 + anchors.top: parent.top + text: root.command + color: theme.textPrimary + font.family: "monospace" + font.pixelSize: theme.fontLegal + wrapMode: Text.WrapAnywhere + } + + // Off-screen carrier: TextEdit.copy() is the only clipboard write + // QML offers without a C++ seam. + TextEdit { + id: carrier + visible: false + text: root.command + } + + // Copy button — fixed 30x30, pinned to the TOP of the row so a + // tall command does not centre it halfway down the block. + Rectangle { + id: copyBtn + anchors.right: parent.right + anchors.top: parent.top + width: 30 + height: 30 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: copyTa.containsMouse ? theme.surfaceHover : theme.surfaceCard + + QbzIcon { + anchors.centerIn: parent + width: 15 + height: 15 + name: root.justCopied ? "check" : "copy" + tintName: root.justCopied + ? "accent" + : (copyTa.containsMouse ? "textPrimary" : "muted") + } + + MouseArea { + id: copyTa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + carrier.selectAll() + carrier.copy() + carrier.deselect() + root.justCopied = true + resetTimer.restart() + } + } + } + } + } + + // Resets the "copied" check after 2s (reference: `reset-timer`, 2s). + Timer { + id: resetTimer + interval: 2000 + repeat: false + onTriggered: root.justCopied = false + } +} diff --git a/crates/qbz-qt/qml/controls/DiscMetaModal.qml b/crates/qbz-qt/qml/controls/DiscMetaModal.qml new file mode 100644 index 000000000..2c12e9e93 --- /dev/null +++ b/crates/qbz-qt/qml/controls/DiscMetaModal.qml @@ -0,0 +1,516 @@ +// "This is the wrong record" — looking a disc up by hand, for a CD or a SACD. +// +// The automatic naming is one guess from one provider, and a DiscID names the +// GEOMETRY rather than the pressing: the owner's Fear Inoculum answers with +// four releases that share a table of contents. So the list shows the fields +// that TELL THEM APART — year, country, label, catalogue number, format and +// the track count next to the disc's own — rather than a row of titles that +// all read the same. +// +// Selecting is deliberately not applying: picking a row fetches it in full and +// shows its track list, because that list is the only thing that proves a +// plausible title is the right pressing. +// +// Mounted ONCE in AppShell, like TrackReplacementModal: applying RENAMES the +// session underneath, so a modal parented into the pane would be torn down by +// its own success. +// +// Data: QbzDiscMeta.metaJson (disc_meta_qt.rs). The only local state is the +// query being typed. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + visible: doc.open === true + + readonly property var doc: { + try { return JSON.parse(QbzDiscMeta.metaJson) } catch (e) { return ({}) } + } + readonly property var rows: doc.results || [] + readonly property var preview: doc.preview || null + readonly property bool busy: doc.searching === true || doc.applying === true + + property string queryText: "" + onVisibleChanged: if (visible) queryText = root.doc.query || "" + + function tr(s) { return QbzSession.tr(s, QbzSession.trRev) } + + function submit() { + var q = root.queryText.trim() + if (q !== "" && !root.busy) + QbzDiscMeta.search(q) + } + + QbzTheme { id: theme } + + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: if (doc.applying !== true) QbzDiscMeta.close() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + id: card + anchors.centerIn: parent + width: Math.min(parent.width - 80, 720) + height: Math.min(parent.height * 0.9, 640) + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + // ---- Header --------------------------------------------------- + Item { + id: header + width: parent.width + height: 72 + Column { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: closeX.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Text { + width: parent.width + text: root.tr("Correct disc details") + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Text { + width: parent.width + // Says WHICH medium, because a disc image and the disc in + // the drive reach this modal from the same button. + text: (root.doc.kind === "sacd" + ? root.tr("SACD image") : root.tr("Audio CD")) + + " · " + (root.doc.discTrackCount || 0) + " " + + root.tr("tracks") + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + Item { + id: closeX + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: closeArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: root.doc.applying !== true + onClicked: QbzDiscMeta.close() + onContainsMouseChanged: tips.hover(containsMouse, closeX, + "meta-close", root.tr("Close without changing anything")) + } + } + } + Rectangle { y: header.height; width: parent.width; height: 1; color: theme.borderSubtle } + + // ---- Provider + query ------------------------------------------ + Item { + id: bar + y: header.height + 1 + width: parent.width + height: 67 + + Row { + id: providers + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + Repeater { + model: [ + { "id": "musicbrainz", "label": "MusicBrainz", + "tip": root.tr("Community database. Knows discs by their table of contents.") }, + { "id": "discogs", "label": "Discogs", + "tip": root.tr("Collector database. Stronger on pressings, editions and catalogue numbers.") } + ] + Rectangle { + required property var modelData + readonly property bool active: root.doc.provider === modelData.id + width: pLabel.width + 24 + height: 30 + radius: 6 + color: active ? theme.accent + : (pArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) + Text { + id: pLabel + anchors.centerIn: parent + text: parent.modelData.label + color: parent.active ? theme.onAccent : theme.textSecondary + font.pixelSize: 13 + } + MouseArea { + id: pArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !root.busy + onClicked: QbzDiscMeta.setProvider(parent.modelData.id) + onContainsMouseChanged: tips.hover(containsMouse, parent, + "prov-" + parent.modelData.id, parent.modelData.tip) + } + } + } + } + + Rectangle { + anchors.left: providers.right + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + height: 34 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: queryInput.activeFocus ? theme.accent : theme.borderSubtle + + TextInput { + id: queryInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 40 + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: !root.busy + text: root.queryText + onTextEdited: root.queryText = text + onAccepted: root.submit() + Binding { + target: queryInput + property: "text" + value: root.queryText + when: !queryInput.activeFocus + } + } + Item { + id: goBtn + width: 28 + height: 28 + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.verticalCenter: parent.verticalCenter + QbzIcon { + anchors.centerIn: parent + name: "search" + width: 15 + height: 15 + visible: root.doc.searching !== true + tintName: goArea.containsMouse ? "textPrimary" : "muted" + } + QbzSpinner { + anchors.centerIn: parent + size: 15 + visible: root.doc.searching === true + } + MouseArea { + id: goArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !root.busy + onClicked: root.submit() + onContainsMouseChanged: tips.hover(containsMouse, goBtn, + "meta-go", root.tr("Search this provider")) + } + } + } + } + Rectangle { y: header.height + bar.height + 1; width: parent.width; height: 1; color: theme.borderSubtle } + + // ---- Results (left) + preview (right) -------------------------- + Item { + y: header.height + bar.height + 2 + width: parent.width + height: card.height - header.height - bar.height - 2 - 66 + + // Empty states. Three of them, because "nothing yet", "nothing + // found" and "the provider refused" are three different things and + // only one of them means the disc is unknown. + Text { + anchors.centerIn: parent + width: parent.width - 80 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + visible: root.rows.length === 0 && root.doc.searching !== true + color: theme.textMuted + font.pixelSize: theme.fontLegal + text: root.doc.rateLimited === true + ? root.tr("The provider is rate-limiting right now. Try again in a moment.") + : (root.doc.searched === true + ? root.tr("No releases matched. Try fewer words, or the other provider.") + : root.tr("Search for the release this disc actually is.")) + } + + ListView { + id: list + width: root.preview ? parent.width * 0.52 : parent.width + height: parent.height + clip: true + model: root.rows + boundsBehavior: Flickable.StopAtBounds + delegate: Rectangle { + id: row + required property var modelData + readonly property bool chosen: root.doc.selectedId === modelData.id + width: list.width + height: 62 + color: chosen ? theme.surfaceHover + : (rowArea.containsMouse ? theme.surfaceElevatedA50 : "transparent") + + Column { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: rowSpin.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + text: row.modelData.title + color: theme.textPrimary + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + Text { + width: parent.width + text: row.modelData.artist + color: theme.textSecondary + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + // The line that actually distinguishes four pressings + // of one album. A track count that disagrees with the + // disc is called out rather than merely shown. + Text { + width: parent.width + text: { + var bits = [] + if (row.modelData.year) bits.push(row.modelData.year) + if (row.modelData.country) bits.push(row.modelData.country) + if (row.modelData.format) bits.push(row.modelData.format) + if (row.modelData.label) bits.push(row.modelData.label) + if (row.modelData.catalogNumber) bits.push(row.modelData.catalogNumber) + if (row.modelData.trackCount > 0) { + var n = row.modelData.trackCount + bits.push(n === (root.doc.discTrackCount || 0) + ? n + " " + root.tr("tracks") + : n + " " + root.tr("tracks") + " ⚠") + } + return bits.join(" · ") + } + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + QbzSpinner { + id: rowSpin + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + size: 15 + visible: root.doc.loadingId === row.modelData.id + } + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: root.doc.applying !== true + onClicked: QbzDiscMeta.select(row.modelData.id) + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: theme.borderSubtle + } + } + } + + Rectangle { + visible: root.preview !== null + x: list.width + width: 1 + height: parent.height + color: theme.borderSubtle + } + + // The track list of the chosen candidate — the evidence. + Flickable { + visible: root.preview !== null + x: list.width + 1 + width: parent.width - list.width - 1 + height: parent.height + contentWidth: width + contentHeight: prevCol.height + 24 + clip: true + boundsBehavior: Flickable.StopAtBounds + Column { + id: prevCol + x: 16 + y: 12 + width: parent.width - 32 + spacing: 6 + Text { + width: parent.width + text: root.preview ? root.preview.title : "" + color: theme.textPrimary + font.pixelSize: theme.fontLink + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Text { + width: parent.width + text: root.preview + ? (root.preview.artist + + (root.preview.year ? " · " + root.preview.year : "")) + : "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + Item { width: 1; height: 4 } + Repeater { + model: root.preview ? root.preview.tracks : [] + Row { + required property var modelData + width: prevCol.width + spacing: 8 + Text { + width: 22 + text: parent.modelData.number + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignRight + } + Text { + width: parent.width - 30 + text: parent.modelData.title + color: theme.textSecondary + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + } + } + } + } + + // ---- Footer ---------------------------------------------------- + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: footerRow.top + anchors.bottomMargin: 16 + height: 1 + color: theme.borderSubtle + } + // The escape hatch, only where it can do something. Without it a bad + // correction is permanent, which would make people afraid to use the + // button at all. + Item { + id: forgetBtn + visible: root.doc.hasCorrection === true + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: footerRow.verticalCenter + width: forgetText.width + height: 28 + Text { + id: forgetText + anchors.verticalCenter: parent.verticalCenter + text: root.tr("Remove my correction") + color: forgetArea.containsMouse ? theme.textPrimary : theme.textMuted + font.pixelSize: theme.fontLegal + } + MouseArea { + id: forgetArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzDiscMeta.forget() + onContainsMouseChanged: tips.hover(containsMouse, forgetBtn, "meta-forget", + root.tr("Forget the saved details for this disc and look it up fresh next time")) + } + } + Row { + id: footerRow + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.bottom: parent.bottom + anchors.bottomMargin: 16 + spacing: 12 + + SettingsButton { + id: cancelBtn + anchors.verticalCenter: parent.verticalCenter + text: root.tr("Cancel") + btnHeight: 34 + minWidth: 0 + enabled: root.doc.applying !== true + onClicked: QbzDiscMeta.close() + HoverHandler { + onHoveredChanged: tips.hover(hovered, cancelBtn, "meta-cancel", + root.tr("Close without changing anything")) + } + } + QbzPrimaryButton { + id: applyBtn + anchors.verticalCenter: parent.verticalCenter + btnHeight: 34 + label: root.doc.applying === true + ? root.tr("Applying...") : root.tr("Use these details") + btnEnabled: root.doc.applying !== true && root.preview !== null + onClicked: QbzDiscMeta.apply() + // A HoverHandler rather than a MouseArea: this control already + // has one, and stacking a second would eat the click. + HoverHandler { + onHoveredChanged: tips.hover(hovered, applyBtn, "meta-apply", + root.tr("Rename this disc and remember it for next time")) + } + } + } + } + + QbzTooltip { + id: tips + anchors.fill: parent + z: 900 + } +} diff --git a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml new file mode 100644 index 000000000..12dfffeea --- /dev/null +++ b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml @@ -0,0 +1,546 @@ +// DiscoverConfigModal — QML port of +// crates/qbz-ui/ui/discover/DiscoverConfigModal.slint (the per-tab +// "Customize" modal opened by the Discover toolbar gear): reorder + +// show/hide the active tab's rails, with a perf-warning banner, an +// enabled/total count and a Reset-to-defaults footer. +// +// No Save button — every toggle / move / reset persists live to +// discover_prefs.db and re-renders the three tabs from the cached section +// data (src/discover_config_qt.rs), so the list stays authoritative. +// +// Mount as the LAST child of the view root (declaration order IS z-order) +// with `anchors.fill: parent`; ADR-009's ">= 3000" is satisfied structurally +// AND by the explicit z below. +// +// The modal has TWO modes, and the tab decides which: +// * the three section tabs (home / editorPicks / forYou) get the reorder +// list, its perf banner, the enabled/total count and the reset footer; +// * Recommendations has no configurable sections, so all of that is hidden +// and it gets the explainer + cache-window select + "Refresh now" instead. +// 1:1 with the reference, which gates the same six blocks on the same +// condition (DiscoverConfigModal.slint:187/213/221/250/261/326). +// +// (The note that used to sit here said the external reco engine was "not +// ported" and the gear was disabled on that tab as a result. The engine has +// been ported for a long time — src/recommendations_qt.rs — so the note was a +// fossil describing a state of the world that had stopped being true, and the +// disabled gear it justified was the only thing keeping the window a user +// chose in one build from being changeable in this one.) + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Item { + id: root + + // "home" | "editorPicks" | "forYou" — the tab whose sections are edited. + property string tab: "home" + readonly property bool opened: root._open + property bool _open: false + + QbzTheme { id: theme } + + readonly property var doc: { + try { + return JSON.parse(QbzBridge.discoverConfigJson) + } catch (e) { + return {} + } + } + // Only render rows once Rust has published THIS tab's payload. + readonly property bool mine: (doc.tab || "") === root.tab + readonly property var rows: root.mine ? (doc.rows || []) : [] + /// Items per rail when the user has not chosen — the first select entry's + /// number. From Rust so it cannot drift from the cap that is applied. + readonly property int defaultRailSize: doc.defaultRailSize || 10 + + function open(forTab) { + root.tab = forTab + QbzBridge.discoverConfigOpen(forTab) + root._open = true + } + function close() { + root._open = false + } + + visible: root._open + enabled: root._open + z: 3100 + + // Scrim — declared FIRST so the panel below it in source order (and thus + // above it in z) keeps its own presses. + // + // `radius` is load-bearing, not decoration. This Item fills the VIEW + // root, which fills AppShell's rounded content frame, and Qt Quick's + // `clip` is a rectangular scissor that does not follow `radius` — so an + // opaque full-bleed child paints straight into the frame's four bezel + // corners and the panel reads square. AppShell mounts four corner nubs + // that cover exactly this, but ONLY while the ambient background is off + // (with ambient on, the gutter must show the ambient field through the + // corners, so an opaque nub would be the regression there). This scrim at + // 75% opacity was the one remaining offender in the ambient-on case: every + // view root already rounds its own fill for precisely this reason + // (HomeView.qml:radius 12 and its comment), the header atmosphere is + // suppressed under ambient, and window-level overlays are supposed to + // cover the corners. Rounding the scrim's own fill fixes it in BOTH + // ambient states and needs no colour guessing — the corners keep showing + // whatever is genuinely behind them. + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: root.close() + // Eat the WHEEL as well as the click. A MouseArea consumes presses + // but lets wheel events fall straight through, so with this open + // the pointer still scrolled the page underneath — and over the + // panel it moved the panel's own list AND the page behind it at the + // same time. These overlays are plain Items, not Popups, so there + // is no modal grab doing it for them: each has to stop the wheel at + // its own surface. + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + id: panel + width: Math.min(root.width - 80, 520) + height: Math.min(panelCol.implicitHeight + 40, root.height * 0.78) + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + // Swallow clicks so they never reach the scrim — and the wheel, so a + // scroll that starts outside the section list (the title, the banner, + // the footer) does not reach the page behind the modal. + MouseArea { + anchors.fill: parent + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + // NOT `body`: WarningBanner below has a `body` PROPERTY, and an + // id that shadows a child's property name is a trap waiting for + // the next reader. + id: panelCol + x: 20 + y: 20 + width: parent.width - 40 + spacing: 14 + + // --- Title + close -------------------------------------------- + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Customize", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + } + Rectangle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + radius: 6 + color: cfgCloseArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: cfgCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: cfgCloseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.close() + } + } + } + + // --- Perf-warning banner (the shared WarningBanner replica) ---- + // This and the next three blocks are section-config only. + WarningBanner { + visible: root.tab !== "recommendations" + width: parent.width + variant: "warning" + body: QbzSession.tr("Enabling more sections increases load time.", QbzSession.trRev) + } + + // --- Count line ----------------------------------------------- + Text { + visible: root.tab !== "recommendations" + width: parent.width + text: QbzSession.tr("{} of {} enabled", QbzSession.trRev) + .replace("{}", root.mine ? (doc.enabled || 0) : 0) + .replace("{}", root.mine ? (doc.total || 0) : 0) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + + // --- Section list --------------------------------------------- + // Explicit, content-derived height capped to what is left of the + // panel; the Flickable scrolls past that. + // + // The subtrahend is the FIXED-CHROME BUDGET (title + banner + count + // + footer + margins), and it is load-bearing: the panel clips + // (`clip: true` above), so a row added over this list without + // raising the number does not overflow visibly — the last sections + // and the Reset footer simply stop existing. + Item { + visible: root.tab !== "recommendations" + width: parent.width + height: Math.max(0, Math.min(listCol.height, root.height * 0.78 - 260)) + clip: true + Flickable { + id: listFlick + anchors.fill: parent + contentWidth: width + contentHeight: listCol.height + boundsBehavior: Flickable.StopAtBounds + Column { + id: listCol + width: listFlick.width + spacing: 2 + Repeater { + model: root.rows + delegate: Rectangle { + id: cfgRow + required property var modelData + required property int index + width: listCol.width + height: 40 + radius: theme.radiusSm + color: cfgRowArea.containsMouse ? theme.surfaceHover : "transparent" + + // Whole-row tap toggles (primary affordance). + // Declared FIRST so the checkbox and the two + // reorder buttons below sit ON TOP of it and + // keep their own presses. + MouseArea { + id: cfgRowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.discoverToggleSection(root.tab, cfgRow.modelData.id) + } + + Row { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 6 + spacing: 12 + + QbzCheckbox { + anchors.verticalCenter: parent.verticalCenter + checked: cfgRow.modelData.enabled + onToggled: QbzBridge.discoverToggleSection(root.tab, cfgRow.modelData.id) + } + Text { + width: parent.width - 18 - 124 - 28 - 28 - 4 * 12 + height: parent.height + text: cfgRow.modelData.label + color: cfgRow.modelData.enabled ? theme.textPrimary : theme.textMuted + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + // How many items THIS rail shows. Per rail + // and not one number for the page: that is + // the shape Tauri had + // (`HomeSettingsModal.svelte`), and it is + // the point of the feature — a 25-wide + // "New Releases" next to a 10-wide + // "Recently Played" is why anyone opens + // this. + // + // Option ORDER is the contract with + // RAIL_SIZE_PRESETS in + // qbz-app/src/settings/discover_prefs.rs. + // + // The first entry IS the default, and it + // says the number. It used to read "All", + // which was a lie twice over: an "all" in + // Qobuz terms is what the View-all page + // holds, and /discover/index hands each rail + // a different count anyway (measured: + // new_releases 26, press_awards 10). The + // default is a real uniform cap of ten now, + // so the label is simply true — and ten + // stops being a separate option because it + // IS the first one. + // + // The number comes from Rust + // (`DEFAULT_RAIL_SIZE`, on the doc) rather + // than being typed here, so the label and + // the cap cannot drift apart. + QbzSelect { + // 124/150, not 82/110: the widest entry + // is "Default (10)" and at 82 both the + // closed control and the popup elided + // it to "Defaul…", which is the one + // string in the list that has to be + // readable. Sized for the longest + // TRANSLATED label, not the English one + // — "Predeterminado (10)" is half again + // as long, hence the elide staying as + // the backstop rather than a tighter + // fit. + width: 124 + anchors.verticalCenter: parent.verticalCenter + sm: true + menuWidth: 150 + enabled: cfgRow.modelData.enabled + options: [ + QbzSession.tr("Default ({})", QbzSession.trRev) + .replace("{}", root.defaultRailSize), + "15", + "20", + "25", + ] + currentIndex: cfgRow.modelData.sizeIndex || 0 + onSelected: function (i) { + QbzHome.discoverSetRailSize(root.tab, cfgRow.modelData.id, i) + } + } + ReorderButton { + anchors.verticalCenter: parent.verticalCenter + glyph: "chevron-up" + buttonEnabled: cfgRow.index > 0 + onClicked: QbzBridge.discoverMoveSection(root.tab, cfgRow.modelData.id, -1) + } + ReorderButton { + anchors.verticalCenter: parent.verticalCenter + glyph: "chevron-down" + buttonEnabled: cfgRow.index < root.rows.length - 1 + onClicked: QbzBridge.discoverMoveSection(root.tab, cfgRow.modelData.id, 1) + } + } + } + } + } + } + } + + // Empty state — a tab whose sections have not been fetched yet. + // NOT on Recommendations: `DiscoveryTab::from_key` has no arm for + // it (discover_prefs.rs:44), so its row list is empty BY DESIGN and + // "No sections to configure yet" would be a permanent lie there. + Text { + visible: root.rows.length === 0 && root.tab !== "recommendations" + width: parent.width + height: 34 + text: QbzSession.tr("No sections to configure yet.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + } + + // --- Recommendations arm: explainer + cache window + refresh --- + // Recommendations has no sections to order, so the gear opens on + // THIS instead. Both controls write through QbzHome: + // `recoSetCacheTtlIndex` persists to the shared per-user discover + // prefs (the same file the Slint build reads, so a window chosen + // in either is the window both honour), and `recoRefreshNow` + // rebuilds every row past the results blob. + Text { + visible: root.tab === "recommendations" + width: parent.width + text: QbzSession.tr("QBZ uses your Last.fm and ListenBrainz account data when they're connected (if they aren't, we recommend connecting them to enrich this section). Here you set how often that data is fetched — it defaults to 48 hours, or you can request an update right now.", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + + Item { + visible: root.tab === "recommendations" + width: parent.width + height: 34 + + Text { + id: ttlLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Cache window", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + QbzSelect { + anchors.left: ttlLabel.right + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + sm: true + menuWidth: 140 + // Option ORDER is the contract with TTL_HOURS in + // src/recommendations_qt.rs — the bridge publishes an + // index, not hours, so reordering these silently changes + // what every stored value means. + options: [ + QbzSession.tr("24 hours", QbzSession.trRev), + QbzSession.tr("36 hours", QbzSession.trRev), + QbzSession.tr("48 hours", QbzSession.trRev), + QbzSession.tr("72 hours", QbzSession.trRev), + ] + currentIndex: QbzHome.recoCacheTtlIndex + onSelected: function (i) { QbzHome.recoSetCacheTtlIndex(i) } + } + + // Pinned right, like the reference's stretch spacer. + Rectangle { + id: refreshBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: refreshRow.width + 24 + height: 34 + radius: theme.radiusSm + color: refreshArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + Row { + id: refreshRow + anchors.centerIn: parent + spacing: 8 + QbzIcon { + name: "refresh-cw" + width: 15 + height: 15 + anchors.verticalCenter: parent.verticalCenter + tintName: "secondary" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Refresh now", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + } + } + MouseArea { + id: refreshArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzHome.recoRefreshNow() + } + } + } + + // --- Footer: Refresh content + Reset to defaults --------------- + Row { + visible: root.tab !== "recommendations" + spacing: 8 + + // REFRESH CONTENT. The discover index is fetched ONCE per shell + // entry — `main.rs` latches `HOME_LOADED` and never calls + // `reload_home` again — so a session shows the page it opened + // with: nothing new from the catalogue, and none of the plays + // made since, because the recently-played rails are built from + // the local history at the same moment. Everything else in this + // modal re-renders from the cached candidates, which is exactly + // what a user cannot use to get fresh ones. + // + // The invokable already existed for the error-state retry + // (HomeView.qml); this is the door for it. + FooterButton { + glyph: "refresh-cw" + label: QbzSession.tr("Refresh content", QbzSession.trRev) + onClicked: QbzHome.reloadHome() + } + FooterButton { + glyph: "rotate-ccw" + label: QbzSession.tr("Reset to defaults", QbzSession.trRev) + onClicked: QbzBridge.discoverResetTab(root.tab) + } + } + } + } + + // The footer's outlined 34px button. Extracted when the footer grew a + // second one — the Reset button's markup copied verbatim, so the pair + // cannot drift. + component FooterButton: Rectangle { + id: footerBtn + property string glyph: "" + property string label: "" + signal clicked() + + width: footerRow.width + 24 + height: 34 + radius: theme.radiusSm + color: footerArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Row { + id: footerRow + anchors.centerIn: parent + spacing: 8 + QbzIcon { + name: footerBtn.glyph + width: 15 + height: 15 + anchors.verticalCenter: parent.verticalCenter + tintName: "secondary" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: footerBtn.label + color: theme.textPrimary + font.pixelSize: theme.fontBody + } + } + MouseArea { + id: footerArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: footerBtn.clicked() + } + } + + // 28x28 ghost reorder button (the Slint ReorderButton): dims and drops + // the pointer cursor at the list boundary. + component ReorderButton: Rectangle { + id: reorderRoot + property string glyph: "chevron-up" + property bool buttonEnabled: true + signal clicked() + + width: 28 + height: 28 + radius: theme.radiusSm + opacity: reorderRoot.buttonEnabled ? 1.0 : 0.3 + color: (reorderRoot.buttonEnabled && reorderArea.containsMouse) + ? theme.surfaceHover : "transparent" + + QbzIcon { + anchors.centerIn: parent + name: reorderRoot.glyph + width: 16 + height: 16 + tintName: "secondary" + } + MouseArea { + id: reorderArea + anchors.fill: parent + enabled: reorderRoot.buttonEnabled + hoverEnabled: reorderRoot.buttonEnabled + cursorShape: reorderRoot.buttonEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: reorderRoot.clicked() + } + } +} diff --git a/crates/qbz-qt/qml/controls/FolderEditPanel.qml b/crates/qbz-qt/qml/controls/FolderEditPanel.qml new file mode 100644 index 000000000..b95868d20 --- /dev/null +++ b/crates/qbz-qt/qml/controls/FolderEditPanel.qml @@ -0,0 +1,391 @@ +// FolderEditPanel — the BODY of the folder editor +// (primitives/FolderEditModal.slint:66-266): name field, the seven icon-preset +// tiles plus the custom-image tile, the eleven colour swatches, the "Hide from +// sidebar" row and the Delete / Save footer. +// +// The chrome around it — scrim, card, title, close X, Escape scope and the +// delete confirm — belongs to FolderModals.qml, which mounts this. The split +// is declared up front (rule 2): 04 §5.2's single ~380-line FolderModals is +// rejected because the two panels plus the confirm plus the drafts land nearer +// 600, and the port's precedents for "one file, several panels" are +// MyQbzModals.qml at 523 lines for three SIMPLER panels and +// PlaylistPickerModal.qml at 685. +// +// ── THE DRAFTS LIVE HERE ─────────────────────────────────────────────────── +// Name, preset, colour and the hidden switch are QML-LOCAL and reach Rust only +// as arguments to `editSave(name, preset, color, hidden)`. That is the port's +// standing rule (MyQbzModals.qml's header: "TEXT STATE IS QML-LOCAL ... every +// submit invokable takes the value as an argument") and it is why the bridge +// has no `selectPreset` / `selectColor` / `toggleHidden` invokables: a colour +// click has no business making a round trip. +// +// Preset selection is the ONE draft write with a Rust side effect — it also +// calls `QbzFolderEdit.clearImage()`, because picking a preset means dropping +// the custom image, and the image is Rust-owned (the file dialog is native). +// That call is also what makes the reference's declared-but-never-called +// `clear-image` callback (main.rs:5611-5619) live. +// +// ── SEEDING ──────────────────────────────────────────────────────────────── +// `doc.name` / `iconPreset` / `iconColor` / `isHidden` are SEEDS, consumed +// once on the open transition. They are NOT bound: Rust republishes editJson +// on every `busy` flip and on every image pick, and a bound field would throw +// away whatever the user had typed each time. `hasCustomImage` / +// `customImagePath` / `busy` ARE read live, because Rust owns them. +// +// ── REUSED, NOT REDRAWN ──────────────────────────────────────────────────── +// QbzLineEdit the name field (its width MUST be set — the plain arm is +// a fixed 240, where the Slint LineEdit stretches). +// QbzToggle the hidden row. The reference uses QbzToggle too +// (FolderEditModal.slint:214). A second switch is exactly +// the fork rule 5 forbids. +// QbzPrimaryButton the Save button. The reference's `#ffffffd6` hover on an +// accent fill and its hardcoded `#ffffff` label are NOT +// copied — that white-wash is the white-button-on-artwork +// treatment and it washes out on an accent fill (illegible +// on the 11 light themes). The sibling +// LibFolderEditModal.slint:376 already uses accent-hover. +// Contract §10 Q5 records the exact literals if parity is +// ever wanted. +// PmFolderIcon NOT reused for the preset tiles: a tile is a +// surface-elevated square with a theme-tinted 17px glyph, +// where PmFolderIcon is a saturated colour tile with a +// fixed-white glyph at half its own size. Only the id -> +// glyph chain is common, and it is seven ternary arms; a +// shared .js would need its own build.rs entry for that. +// Keep the two in step — PmFolderIcon.qml carries the +// same chain. +// Delete button hand-drawn, because it is a GHOST danger button and the +// port's filled danger control (QbzPrimaryButton +// `destructive`) is a different affordance. SettingsButton +// has `danger`, but it is a settings-row control with its +// own leading-glyph geometry. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + /// The parsed QbzFolderEdit.editJson (§4.5). The HOST parses it once; a + /// second `JSON.parse` in here would double the work on every republish. + property var doc: ({}) + /// The panel never raises the confirm itself — FolderModals owns it, so + /// the confirm can stack over this panel with its own z (D21). + signal deleteRequested() + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // --- Drafts ----------------------------------------------------------- + property string draftName: "" + property string draftPreset: "folder" + property string draftColor: "" + property bool draftHidden: false + + readonly property bool busy: root.doc.busy === true + readonly property bool isCreate: (root.doc.id || "") === "" + readonly property bool canSave: root.draftName.trim() !== "" && !root.busy + + /// Consume the seeds. Called by the host on the open transition — never on + /// a plain republish. + function seed() { + root.draftName = root.doc.name || "" + root.draftPreset = root.doc.iconPreset || "folder" + root.draftColor = root.doc.iconColor || "" + root.draftHidden = root.doc.isHidden === true + } + + /// Focus the name field. Split out so the host can call it AFTER the panel + /// is actually visible — an invisible item cannot take active focus. + function focusName() { nameField.focusField() } + + /// The id -> glyph chain, shared with PmFolderIcon.qml. "folder" is not a + /// case in the reference's chain at all and falls through to the default + /// arm, which is also the unknown-id arm; "headphones" draws audio-lines. + /// Both are upstream intent (§4.5) — do not "fix" either. + function presetGlyph(id) { + return id === "heart" ? "heart" + : id === "star" ? "star" + : id === "music" ? "music" + : id === "disc" ? "disc" + : id === "library" ? "library" + : id === "headphones" ? "audio-lines" + : "folder" + } + + implicitHeight: col.implicitHeight + + Column { + id: col + width: parent.width + spacing: 16 + + // ================= Name ========================================= + Column { + width: parent.width + spacing: 6 + Text { + text: root.t("Folder name") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + QbzLineEdit { + id: nameField + // MUST be explicit: the plain arm is a fixed `width: 240` + // (QbzLineEdit.qml:62), where the Slint LineEdit stretches to + // the panel. + width: parent.width + text: root.draftName + placeholder: root.t("Enter folder name") + onEdited: function (value) { root.draftName = value } + // `accepted`, NEVER `committed`: committed also fires on blur, + // and closing the modal blurs the field, which would submit a + // second time (MyQbzModals.qml:34-36 documents the incident). + onAccepted: function (value) { + root.draftName = value + if (root.draftName.trim() !== "" && !root.busy) + QbzFolderEdit.editSave(root.draftName, root.draftPreset, + root.draftColor, root.draftHidden) + } + } + } + + // ================= Icon ========================================= + Column { + width: parent.width + spacing: 6 + Text { + text: root.t("Icon") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + Row { + spacing: 8 + + Repeater { + model: root.doc.presets || [] + delegate: Rectangle { + id: presetTile + required property var modelData + // A preset is only "the" selection while there is no + // custom image — the image wins, exactly as in the + // reference (`custom-image-path == "" && ...`). + readonly property bool active: + root.doc.hasCustomImage !== true + && root.draftPreset === presetTile.modelData + width: 40 + height: 40 + radius: 8 + color: presetTile.active + ? theme.accent + : (presetArea.containsMouse ? theme.surfaceHover + : theme.surfaceElevated) + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: root.presetGlyph(presetTile.modelData) + // On an accent fill the port goes through the + // measured on-accent selector, not a hardcoded + // #ffffff (QbzTheme.qml "ON AN ACCENT FILL"). + tintName: presetTile.active ? theme.accentGlyphTint : "secondary" + } + MouseArea { + id: presetArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.draftPreset = presetTile.modelData + // The image is RUST-owned (native dialog), so + // dropping it is the one draft write that has + // to make a round trip. + QbzFolderEdit.clearImage() + } + } + } + } + + // --- The custom-image tile. + Rectangle { + id: imageTile + readonly property bool active: root.doc.hasCustomImage === true + width: 40 + height: 40 + radius: 8 + color: imageTile.active + ? theme.accent + : (imageArea.containsMouse ? theme.surfaceHover + : theme.surfaceElevated) + // No `clip: true` — it is a rectangular scissor and does + // not follow `radius`. RoundedImage masks its own corners. + RoundedImage { + anchors.fill: parent + visible: imageTile.active + source: imageTile.active ? (root.doc.customImagePath || "") : "" + radius: 8 + fit: "crop" + } + QbzIcon { + anchors.centerIn: parent + visible: !imageTile.active + width: 17 + height: 17 + name: "image-plus" + tintName: "secondary" + } + MouseArea { + id: imageArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzFolderEdit.pickImage() + } + } + } + } + + // ================= Colour ======================================= + Column { + width: parent.width + spacing: 6 + Text { + text: root.t("Color") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + // ELEVEN swatches, ONE row — 11 * 28 + 10 * 8 = 388 px. The + // .slint comment at :167 saying "Two rows" is wrong about its own + // grid. The wrap arithmetic is kept anyway, so a longer constant + // list wraps instead of overflowing the card. + Item { + id: swatchGrid + readonly property int columns: 11 + readonly property int sw: 28 + readonly property int gap: 8 + readonly property int count: (root.doc.swatches || []).length + readonly property int rows: Math.ceil(swatchGrid.count / swatchGrid.columns) + width: parent.width + height: swatchGrid.rows * swatchGrid.sw + + Math.max(0, swatchGrid.rows - 1) * swatchGrid.gap + + Repeater { + model: root.doc.swatches || [] + delegate: Rectangle { + id: swatch + required property int index + required property var modelData + readonly property string value: swatch.modelData.value || "" + readonly property bool active: root.draftColor === swatch.value + x: (swatch.index % swatchGrid.columns) * (swatchGrid.sw + swatchGrid.gap) + y: Math.floor(swatch.index / swatchGrid.columns) + * (swatchGrid.sw + swatchGrid.gap) + width: swatchGrid.sw + height: swatchGrid.sw + radius: swatchGrid.sw / 2 + color: swatch.modelData.isAccent === true ? theme.accent : swatch.value + // A fixed white ring: the host is a saturated swatch + // under every theme, so a theme-following border would + // vanish on half of them. + border.width: swatch.active ? 2 : 0 + border.color: "#ffffff" + QbzIcon { + anchors.centerIn: parent + visible: swatch.active + width: 14 + height: 14 + name: "check" + tintName: "white" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + // "" IS a value ("use the theme accent") and it + // reaches the DB as "" — see contract D24. + onClicked: root.draftColor = swatch.value + } + } + } + } + } + + // ================= Hidden ======================================= + Item { + width: parent.width + height: 22 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 50) + text: root.t("Hide from sidebar") + color: theme.textSecondary + font.pixelSize: theme.fontBody + elide: Text.ElideRight + } + QbzToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: root.draftHidden + enabled: !root.busy + // QbzToggle never self-flips; the owner of the state does. + onToggled: function (value) { root.draftHidden = value } + } + } + + // ================= Footer ======================================= + Item { + width: parent.width + height: 36 + + // Delete — edit mode only. A ghost danger button: fill on hover + // only, 1px danger border, danger label. theme.danger / + // theme.dangerHover ARE the reference's #ef4444 / #ef444433 under + // the default theme (the engine defaults danger to rgb(ef,44,44) + // and derives danger_hover as a 0.2 tint, i.e. alpha 0x33) and the + // correct colours under the other 35. + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + visible: !root.isCreate + width: delLabel.implicitWidth + 28 + height: 36 + radius: 8 + color: (delArea.containsMouse && !root.busy) ? theme.dangerHover : "transparent" + border.width: 1 + border.color: theme.danger + opacity: root.busy ? 0.5 : 1.0 + Text { + id: delLabel + anchors.centerIn: parent + text: root.t("Delete") + color: theme.danger + font.pixelSize: 15 + font.weight: theme.weightMedium + } + MouseArea { + id: delArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.busy ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: if (!root.busy) root.deleteRequested() + } + } + + // Save. The label stays "Save" in create mode too — that is the + // reference (FolderEditModal.slint:246); the small sidebar create + // panel is the one that says "Create". + QbzPrimaryButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + label: root.t("Save") + btnHeight: 36 + labelSize: 15 + btnEnabled: root.canSave + onClicked: QbzFolderEdit.editSave(root.draftName, root.draftPreset, + root.draftColor, root.draftHidden) + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/FolderModals.qml b/crates/qbz-qt/qml/controls/FolderModals.qml new file mode 100644 index 000000000..b25432249 --- /dev/null +++ b/crates/qbz-qt/qml/controls/FolderModals.qml @@ -0,0 +1,399 @@ +// FolderModals — the TWO folder panels and their delete confirm, in one file, +// as sibling self-gating overlays (the MyQbzModals.qml shape): +// +// CREATE primitives/CreateFolderModal.slint (109 lines) — a name field and +// a Create button, opened from the sidebar's "..." -> New folder. +// Panel width min(host - 80, 400). +// EDITOR primitives/FolderEditModal.slint (277) — the full editor, opened +// from every folder card / chip / tree row (D7) and from the +// sidebar row menu, and by the manager toolbar's own "New folder" +// in CREATE mode (`id == ""`). Panel width min(host - 80, 480). Its +// body is FolderEditPanel.qml; this file owns the chrome. +// CONFIRM QbzConfirmModal, declared LAST so it stacks over the editor, with +// its own z: 3100. +// +// Mounted ONCE in qml/shell/AppShell.qml as a direct child of the root, after +// MyQbzModals and before QbzToast, with no `z` — that file's convention is +// declaration order, and ADR-009's ">= 3000" is satisfied structurally +// (AppShell.qml:533-563). 05 §5.4's "mount the create modal inside Sidebar.qml" +// is rejected: the sidebar animates to width 0 with clip: true and already +// force-closes its own flyout on a state change, so a modal parented into it +// disappears with the sidebar. +// +// ── TWO DRAFTS, NOT ONE ──────────────────────────────────────────────────── +// `draftCreateName` belongs to the CREATE panel and is reset on its OWN open +// transition; the editor's four drafts live inside FolderEditPanel. Sharing +// one draft set between them (04 §5.6) makes "New folder" reopen holding +// whatever was last typed in the editor. +// +// ── `busy` HOLDS THE PANEL OPEN ──────────────────────────────────────────── +// Neither panel closes itself. Rust closes them, and only after the write +// landed (contract D22) — a failed create keeps the name on screen and toasts, +// where the reference closes first and loses both the name and the error. +// +// ── ESCAPE IS A WRAPPING FocusScope, NOT A SIBLING ONE ───────────────────── +// QbzConfirmModal gets away with a sibling FocusScope because it has no text +// field. Here the name input takes active focus, and QML propagates an +// unhandled key up the focused item's PARENT chain — a sibling scope is never +// on that chain, so Escape would be dead the moment the field is focused. +// Each panel is therefore wrapped in its scope. QbzLineEdit's TextInput leaves +// Escape unaccepted on the plain arm (QbzLineEdit.qml:177-184), so it reaches +// us. Escape closes the innermost open thing: while the confirm is up it holds +// focus and handles its own. +// +// ── ENTER ────────────────────────────────────────────────────────────────── +// Through QbzLineEdit's `accepted`, NEVER `committed`: committed also fires on +// focus-out, and closing the modal blurs the field, which would submit the +// same value twice (MyQbzModals.qml:34-36). + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // --- Documents. Guarded parses: a raw JSON.parse in a binding throws on + // the pre-publish frame (PlaylistView.qml:55-61). + readonly property var createDoc: { + try { return JSON.parse(QbzFolderEdit.createJson) } catch (e) { return ({}) } + } + readonly property var editDoc: { + try { return JSON.parse(QbzFolderEdit.editJson) } catch (e) { return ({}) } + } + + readonly property bool createOpen: root.createDoc.open === true + readonly property bool creating: root.createDoc.creating === true + readonly property bool editOpen: root.editDoc.open === true + /// Rust REFUSES `createClose()` / `editClose()` while a write is in flight + /// (D22 — `busy` holds the panel open). The close X must therefore SHOW + /// that it is inert for that window, or it is a control that renders and + /// no-ops. Same treatment the panel's own Delete button already carries. + readonly property bool editBusy: root.editDoc.busy === true + + visible: root.createOpen || root.editOpen + enabled: root.visible + + // §1.4.3 (2026-08-03 hotkeys-port contract): both FocusScopes grab focus + // on open and Qt strands it on the now-invisible scope at close, which + // kills the AppShell key dispatcher until the next click. Restore the + // shell root — the dispatcher's fallback focus item — on the close edge. + function _restoreShellFocus() { + var p = root + while (p.parent) { + if (p.parent.isQbzShellRoot === true) { + p.parent.forceActiveFocus() + return + } + p = p.parent + } + } + + // ================================================================== + // PANEL 1 — Create folder (CreateFolderModal.slint) + // ================================================================== + property string draftCreateName: "" + readonly property bool canCreate: root.draftCreateName.trim() !== "" && !root.creating + + onCreateOpenChanged: { + if (root.createOpen) { + // Its OWN draft, reset on its OWN open transition. + root.draftCreateName = "" + createScope.forceActiveFocus() + createField.focusField() + } else { + root._restoreShellFocus() + } + } + + FocusScope { + id: createScope + anchors.fill: parent + visible: root.createOpen + enabled: root.createOpen + + // Taken HERE rather than in the open handler: an invisible item cannot + // hold active focus, and a handler on the document property is not + // ordered against the `visible` binding it shares a frame with. + onVisibleChanged: if (createScope.visible) createScope.forceActiveFocus() + + Keys.onEscapePressed: function (event) { + QbzFolderEdit.createClose() + event.accepted = true + } + + // `radius` is load-bearing: this Item fills AppShell's ROUNDED content + // frame and Qt Quick's clip is a rectangular scissor, so an opaque + // full-bleed child paints into the frame's four bezel corners + // (AppShell.qml:246-310). #bf000000 is Slint's #000000bf CONVERTED — + // Slint is #RRGGBBAA, Qt is #AARRGGBB. + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzFolderEdit.createClose() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + width: Math.min(root.width - 80, 400) + height: createCol.implicitHeight + 40 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + // FIRST child: swallows clicks so they never reach the scrim. + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: createCol + x: 20 + y: 20 + width: parent.width - 40 + spacing: 16 + + // --- Title + close X. + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 28) + text: root.t("New folder") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + opacity: root.creating ? 0.5 : 1.0 + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: "x" + tintName: createCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: createCloseArea + anchors.fill: parent + // `createClose()` is refused mid-create, so the + // affordance goes with it rather than lying. + enabled: !root.creating + hoverEnabled: !root.creating + cursorShape: root.creating ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzFolderEdit.createClose() + } + } + } + + // --- Name. No label above it — the reference has none here. + QbzLineEdit { + id: createField + width: parent.width + text: root.draftCreateName + placeholder: root.t("Folder name") + onEdited: function (value) { root.draftCreateName = value } + onAccepted: function (value) { + root.draftCreateName = value + if (root.canCreate) + QbzFolderEdit.createSubmit(root.draftCreateName) + } + } + + // --- Footer. + Item { + width: parent.width + height: 36 + QbzPrimaryButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + label: root.creating ? root.t("Creating...") : root.t("Create") + btnHeight: 36 + labelSize: 15 + btnEnabled: root.canCreate + onClicked: QbzFolderEdit.createSubmit(root.draftCreateName) + } + } + } + } + } + + // ================================================================== + // PANEL 2 — The folder editor (FolderEditModal.slint) + // ================================================================== + // Seeds are consumed ONCE per open, and again when the editor is pointed + // at a different folder without closing. A plain republish (a busy flip, a + // picked image) must NOT reseed — it would throw away what the user typed. + readonly property string editSeedKey: + (root.editOpen ? "1" : "0") + "|" + (root.editDoc.id || "") + + onEditSeedKeyChanged: { + if (root.editOpen) { + editPanel.seed() + editScope.forceActiveFocus() + editPanel.focusName() + } + } + onEditOpenChanged: { + // A sub-modal must never outlive its host panel. + if (!root.editOpen) + deleteConfirm.close() + if (!root.editOpen) + root._restoreShellFocus() + } + + FocusScope { + id: editScope + anchors.fill: parent + visible: root.editOpen + enabled: root.editOpen + + onVisibleChanged: if (editScope.visible) editScope.forceActiveFocus() + + Keys.onEscapePressed: function (event) { + // The confirm holds focus while it is up and handles its own Esc, + // so reaching here means the panel is the innermost open thing. + QbzFolderEdit.editClose() + event.accepted = true + } + + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzFolderEdit.editClose() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + width: Math.min(root.width - 80, 480) + height: editCol.implicitHeight + 40 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: editCol + x: 20 + y: 20 + width: parent.width - 40 + spacing: 16 + + // --- Title + close X. + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 28) + // "" is CREATE mode — the manager toolbar's New folder + // opens this same editor with no id. + text: (root.editDoc.id || "") === "" ? root.t("New folder") + : root.t("Edit folder") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + opacity: root.editBusy ? 0.5 : 1.0 + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: "x" + tintName: editCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: editCloseArea + anchors.fill: parent + // `editClose()` is refused while a save or delete + // is in flight — see `editBusy`. + enabled: !root.editBusy + hoverEnabled: !root.editBusy + cursorShape: root.editBusy ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzFolderEdit.editClose() + } + } + } + + FolderEditPanel { + id: editPanel + width: parent.width + doc: root.editDoc + onDeleteRequested: deleteConfirm.open() + } + } + } + } + + // ================================================================== + // The delete confirm — LAST, so it stacks over the editor (D21). + // ================================================================== + // The reference raises a NATIVE rfd message box with untranslated &str + // (main.rs:5724); this is the in-app surface the port already has, and its + // two strings are new msgids. The body interpolates the LIVE draft name, + // matching the reference's `get_name()` — rename the folder, then delete + // it without saving, and the prompt still names what is on screen. + QbzConfirmModal { + id: deleteConfirm + anchors.fill: parent + danger: true + title: root.t("Delete folder") + body: root.t("Delete the folder “{}”? Its playlists move back to the root.") + .replace("{}", editPanel.draftName) + confirmLabel: root.t("Delete") + onConfirmed: { + QbzFolderEdit.editDeleteConfirmed() + // Hand focus back so Esc keeps working on the panel that is still + // up while the delete is in flight. + if (root.editOpen) + editScope.forceActiveFocus() + } + onCancelled: { + if (root.editOpen) + editScope.forceActiveFocus() + } + } +} diff --git a/crates/qbz-qt/qml/controls/GenreFilterPopup.qml b/crates/qbz-qt/qml/controls/GenreFilterPopup.qml new file mode 100644 index 000000000..213df2866 --- /dev/null +++ b/crates/qbz-qt/qml/controls/GenreFilterPopup.qml @@ -0,0 +1,527 @@ +// GenreFilterPopup — QML port of crates/qbz-ui/ui/discover/GenreFilterPopup.slint +// (+ its GenreCard / GenreTreeRowItem sub-components). +// +// ONE shared overlay used by every surface that draws a "Filter by genre" +// button. Mount it as the LAST child of a view root (declaration order IS +// z-order in QML) with `anchors.fill: parent`, give it the surface's +// `context`, and drive it from the button: +// +// GenreFilterPopup { id: genrePopup; anchors.fill: parent +// context: "discover"; anchorTop: 62 } +// ... MouseArea { onClicked: genrePopup.toggle() } // click handler: safe +// +// The BUTTON's badge must NOT bind to `genrePopup.selectedCount`: the popup is +// declared last, and a creation-time binding that dereferences a +// not-yet-created id registers no dependency and never re-evaluates. Views +// parse QbzBridge.genreFilterJson themselves for that (see HomeView's +// `genreCount`); `selectedCount` here is for post-creation reads. +// +// Data: QbzBridge.genreFilterJson (src/genre_filter_qt.rs) — chips, the +// 3-level advanced tree, remember/advanced flags, and the PER-CONTEXT +// selection counts + names. Actions go straight back through the QbzBridge +// invokables; the controller persists and refreshes the right consumer +// (Discover re-fetches the index, Library > All re-derives in JS). +// +// The counts are PER CONTEXT, so Discover's button and Library's button each +// show their own badge no matter which one was opened last (the Slint pair +// both read the shared current-context count — one of them showed the +// other's number). + +import QtQuick +import com.blitzfc.qbz +// Self-referential directory import: QbzToggle lives next to this file and +// the port's proven way to reach a control is the explicit directory import +// (a missing one resolves LAZILY and dies at runtime, not at build). +import "../controls" +import "../theme" + +Item { + id: root + + // "discover" | "library-all" — which selection this instance edits. + property string context: "discover" + // Card position: below the toolbar, pinned to the right edge (the Slint + // overlay sits under the Filter-by-genre button). + property int anchorTop: 62 + property int anchorRight: 32 + + readonly property bool opened: root._open + property bool _open: false + + QbzTheme { id: theme } + + readonly property var doc: { + try { + return JSON.parse(QbzBridge.genreFilterJson) + } catch (e) { + return {} + } + } + // This surface's own selection size (button badge). + readonly property int selectedCount: (doc.counts || {})[root.context] || 0 + // The popup body only ever shows the context it was opened for. + readonly property bool mine: (doc.context || "") === root.context + readonly property var chips: root.mine ? (doc.genres || []) : [] + readonly property var treeRows: root.mine ? (doc.tree || []) : [] + readonly property bool advanced: root.mine && doc.advanced === true + readonly property bool remember: doc.remember !== false + readonly property bool loading: root.mine && doc.loading === true + readonly property int liveCount: root.mine ? (doc.selectedCount || 0) : 0 + + // `genreOpen` resets the controller's query, and `setAdvanced(false)` + // clears it too — mirror that in the box so a reopened advanced view + // never shows a stale needle over an unfiltered tree. + onAdvancedChanged: if (!root.advanced) searchInput.text = "" + + function open() { + searchInput.text = "" + QbzBridge.genreOpen(root.context) + root._open = true + } + function close() { + root._open = false + } + function toggle() { + if (root._open) + root.close() + else + root.open() + } + + visible: root._open + enabled: root._open + z: 3000 + + // Click-out backdrop — declared FIRST so every later sibling (the card) + // sits above it and keeps its own presses. + MouseArea { + anchors.fill: parent + onClicked: root.close() + // Same wheel rule as the configurator modal: a MouseArea eats the + // press and not the wheel, so without this the page kept scrolling + // under an open popup. + onWheel: function (wheel) { wheel.accepted = true } + } + + Rectangle { + id: card + x: parent.width - width - root.anchorRight + y: root.anchorTop + width: 470 + height: col.implicitHeight + 32 + radius: theme.radiusMd + color: theme.surfaceMain + border.width: 1 + border.color: theme.borderMuted + + // Swallow clicks that land on the card's own chrome so the backdrop + // does not close it. Declared FIRST: the content below stays on top. + MouseArea { + anchors.fill: parent + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: col + x: 16 + y: 16 + width: parent.width - 32 + spacing: 14 + + // --- Header --------------------------------------------------- + Item { + width: parent.width + height: 26 + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + QbzIcon { + name: "list-filter" + width: 16 + height: 16 + anchors.verticalCenter: parent.verticalCenter + tintName: "secondary" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Filter by genre", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + } + } + Rectangle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 26 + height: 26 + radius: 6 + color: closeArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 15 + height: 15 + tintName: closeArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.close() + } + } + } + + // --- Options: Remember selection + Advanced view --------------- + Item { + width: parent.width + height: 22 + Row { + anchors.left: parent.left + width: (parent.width - 16) / 2 + spacing: 10 + Text { + width: parent.width - 50 + height: 22 + text: QbzSession.tr("Remember selection", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + QbzToggle { + checked: root.remember + onToggled: function (v) { QbzBridge.genreSetRemember(v) } + } + } + Row { + anchors.right: parent.right + width: (parent.width - 16) / 2 + spacing: 10 + Text { + width: parent.width - 50 + height: 22 + text: QbzSession.tr("Advanced view", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + QbzToggle { + checked: root.advanced + onToggled: function (v) { QbzBridge.genreSetAdvanced(v) } + } + } + } + + // --- Advanced view: search + 3-level tree ---------------------- + Column { + visible: root.advanced + width: parent.width + spacing: 10 + + Rectangle { + width: parent.width + height: 34 + radius: 6 + color: theme.surfaceElevated + border.width: 1 + border.color: searchInput.activeFocus ? theme.borderSubtle : theme.surfaceElevated + Row { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + QbzIcon { + name: "search" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + TextInput { + id: searchInput + width: parent.width - 22 + height: parent.height + color: theme.textPrimary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + clip: true + onTextEdited: QbzBridge.genreSearch(text) + Text { + visible: searchInput.text === "" + anchors.fill: parent + text: QbzSession.tr("Search genres…", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } + } + } + } + + Item { + width: parent.width + height: 320 + Flickable { + id: treeFlick + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: treeCol.height + boundsBehavior: Flickable.StopAtBounds + Column { + id: treeCol + width: treeFlick.width + spacing: 1 + Repeater { + model: root.treeRows + delegate: Rectangle { + id: treeRow + required property var modelData + width: treeCol.width + height: 34 + radius: 6 + color: rowArea.containsMouse || arrowArea.containsMouse + ? theme.surfaceHover : "transparent" + + // Row click toggles selection. Declared + // FIRST so the chevron's MouseArea below + // sits ON TOP of it — otherwise the row + // swallows the chevron press and the + // sub-genres never expand (Slint #545, + // same z-order rule). + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.genreToggle(treeRow.modelData.id) + } + + Row { + anchors.fill: parent + anchors.leftMargin: 6 + treeRow.modelData.level * 18 + anchors.rightMargin: 10 + spacing: 6 + + Item { + width: 20 + height: parent.height + QbzIcon { + visible: treeRow.modelData.hasChildren + anchors.centerIn: parent + name: treeRow.modelData.expanded ? "chevron-down" : "chevron-right" + width: 13 + height: 13 + tintName: "muted" + } + MouseArea { + id: arrowArea + anchors.fill: parent + // Disabled on leaf rows so the + // press falls through to the row + // (an enabled MouseArea eats it + // even with a no-op handler — + // 20px dead zone otherwise). + enabled: treeRow.modelData.hasChildren + hoverEnabled: treeRow.modelData.hasChildren + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.genreToggleExpand(treeRow.modelData.id) + } + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 16 + height: 16 + radius: 4 + color: treeRow.modelData.selected ? theme.accent : "transparent" + border.width: treeRow.modelData.selected ? 0 : 2 + border.color: theme.borderMuted + QbzIcon { + visible: treeRow.modelData.selected + anchors.centerIn: parent + name: "check" + width: 11 + height: 11 + // DELIBERATE DIVERGENCE from + // discover/GenreFilterPopup.slint:85, + // which hardcodes `tint: #ffffff` + // on this accent box — 1.70:1 on + // high-contrast, 1.74 on ikari, + // 16 of the 35 palettes under + // 2.6:1. theme/QbzTheme.qml, "ON + // AN ACCENT FILL". + tintName: theme.accentGlyphTint + } + } + + Text { + // Row: [20 arrow][16 box][name][count], + // spacing 6 — the count and ITS + // spacing only exist when shown + // (an invisible Text still has a + // width, so it cannot be + // subtracted unconditionally). + width: parent.width - 26 - 22 + - (countLabel.visible ? countLabel.width + 6 : 0) + height: parent.height + text: treeRow.modelData.name + color: treeRow.modelData.selected ? theme.textPrimary : theme.textSecondary + font.pixelSize: 13 + font.weight: treeRow.modelData.level === 0 + ? theme.weightMedium : theme.weightRegular + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + id: countLabel + visible: (treeRow.modelData.count || 0) > 0 + height: parent.height + text: treeRow.modelData.count + color: theme.textMuted + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + } + } + } + } + } + } + } + } + + // --- Simple grid: parent genres as chips ----------------------- + Grid { + id: chipGrid + visible: !root.advanced && root.chips.length > 0 + width: parent.width + columns: 3 + columnSpacing: 8 + rowSpacing: 8 + readonly property int cellWidth: Math.floor((width - 2 * columnSpacing) / columns) + + Repeater { + model: root.chips + delegate: Rectangle { + id: chip + required property var modelData + width: chipGrid.cellWidth + height: 38 + radius: 6 + color: chip.modelData.selected ? theme.accent + : chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + Text { + width: parent.width - 24 + height: parent.height + text: chip.modelData.name + // On the accent fill -> the measured on-accent + // colour. GenreFilterPopup.slint:125 hardcodes + // #ffffff (same defect as :85 above); the port + // had already moved to accent-text, and + // `accentGlyphColor` IS accent-text on 34 of + // the 35 palettes — it only differs where + // accent-text itself drops under 3:1 + // (rose-pine-dawn, 2.56:1). + color: chip.modelData.selected + ? theme.accentGlyphColor : theme.textSecondary + font.pixelSize: 13 + font.weight: chip.modelData.selected ? theme.weightMedium : theme.weightRegular + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 16 + height: 16 + radius: 8 + // INVERTED host: this 16px disc is the chip's + // FOREGROUND punched out of the accent fill, + // and the check inside it is tinted `accent`. + // So the pair that has to clear the floor is + // (accent glyph, this fill) — the same two + // colours as everywhere else on this pill, + // just swapped. Painting it with + // `accentGlyphColor` rather than raw + // accent-text is what keeps that true: + // identical on 34 palettes, and on + // rose-pine-dawn it turns a 2.56:1 check into + // 7.38:1. GenreFilterPopup.slint:141 paints + // this disc #ffffff. + color: chip.modelData.selected + ? theme.accentGlyphColor : "transparent" + border.width: chip.modelData.selected ? 0 : 2 + border.color: theme.borderMuted + QbzIcon { + visible: chip.modelData.selected + anchors.centerIn: parent + name: "check" + width: 11 + height: 11 + tintName: "accent" + } + } + } + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.genreToggle(chip.modelData.id) + } + } + } + } + + // Empty/loading state for the simple grid — the parent list loads + // on first open, so the card would otherwise flash a bare header. + Text { + visible: !root.advanced && root.chips.length === 0 + width: parent.width + height: 38 + // `!mine` = the context switch has been sent but Rust's + // republish has not landed yet (one event-loop hop), which + // reads as loading, not as "nothing here". + text: (root.loading || !root.mine) + ? QbzSession.tr("Loading…", QbzSession.trRev) + : QbzSession.tr("No genres available.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } + + // --- Footer: Clear filter ------------------------------------- + Rectangle { + width: parent.width + height: 34 + radius: 6 + opacity: root.liveCount > 0 ? 1.0 : 0.5 + color: clearArea.containsMouse && root.liveCount > 0 + ? theme.surfaceHover : theme.surfaceElevated + Text { + anchors.centerIn: parent + text: QbzSession.tr("Clear filter", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 13 + } + MouseArea { + id: clearArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.liveCount > 0 ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (root.liveCount > 0) QbzBridge.genreClear() + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/GroupHeader.qml b/crates/qbz-qt/qml/controls/GroupHeader.qml new file mode 100644 index 000000000..d50a82a8c --- /dev/null +++ b/crates/qbz-qt/qml/controls/GroupHeader.qml @@ -0,0 +1,19 @@ +// Settings group header (AppearanceSettings.slint GroupHeader) — extracted +// from SettingsView.qml in phase 19: 11px muted uppercase, 1.5px tracking, +// semibold. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Text { + // Explicit density opt-in; desktop geometry remains the default. + property bool kioskHost: false + + QbzTheme { id: theme } + + color: theme.textMuted + font.pixelSize: kioskHost ? 13.2 : 11 + font.letterSpacing: 1.5 + font.weight: theme.weightSemibold +} diff --git a/crates/qbz-qt/qml/controls/HeaderGradient.qml b/crates/qbz-qt/qml/controls/HeaderGradient.qml new file mode 100644 index 000000000..2bddcb911 --- /dev/null +++ b/crates/qbz-qt/qml/controls/HeaderGradient.qml @@ -0,0 +1,271 @@ +// HeaderGradient — the artwork-tinted band behind the album / artist page +// headers. ONE component, mounted by BOTH AlbumView and ArtistView (the +// Slint paints them from identical blocks: AlbumPageView.slint:235-257 and +// ArtistPageView.slint:225-247 are the same four lines twice). +// +// The Slint has TWO routes for this band, and this component carries BOTH: +// route A header-atmosphere.width > 0 -> ImmersiveAtmosphere (a blurred, +// magnified, four-times-overlaid copy of the cover). This is what +// the app actually shows. +// route B header-atmosphere.width <= 0 -> a flat linear gradient built +// from `header-color` (crates/qbz/src/artwork.rs header_tint), the +// arm the Slint paints only while the atmosphere is still missing. +// Route A needs NO shader here: the atmosphere is a 128x128 PNG produced in +// Rust (atmosphere_qt.rs, the immersive.rs pipeline ported stage for stage), +// so it is an ordinary picture. `tint` is the header_tint hex the Rust side +// publishes in the page document (album_qt.rs / artist_qt.rs `headerColor`) +// and feeds route B. +// +// Bands, read off the .slint: +// band 1 linear-gradient(180deg, tint 0%, tint 16%, transparent 100%) +// band 4 linear-gradient(180deg, transparent 0%, transparent 82%, +// surface-main 100%) +// Band 4 lands the fade EXACTLY on the header/content boundary whatever +// height the header resolved to, which is why the host binds `height` to the +// divider's y rather than to a constant. +// +// --- LIGHT-THEME CONTRAST FLOOR (bands 2 + 3) --------------------------- +// The header text is WHITE whenever this band is on — AlbumPageView.slint +// :169-172 pins `hdr-strong: #ffffff` / `hdr-body: #ffffff@88%` the moment +// `header-light` is true, on EVERY theme. That is only safe because the +// backdrop the .slint normally paints is route A, and route A is OPAQUE +// DARK: ImmersiveAtmosphere.slint:18 fills `#0a0a0b` under the artwork +// copies, then :76-79 lay a flat `#000000 @ dim` sheet (the album/artist +// headers pass `dim: 0.24`) and a `linear-gradient(180deg, #00000066 0%, +// transparent 35%, #00000080 100%)` vignette over the whole band. White text +// and the overlay-palette circles sit on that, not on the page colour. +// +// Route B alone does NOT reproduce that: its tint alpha decays from 1.0 at +// 16% to 0.0 at 100%, so below roughly half the band the backdrop IS the +// page. On a dark theme that is invisible (white on dark either way); on a +// LIGHT theme the description, the meta line and the whole CircleAction row +// — which live in the bottom third of the header — were white on white. +// Route A's two constant sheets are therefore replicated verbatim here as +// bands 2 and 3, and band 1 holds the tint solid until the 82% mark where +// band 4 takes over, matching route A's opaque coverage instead of route +// B's early decay. Same numbers, same order as ImmersiveAtmosphere. +// +// Mount it as the FIRST child of the page Flickable (so it scrolls with the +// content, as the Slint's does) at x:0 with the viewport width — the band is +// deliberately full-bleed: the page's own 32px padding must not clip it, or +// a dark gutter strip appears on the right (the .slint comment at +// AlbumPageView.slint:199-202 documents that exact regression). + +// --- ROUTE A IS FOUR OVERSIZED COPIES, NOT ONE STRETCHED ONE ------------- +// The first version of this file painted the atmosphere as ONE Image at +// `anchors.fill` + `Image.Stretch`, i.e. the 128x128 field mapped 1:1 onto the +// band. That is not what ImmersiveAtmosphere does and it is why the header read +// as "a blob in the middle": the field is VIGNETTED in Rust (atmosphere_qt.rs +// `vignette`, 0.20 from 20% to 70% of the shorter side) and its colour is +// concentrated at the centre, so mapping it 1:1 onto a 1400x300 band puts the +// bright core dead centre and the vignette's dark ring on the edges. +// +// ImmersiveAtmosphere.slint:21-67 lays FOUR copies, each SIGNIFICANTLY LARGER +// than the band (+520/+620/+700/+560 wide, +420/+500/+540/+620 tall) and pulled +// back by a negative offset, `image-fit: cover`. So what the band shows is a +// CROP of a heavily magnified field: the vignette ring falls outside the +// viewport, the four crops disagree with each other, and the colour spreads +// across the whole band instead of pooling. THAT is the "zoom" — it is not +// decoration, it is what makes the gradient read as a field. +// +// `animated: false` (both headers pass it) freezes `tick` at 0, so the four sin +// pairs collapse to their PHASE terms — written out below with the same +// expressions the .slint uses, so they stay checkable line by line. +import QtQuick +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + // The band must not bleed: every route-A copy is larger than it. + clip: true + + /// Artwork-derived tint, "#rrggbb" (header_tint). Empty = nothing paints. + property string tint: "" + /// The appearance pref AND the "no app-wide dynamic background" gate: + /// `AppearanceState.album-header-gradient && !ShellState.app-background-active` + /// (AlbumPageView.slint:168). The dynamic background and this atmosphere + /// clash, so only one of them is ever on. + property bool active: false + /// ImmersiveAtmosphere's flat black sheet — the album and artist headers + /// both mount it with `dim: 0.24` (AlbumPageView.slint:230, + /// ArtistPageView.slint:222). + property real dim: 0.24 + /// The two opacity knobs both headers pass (AlbumPageView.slint:231-232, + /// ArtistPageView.slint:223-224). ImmersiveAtmosphere's OWN defaults are + /// 0.95 / 0.48 — the headers override them, so these are the header values, + /// not the component's. + property real baseOpacity: 0.92 + property real warpOpacity: 0.38 + + // `visible` gates the paint, but bindings still evaluate underneath it — + // so the stops read this, never the raw string: coercing "" to a colour + // logs a warning on every re-evaluation. + readonly property color tintColor: root.tint === "" ? "transparent" : root.tint + + /// The tint at a given alpha. A GradientStop needs a concrete colour, and + /// `Qt.alpha()` does not exist — the channels have to be rebuilt. + function tintAlpha(a) { + var c = root.tintColor + return Qt.rgba(c.r, c.g, c.b, c.a * a) + } + /// Twin of tintAlpha for the page-surface fade underneath. + function surfaceAlpha(a) { + var c = theme.surfaceMain + return Qt.rgba(c.r, c.g, c.b, a) + } + + visible: root.active && (root.tint !== "" || root.hasAtmosphere) + // Never eat clicks meant for the header controls painted on top. + enabled: false + + /// ROUTE A — the blurred artwork field Slint actually renders + /// (`ImmersiveAtmosphere`, AlbumPageView.slint:222). It needs no shader: + /// the atmosphere is a 128x128 image produced in Rust + /// (atmosphere_qt.rs, the immersive.rs pipeline ported stage for stage), + /// so it works on the software path like any other picture. Empty means + /// the cover has not resolved yet and route B stands in. + property string atmosphere: "" + readonly property bool hasAtmosphere: root.atmosphere !== "" + + // --- the four phase constants, one per copy -------------------------- + // ImmersiveAtmosphere drives each copy from `tick = animation-tick()/1ms`, + // and `animated: false` pins tick at 0. What survives is the phase offset + // inside each sine, so these are the .slint expressions with `tick = 0` + // substituted and nothing else changed. `Math.sin` takes radians and + // Slint's `* 1rad` does the same, so the arguments are identical. + // They are constants: QML evaluates each of these exactly once. + readonly property real _tau: 6.283185 + // :22-24 sx = sin(0) + 0.35*sin(1.9τ) sy = sin(1.1τ) + readonly property real _sx1: 0.35 * Math.sin(1.9 * root._tau) + readonly property real _sy1: Math.sin(1.1 * root._tau) + // :34-36 sx = sin(0.4τ) + 0.42*sin(2.6τ) sy = sin(1.7τ) + readonly property real _sx2: Math.sin(0.4 * root._tau) + 0.42 * Math.sin(2.6 * root._tau) + readonly property real _sy2: Math.sin(1.7 * root._tau) + // :46-48 sx = sin(1.6τ) sy = sin(0.2τ) + 0.28*sin(2.8τ) + readonly property real _sx3: Math.sin(1.6 * root._tau) + readonly property real _sy3: Math.sin(0.2 * root._tau) + 0.28 * Math.sin(2.8 * root._tau) + // :58-59 sx = sin(2.4τ) sy = sin(1.4τ) + readonly property real _sx4: Math.sin(2.4 * root._tau) + readonly property real _sy4: Math.sin(1.4 * root._tau) + + // ImmersiveAtmosphere.slint:18 — the component's own `background: #0a0a0b`. + // Route A is OPAQUE DARK by construction; the white header text and the + // overlay-palette circles are legible because of THIS, not because of the + // tint. Route B never had it (Slint paints the flat gradient without the + // component), so it stays gated on the atmosphere. + Rectangle { + anchors.fill: parent + visible: root.hasAtmosphere + color: "#0a0a0b" + } + + // The four artwork copies, :21-67, one row per copy. `growW`/`growH` are + // the +Npx the .slint adds to the band's size; `baseX`/`baseY` are its + // constant negative offsets; `offX`/`offY` are the already-resolved + // `sx * Npx` / `sy * Npx` terms, SIGNS INCLUDED — copy 2 subtracts where + // copy 1 adds (`x: -310px - sx*156px`), and copy 4 subtracts on x only. + // That disagreement between the crops is the point: four identical + // offsets would just stack into one brighter copy. + // + // A Repeater, not four hand-written Images and not an inline `component`: + // an inline component does NOT see this document's ids, so an `AtmoLayer` + // reading `root.atmosphere` would silently resolve to nothing (the trap + // controls/QbzSkeleton.qml documents). A Repeater's delegate DOES live in + // document scope. + readonly property var _atmoLayers: [ + { growW: 520, growH: 420, baseX: -260, baseY: -210, + offX: root._sx1 * 132, offY: root._sy1 * 58, o: root.baseOpacity }, + { growW: 620, growH: 500, baseX: -310, baseY: -250, + offX: -root._sx2 * 156, offY: -root._sy2 * 74, o: root.warpOpacity }, + { growW: 700, growH: 540, baseX: -350, baseY: -270, + offX: root._sx3 * 118, offY: root._sy3 * 92, o: 0.24 }, + { growW: 560, growH: 620, baseX: -280, baseY: -310, + offX: -root._sx4 * 102, offY: root._sy4 * 128, o: 0.18 } + ] + + Repeater { + model: root._atmoLayers + delegate: Image { + required property var modelData + source: root.atmosphere + visible: root.hasAtmosphere + asynchronous: true + smooth: true + // Slint `image-fit: cover`. The source is square and this rect is + // far wider than tall, so cover scales to WIDTH and crops the + // height — that magnification is what spreads the colour across + // the band instead of pooling it in the middle. + fillMode: Image.PreserveAspectCrop + // All four share ONE decode: same url, same pixmap-cache entry. + width: root.width + modelData.growW + height: root.height + modelData.growH + x: modelData.baseX + modelData.offX + y: modelData.baseY + modelData.offY + opacity: modelData.o + } + } + + // Band 1 — ROUTE B, the flat tint. This is Slint's FALLBACK, painted only + // while no atmosphere exists; shipping it as the design is what made the + // header read as a plain ugly gradient. Solid to the 82% mark, the last + // 18% is band 4's job. + Rectangle { + anchors.fill: parent + visible: !root.hasAtmosphere + gradient: Gradient { + // The tint used to hold FULL strength to 0.82 and then die inside + // the last 18%, which draws a visible horizontal seam across the + // page — the taller the band, the worse, because the falloff stays + // the same fraction while the solid part grows. The stops below + // spread the decay over the bottom ~60% on an ease-out curve, so + // there is no single row of pixels where the gradient "starts". + // Alpha is stepped explicitly because a GradientStop interpolates + // linearly between whatever two colours it is given. + GradientStop { position: 0.00; color: root.tintColor } + GradientStop { position: 0.16; color: root.tintColor } + GradientStop { position: 0.38; color: root.tintAlpha(0.94) } + GradientStop { position: 0.52; color: root.tintAlpha(0.80) } + GradientStop { position: 0.65; color: root.tintAlpha(0.60) } + GradientStop { position: 0.77; color: root.tintAlpha(0.38) } + GradientStop { position: 0.88; color: root.tintAlpha(0.18) } + GradientStop { position: 0.95; color: root.tintAlpha(0.07) } + GradientStop { position: 1.00; color: "transparent" } + } + } + + // Band 2 — ImmersiveAtmosphere.slint:77-79, `#000000.with-alpha(dim)`. + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, root.dim) + } + + // Band 3 — ImmersiveAtmosphere.slint:80-82 vignette, verbatim stops. + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: "#66000000" } + GradientStop { position: 0.35; color: "transparent" } + GradientStop { position: 1.0; color: "#80000000" } + } + } + + // Band 4 — the blend that must COMPLETE on the header/content divider. + Rectangle { + anchors.fill: parent + gradient: Gradient { + // Mirrors the tint's curve. Held at zero to 0.82 this overlay put + // its own seam in the same place, doubling the edge the tint was + // already drawing. + GradientStop { position: 0.00; color: "transparent" } + GradientStop { position: 0.45; color: "transparent" } + GradientStop { position: 0.62; color: root.surfaceAlpha(0.12) } + GradientStop { position: 0.75; color: root.surfaceAlpha(0.34) } + GradientStop { position: 0.86; color: root.surfaceAlpha(0.60) } + GradientStop { position: 0.94; color: root.surfaceAlpha(0.84) } + GradientStop { position: 1.00; color: theme.surfaceMain } + } + } +} diff --git a/crates/qbz-qt/qml/controls/IconTextButton.qml b/crates/qbz-qt/qml/controls/IconTextButton.qml new file mode 100644 index 000000000..ae4e4bc42 --- /dev/null +++ b/crates/qbz-qt/qml/controls/IconTextButton.qml @@ -0,0 +1,72 @@ +// Small bordered icon+label button — the QML port of the private +// `IconTextButton` in crates/qbz-ui/ui/shell/LogViewerModal.slint:29-75. +// +// It exists because it AUTO-SIZES TO ITS LABEL. `controls/SettingsButton.qml` +// carries `minWidth: 160`, which is right for a settings row's right rail and +// wrong for a footer of five buttons — using it there overflowed the panel and +// the labels collided (observed 2026-08-05). +// +// Geometry is 1:1 with the reference: height 32, Radius.sm, 1px border-subtle, +// surface-elevated (surface-hover on hover), opacity 0.4 when disabled, +// 12px side padding, 7px gap, 14px icon, 13px medium label. `danger` recolors +// BOTH the icon and the label — it does not fill. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + property string label: "" + property string iconName: "" + /// The reference's `has-icon`: an icon-less variant keeps the same + /// padding and height. + property bool hasIcon: true + property bool danger: false + property bool btnEnabled: true + + signal clicked() + + QbzTheme { id: theme } + + height: 32 + // Auto-size: padding + optional icon + gap + label. + width: 12 + (root.hasIcon ? 14 + 7 : 0) + labelText.implicitWidth + 12 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: hoverArea.containsMouse && root.btnEnabled ? theme.surfaceHover : theme.surfaceElevated + opacity: root.btnEnabled ? 1.0 : 0.4 + + Row { + anchors.centerIn: parent + spacing: 7 + QbzIcon { + visible: root.hasIcon + anchors.verticalCenter: parent.verticalCenter + name: root.iconName + width: 14 + height: 14 + // `favorite` is the port's documented stand-in for the danger red + // (icon_tint_qt.rs:63); there is no `danger` bake. + tintName: root.danger ? "favorite" : "secondary" + } + Text { + id: labelText + anchors.verticalCenter: parent.verticalCenter + text: root.label + color: root.danger ? theme.danger : theme.textSecondary + font.pixelSize: 13 + font.weight: theme.weightMedium + } + } + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (root.btnEnabled) root.clicked() + } +} diff --git a/crates/qbz-qt/qml/controls/LinkResolverModal.qml b/crates/qbz-qt/qml/controls/LinkResolverModal.qml new file mode 100644 index 000000000..82c09a406 --- /dev/null +++ b/crates/qbz-qt/qml/controls/LinkResolverModal.qml @@ -0,0 +1,211 @@ +// App-wide music-link resolver. QbzLink owns state and async resolution; +// this file is only the modal surface and survives whichever menu/hotkey +// opened it. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + visible: QbzLink.modalOpen + + QbzTheme { id: theme } + + Keys.onEscapePressed: function(event) { + QbzLink.close() + event.accepted = true + } + + onVisibleChanged: { + if (visible) + urlInput.focusField() + } + + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzLink.close() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + id: card + anchors.centerIn: parent + width: Math.min(parent.width - 80, 520) + height: panel.implicitHeight + 48 + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: panel + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 24 + spacing: 16 + + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.right: closeButton.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Open Music Link", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + id: closeButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: closeArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzLink.close() + } + } + } + + Row { + width: parent.width + height: 40 + spacing: 8 + + Item { + width: 36 + height: 40 + + Image { + anchors.centerIn: parent + width: 24 + height: 24 + fillMode: Image.PreserveAspectFit + visible: QbzLink.platform === "qobuz" + || QbzLink.platform === "spotify" + || QbzLink.platform === "apple" + || QbzLink.platform === "deezer" + source: QbzLink.platform === "qobuz" + ? "../assets/brand/qobuz-logo-filled.svg" + : QbzLink.platform === "spotify" + ? "../assets/brand/spotify-logo.svg" + : QbzLink.platform === "apple" + ? "../assets/brand/apple-music-logo.svg" + : "../assets/brand/deezer-logo.svg" + } + QbzIcon { + anchors.centerIn: parent + width: 24 + height: 24 + visible: QbzLink.platform === "tidal" + name: "tidal-tidal" + tintName: "accent" + } + QbzIcon { + anchors.centerIn: parent + width: 22 + height: 22 + visible: QbzLink.platform !== "qobuz" + && QbzLink.platform !== "spotify" + && QbzLink.platform !== "apple" + && QbzLink.platform !== "deezer" + && QbzLink.platform !== "tidal" + name: "link" + tintName: "muted" + } + } + + QbzLineEdit { + id: urlInput + width: parent.width - 36 - goButton.width - 2 * parent.spacing + height: 40 + text: QbzLink.url + placeholder: QbzSession.tr("Paste a Qobuz, Spotify, Apple Music, Tidal, Deezer or song.link URL", QbzSession.trRev) + enabled: !QbzLink.resolving + opacity: enabled ? 1.0 : 0.6 + onEdited: function(value) { QbzLink.urlEdited(value) } + onAccepted: function(value) { QbzLink.submit(value) } + } + + QbzPrimaryButton { + id: goButton + anchors.verticalCenter: parent.verticalCenter + label: QbzLink.resolving + ? QbzSession.tr("Resolving…", QbzSession.trRev) + : QbzSession.tr("Go", QbzSession.trRev) + btnHeight: 40 + labelSize: theme.fontBody + btnEnabled: QbzLink.url.trim() !== "" && !QbzLink.resolving + onClicked: QbzLink.submit(QbzLink.url) + } + } + + Text { + width: parent.width + visible: QbzLink.error !== "" + text: QbzLink.error + color: theme.danger + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + height: playlistColumn.implicitHeight + 24 + visible: QbzLink.playlistDetected + radius: theme.radiusSm + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + id: playlistColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 10 + Text { + width: parent.width + text: QbzSession.tr("This looks like a playlist.", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + QbzPrimaryButton { + label: QbzSession.tr("Open Playlist Importer", QbzSession.trRev) + btnHeight: 34 + labelSize: theme.fontLegal + onClicked: QbzLink.openImporter() + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/LocalMediaInfoModal.qml b/crates/qbz-qt/qml/controls/LocalMediaInfoModal.qml new file mode 100644 index 000000000..9ef38d183 --- /dev/null +++ b/crates/qbz-qt/qml/controls/LocalMediaInfoModal.qml @@ -0,0 +1,293 @@ +// Physical media facts for Local Library. This intentionally does not reuse +// the Qobuz Track/Album Info document: those modals describe a catalog record; +// this one describes the playable file/server copy and exposes copyable paths. + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz +import "../theme" + +Popup { + id: root + + parent: Overlay.overlay + x: 0 + y: 0 + width: parent ? parent.width : 0 + height: parent ? parent.height : 0 + padding: 0 + z: 3000 + modal: true + dim: false + closePolicy: Popup.CloseOnEscape + + QbzTheme { id: theme } + + readonly property var doc: { + try { return JSON.parse(QbzLocal.localMediaInfoJson || "{}") } + catch (e) { return ({}) } + } + readonly property bool loading: QbzLocal.localMediaInfoLoading + + function sourceName(kind) { + if (kind === "local") return QbzSession.tr("Local file", QbzSession.trRev) + if (kind === "network") return QbzSession.tr("Network file", QbzSession.trRev) + if (kind === "offline") return QbzSession.tr("Offline download", QbzSession.trRev) + if (kind === "plex") return "Plex" + if (kind === "jellyfin") return "Jellyfin" + if (kind === "subsonic") return "Subsonic / Navidrome" + return kind || "" + } + function sourceText() { + var values = root.doc.sourceKinds || [] + var out = [] + for (var i = 0; i < values.length; i++) out.push(root.sourceName(values[i])) + return out.join(", ") + } + function locationName(kind) { + if (kind === "file") return QbzSession.tr("File path", QbzSession.trRev) + if (kind === "folder") return QbzSession.tr("Containing folder", QbzSession.trRev) + if (kind === "item") return QbzSession.tr("Media item ID", QbzSession.trRev) + return QbzSession.tr("Album ID", QbzSession.trRev) + } + function factRows() { + var rows = [] + function add(label, value) { + if (String(value || "") !== "") rows.push({"label": label, "value": value}) + } + add(QbzSession.tr("Source", QbzSession.trRev), root.sourceText()) + add(QbzSession.tr("Server", QbzSession.trRev), root.doc.server) + if (root.doc.kind === "album") + add(QbzSession.tr("Tracks", QbzSession.trRev), root.doc.trackCount) + add(QbzSession.tr("Duration", QbzSession.trRev), root.doc.duration) + add(QbzSession.tr("Format", QbzSession.trRev), root.doc.formats) + add(QbzSession.tr("Quality", QbzSession.trRev), root.doc.quality) + add(QbzSession.tr("Channels", QbzSession.trRev), root.doc.channels) + add(QbzSession.tr("File size", QbzSession.trRev), root.doc.fileSize) + return rows + } + + Connections { + target: QbzLocal + function onLocalMediaInfoOpenChanged() { + if (QbzLocal.localMediaInfoOpen) root.open() + else root.close() + } + } + Component.onCompleted: if (QbzLocal.localMediaInfoOpen) open() + onClosed: if (QbzLocal.localMediaInfoOpen) QbzLocal.closeMediaInfo() + + background: Rectangle { color: "#bf000000" } + + contentItem: Item { + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + Rectangle { + id: card + width: Math.min(root.width - 40, 760) + height: Math.min(root.height - 40, 620) + anchors.centerIn: parent + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + MouseArea { anchors.fill: parent } + + Column { + id: header + anchors.left: parent.left + anchors.right: closeButton.left + anchors.leftMargin: 24 + anchors.rightMargin: 16 + anchors.top: parent.top + anchors.topMargin: 20 + spacing: 4 + Text { + width: parent.width + text: root.doc.kind === "album" + ? QbzSession.tr("Album info", QbzSession.trRev) + : QbzSession.tr("Track info", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightBold + elide: Text.ElideRight + } + Text { + width: parent.width + text: root.doc.title || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Text { + width: parent.width + text: root.doc.subtitle || "" + visible: text !== "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + + Rectangle { + id: closeButton + width: 32 + height: 32 + radius: theme.radiusSm + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.top: parent.top + anchors.topMargin: 16 + color: closeArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { anchors.centerIn: parent; name: "x"; width: 16; height: 16; tintName: "muted" } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.close() + } + } + + QbzSpinner { + visible: root.loading + width: 32 + height: 32 + anchors.centerIn: parent + } + + Text { + visible: !root.loading && (root.doc.error || "") !== "" + anchors.centerIn: parent + width: parent.width - 80 + text: root.doc.error || "" + color: theme.textMuted + font.pixelSize: theme.fontBody + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + Flickable { + id: flick + visible: !root.loading && (root.doc.error || "") === "" + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + anchors.bottom: parent.bottom + anchors.leftMargin: 24 + anchors.rightMargin: 24 + anchors.topMargin: 20 + anchors.bottomMargin: 20 + clip: true + contentWidth: width + contentHeight: body.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + Column { + id: body + width: flick.width - 18 + spacing: 10 + + Repeater { + model: root.factRows() + delegate: Item { + required property var modelData + width: body.width + height: Math.max(34, factValue.implicitHeight + 8) + Text { + width: 142 + anchors.verticalCenter: parent.verticalCenter + text: modelData.label + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + TextEdit { + id: factValue + x: 154 + width: parent.width - x + anchors.verticalCenter: parent.verticalCenter + text: String(modelData.value) + color: theme.textPrimary + font.pixelSize: theme.fontLegal + readOnly: true + selectByMouse: true + wrapMode: TextEdit.Wrap + } + } + } + + Rectangle { + visible: (root.doc.locations || []).length > 0 + width: parent.width + height: 1 + color: theme.borderSubtle + } + + Text { + visible: (root.doc.locations || []).length > 0 + text: QbzSession.tr("Location", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + + Repeater { + model: root.doc.locations || [] + delegate: Rectangle { + required property var modelData + width: body.width + height: 68 + radius: theme.radiusSm + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + Text { + x: 12 + y: 8 + text: root.locationName(modelData.kind) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + } + TextEdit { + x: 12 + y: 28 + width: parent.width - 92 + height: 30 + text: modelData.value || "" + color: theme.textPrimary + font.pixelSize: theme.fontLegal + readOnly: true + selectByMouse: true + wrapMode: TextEdit.NoWrap + clip: true + } + SettingsButton { + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Copy", QbzSession.trRev) + onClicked: QbzLocal.copyMediaInfo(modelData.value || "") + } + } + } + } + } + + QbzScrollBar { + visible: flick.visible && flick.contentHeight > flick.height + target: flick + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: flick.top + anchors.bottom: flick.bottom + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/MyQbzModals.qml b/crates/qbz-qt/qml/controls/MyQbzModals.qml new file mode 100644 index 000000000..139236af4 --- /dev/null +++ b/crates/qbz-qt/qml/controls/MyQbzModals.qml @@ -0,0 +1,567 @@ +// MyQbzModals — the THREE small MyQBZ modals in one file, as three sibling +// panels: Create (myqbz/CreateMyQbzModal.slint, 199 lines), Edit +// (MyQbzEditModal.slint, 147) and Mix (MyQbzMixModal.slint, 184). Slint keeps +// them apart only because each has its own global; they share the same +// `#bf000000` backdrop, centred surface-card panel and right-aligned action +// footer, and they are mutually exclusive in practice. +// +// A GLOBAL overlay: AppShell.qml mounts it once and each panel self-gates on +// its OWN document's `open` flag (`QbzMyQbz.createJson` / `editJson` / +// `mixJson`) — the same shape as Slint's four bare instantiations at +// AppShell.slint:814-824. +// +// Per-panel numbers that are NOT shared, and every one of them is deliberate: +// * width 420 / 420 / 440 (Create / Edit / Mix); +// * spacing 16 / 16 / 18; +// * Edit follows the current modal standard (20px padding, heading + 28x28 +// close X, 10px footer spacing, 36px buttons); Create and Mix retain their +// reference-specific metrics until their own parity pass. +// +// Drop shadows (`drop-shadow-blur: 32px` on all three) are dropped: shadows +// render nothing on this port's software path. The scrims carry `radius: +// theme.radiusMd` so an opaque full-bleed child cannot paint into the shell +// frame's bezel corners (controls/DiscoverConfigModal.qml:59-75). +// +// TEXT STATE IS QML-LOCAL. `MyQbzCreateState.name` and +// `MyQbzEditState.draft-name` / `draft-description` are NOT bridge members: +// every submit invokable takes the value as an argument (`createSubmit(name)`, +// `editSubmitRename(name)`, `editSubmitDescription(description)`). The drafts +// are seeded from the document when a panel opens and cleared when it closes. +// +// ENTER submits through `QbzLineEdit`'s `accepted` signal, NEVER `committed`: +// `committed` also fires on focus-out (QbzLineEdit.qml:155), and a blur must +// not create or rename anything. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // --- Documents (guarded parses; a raw JSON.parse in a binding throws on + // the pre-publish frame — PlaylistView.qml:55-61). + readonly property var createDoc: { + try { return JSON.parse(QbzMyQbz.createJson) } catch (e) { return ({}) } + } + readonly property var editDoc: { + try { return JSON.parse(QbzMyQbz.editJson) } catch (e) { return ({}) } + } + readonly property var mixDoc: { + try { return JSON.parse(QbzMyQbz.mixJson) } catch (e) { return ({}) } + } + + readonly property bool createOpen: root.createDoc.open === true + readonly property bool editOpen: root.editDoc.open === true + readonly property bool mixOpen: root.mixDoc.open === true + + visible: root.createOpen || root.editOpen || root.mixOpen + enabled: root.visible + + // ================================================================ + // PANEL 1 — Create Mixtape / Collection (CreateMyQbzModal.slint) + // ================================================================ + Item { + id: createPanel + anchors.fill: parent + visible: root.createOpen + + readonly property string kind: root.createDoc.kind || "mixtape" + readonly property bool isMixtape: createPanel.kind === "mixtape" + readonly property bool creating: root.createDoc.creating === true + property string name: "" + readonly property bool canCreate: createPanel.name !== "" && !createPanel.creating + + // A fresh open starts with an empty field. + Connections { + target: root + function onCreateOpenChanged() { createPanel.name = "" } + } + + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzMyQbz.createClose() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + Rectangle { + width: Math.min(root.width - 80, 420) + height: createCol.implicitHeight + 48 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: createCol + x: 24 + y: 24 + width: parent.width - 48 + spacing: 16 + + // --- Title + close + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 28) + text: createPanel.isMixtape ? root.t("Create Mixtape") + : root.t("Create Collection") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightBold + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: createCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: createCloseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzMyQbz.createClose() + } + } + } + + // --- NAME + Column { + width: parent.width + spacing: 8 + Text { + text: root.t("NAME") + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + font.letterSpacing: 1.5 + } + QbzLineEdit { + width: parent.width + height: 34 + text: createPanel.name + onEdited: function (v) { createPanel.name = v } + onAccepted: function (v) { + if (createPanel.canCreate) QbzMyQbz.createSubmit(createPanel.name) + } + } + } + + // --- KIND (12px between the radios here; the Add modal uses + // 16 — AddToMixtapeModal.slint:503). + Column { + width: parent.width + spacing: 8 + Text { + text: root.t("KIND") + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + font.letterSpacing: 1.5 + } + Row { + spacing: 12 + QbzRadioOption { + label: root.t("Mixtapes") + selected: createPanel.isMixtape + onClicked: if (!createPanel.creating) QbzMyQbz.createSetKind("mixtape") + } + QbzRadioOption { + label: root.t("Collections") + selected: !createPanel.isMixtape + onClicked: if (!createPanel.creating) QbzMyQbz.createSetKind("collection") + } + } + } + + // --- Footer + Row { + anchors.right: parent.right + spacing: 8 + SettingsButton { + text: root.t("Cancel") + btnHeight: 38 + minWidth: 0 + onClicked: QbzMyQbz.createClose() + } + QbzPrimaryButton { + label: createPanel.isMixtape ? root.t("+ New Mixtape") + : root.t("+ New Collection") + btnHeight: 38 + btnEnabled: createPanel.canCreate + onClicked: QbzMyQbz.createSubmit(createPanel.name) + } + } + } + } + } + + // ================================================================ + // PANEL 2 — Rename / Edit description / Delete (MyQbzEditModal.slint) + // ================================================================ + Item { + id: editPanel + anchors.fill: parent + visible: root.editOpen + + readonly property string mode: root.editDoc.mode || "" + readonly property bool isRename: editPanel.mode === "rename" + readonly property bool isDescription: editPanel.mode === "description" + readonly property bool isDelete: editPanel.mode === "delete" + readonly property bool busy: root.editDoc.busy === true + property string draftName: "" + property string draftDescription: "" + // :33-35 — busy blocks everything; rename additionally needs a + // non-empty name (Rust trims again). + readonly property bool canSubmit: editPanel.busy + ? false : (editPanel.isRename ? editPanel.draftName !== "" : true) + + // Prefill from the document (Rust fills `name` for the rename + the + // delete-confirm interpolation, `description` for the description + // body — myqbz_edit sets the drafts when it opens, which is what this + // reproduces). + // + // Keyed on mode+name, NOT on `open`: Rust may publish a second open + // document for a DIFFERENT collection without an intervening closed + // frame, and then `open` never changes and the prefill would be stale. + // Keying it on the whole document instead would clobber what the user + // is typing every time an unrelated field (`busy`) flips. + readonly property string editKey: editPanel.mode + "|" + (root.editDoc.name || "") + onEditKeyChanged: { + editPanel.draftName = root.editDoc.name || "" + editPanel.draftDescription = root.editDoc.description || "" + } + + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzMyQbz.editClose() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + Rectangle { + width: Math.min(root.width - 80, 420) + height: editCol.implicitHeight + 40 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: editCol + x: 20 + y: 20 + width: parent.width - 40 + spacing: 16 + + // --- Standard modal title + close X. + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 28) + text: editPanel.isRename ? root.t("Rename") + : editPanel.isDescription ? root.t("Edit description") + : root.t("Delete") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + opacity: editPanel.busy ? 0.5 : 1.0 + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: "x" + tintName: editCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: editCloseArea + anchors.fill: parent + enabled: !editPanel.busy + hoverEnabled: !editPanel.busy + cursorShape: editPanel.busy ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzMyQbz.editClose() + } + } + } + + // --- Body: rename + QbzLineEdit { + visible: editPanel.isRename + width: parent.width + height: visible ? 34 : 0 + text: editPanel.draftName + onEdited: function (v) { editPanel.draftName = v } + onAccepted: function (v) { + if (editPanel.canSubmit) QbzMyQbz.editSubmitRename(editPanel.draftName) + } + } + + // --- Body: description + QbzTextArea { + visible: editPanel.isDescription + width: parent.width + // A FLAT 96, never `visible ? 96 : 0`: a Column does not + // position invisible children, so the hidden arm already + // costs no layout height, while a 0-height text area + // hands its inner Flickable height = 0 - 8 - 8 = -16 — + // the silent negative-size trap QbzLineEdit.qml:166-167 + // documents. The sibling QbzLineEdit is single-line and + // lands at a clean 0, so it keeps its ternary. + height: 96 + wrapMode: TextEdit.WordWrap + text: editPanel.draftDescription + onEdited: function (v) { editPanel.draftDescription = v } + } + + // --- Body: delete confirm (:105, DOUBLE quotes around the + // name, interpolated into the msgid's `{}`). + Text { + visible: editPanel.isDelete + width: parent.width + text: root.t("Delete \"{}\"? This cannot be undone.") + .replace("{}", root.editDoc.name || "") + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + + // --- Footer + Row { + anchors.right: parent.right + spacing: 10 + SettingsButton { + text: root.t("Cancel") + btnHeight: 36 + minWidth: 0 + enabled: !editPanel.busy + onClicked: QbzMyQbz.editClose() + } + QbzPrimaryButton { + label: editPanel.isDelete ? root.t("Delete") : root.t("Save") + btnHeight: 36 + labelSize: theme.fontBody + destructive: editPanel.isDelete + btnEnabled: editPanel.canSubmit + onClicked: { + if (editPanel.isRename) + QbzMyQbz.editSubmitRename(editPanel.draftName) + else if (editPanel.isDescription) + QbzMyQbz.editSubmitDescription(editPanel.draftDescription) + else + QbzMyQbz.editConfirmDelete() + } + } + } + } + } + } + + // ================================================================ + // PANEL 3 — DJ-mix "Random queue" (MyQbzMixModal.slint) + // ================================================================ + Item { + id: mixPanel + anchors.fill: parent + visible: root.mixOpen + + readonly property bool loading: root.mixDoc.loading === true + readonly property bool busy: root.mixDoc.busy === true + readonly property var sizeOptions: root.mixDoc.sizeOptions || [] + readonly property int selectedIndex: root.mixDoc.selectedIndex || 0 + readonly property int selectedSize: root.mixDoc.selectedSize || 0 + readonly property bool selectedIsAll: root.mixDoc.selectedIsAll === true + readonly property int uniqueCount: root.mixDoc.uniqueCount || 0 + // :35-39 + readonly property bool canShuffle: !mixPanel.loading && !mixPanel.busy + && mixPanel.sizeOptions.length > 0 && mixPanel.selectedSize > 0 + // The slider travels over option INDICES, not song counts; a single + // option (only "All (N)") renders a degenerate 0..0 track (:42). + readonly property int sliderMax: Math.max(0, mixPanel.sizeOptions.length - 1) + readonly property string sizeLabel: mixPanel.selectedIsAll + ? root.t("All ({})").replace("{}", mixPanel.selectedSize) + : String(mixPanel.selectedSize) + + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzMyQbz.mixClose() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + Rectangle { + width: Math.min(root.width - 80, 440) + height: mixCol.implicitHeight + 48 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: mixCol + x: 24 + y: 24 + width: parent.width - 48 + spacing: 18 + + Text { + width: parent.width + text: root.t("Random queue") + color: theme.textPrimary + font.pixelSize: 18 + font.weight: theme.weightBold + elide: Text.ElideRight + } + Text { + width: parent.width + text: root.t("Create a random queue with songs from this collection. How many songs do you want?") + color: theme.textSecondary + font.pixelSize: 14 + wrapMode: Text.WordWrap + } + + // --- Loading (:93-106): left-aligned, 8px spacing. + Row { + visible: mixPanel.loading + height: visible ? 18 : 0 + spacing: 8 + QbzSpinner { + anchors.verticalCenter: parent.verticalCenter + size: 18 + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.t("Loading…") + color: theme.textSecondary + font.pixelSize: 14 + verticalAlignment: Text.AlignVCenter + } + } + + // --- Size field (:109-154) + Column { + visible: !mixPanel.loading && mixPanel.sizeOptions.length > 0 + width: parent.width + spacing: 8 + + Text { + text: root.t("Number of songs") + color: theme.textPrimary + font.pixelSize: 13 + font.weight: theme.weightMedium + } + Row { + width: parent.width + spacing: 12 + Text { + id: sizeValue + anchors.verticalCenter: parent.verticalCenter + width: Math.max(64, implicitWidth) + text: mixPanel.sizeLabel + color: theme.accent + font.pixelSize: 15 + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + } + QbzSlider { + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - sizeValue.width - 12) + minimum: 0 + maximum: mixPanel.sliderMax + value: mixPanel.selectedIndex + onChanged: function (v) { QbzMyQbz.mixSetIndex(v) } + } + } + Text { + width: parent.width + text: root.t("{} songs available").replace("{}", mixPanel.uniqueCount) + color: theme.textMuted + font.pixelSize: 12 + } + } + + // --- Footer: spacing 12 here, NOT 8 (:159). + Row { + anchors.right: parent.right + spacing: 12 + SettingsButton { + text: root.t("Cancel") + btnHeight: 38 + minWidth: 0 + enabled: !mixPanel.busy + onClicked: QbzMyQbz.mixClose() + } + QbzPrimaryButton { + label: root.t("Add to queue") + btnHeight: 38 + btnEnabled: mixPanel.canShuffle + onClicked: QbzMyQbz.mixShuffle() + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml b/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml new file mode 100644 index 000000000..6eb67ac9a --- /dev/null +++ b/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml @@ -0,0 +1,251 @@ +// Shared whole-collection offline-cache preflight. Rust retains the complete +// album/playlist snapshot; this surface receives only counts and labels, so a +// 5,000-track playlist never becomes a QML object graph just to ask one +// question. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + readonly property var doc: { + try { return JSON.parse(QbzOffline.collectionChoiceJson || "{}") } + catch (e) { return {} } + } + readonly property bool opened: QbzOffline.collectionChoiceOpen + + function tr(source) { return QbzSession.tr(source, QbzSession.trRev) } + function summary() { + return root.tr("{} of {} tracks in “{}” are already available offline.") + .replace("{}", String(root.doc.cachedTracks || 0)) + .replace("{}", String(root.doc.totalTracks || 0)) + .replace("{}", root.doc.title || "") + } + function restoreShellFocus() { + var p = root + while (p.parent) { + if (p.parent.isQbzShellRoot === true) { + p.parent.forceActiveFocus() + return + } + p = p.parent + } + } + function cancel() { + QbzOffline.cancelCollectionCache() + root.restoreShellFocus() + } + function choose(mode) { + QbzOffline.confirmCollectionCache(mode) + root.restoreShellFocus() + } + + visible: root.opened + enabled: root.opened + z: 3150 + + onOpenedChanged: { + if (root.opened) keyScope.forceActiveFocus() + } + + FocusScope { + id: keyScope + anchors.fill: parent + Keys.onEscapePressed: function(event) { + root.cancel() + event.accepted = true + } + } + + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: root.cancel() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + x: panel.x + y: panel.y + 8 + width: panel.width + height: panel.height + radius: theme.radiusMd + color: "#80000000" + opacity: 0.5 + } + + Rectangle { + id: panel + width: Math.min(root.width - 80, 520) + height: content.implicitHeight + 48 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceMain + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: content + x: 24 + y: 24 + width: parent.width - 48 + spacing: 12 + + Text { + width: parent.width + text: root.tr("Some tracks are already available offline") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + wrapMode: Text.WordWrap + } + Text { + width: parent.width + text: root.summary() + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + Item { width: 1; height: 2 } + + Rectangle { + width: parent.width + height: allCol.implicitHeight + 24 + radius: theme.radiusSm + color: allArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.width: 1 + border.color: allArea.containsMouse ? theme.accent : theme.borderSubtle + Row { + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + QbzIcon { + name: "refresh-cw" + width: 18 + height: 18 + anchors.verticalCenter: parent.verticalCenter + tintName: "accent" + } + Column { + id: allCol + width: parent.width - 30 + spacing: 3 + Text { + width: parent.width + text: root.doc.kind === "playlist" + ? root.tr("Download the entire playlist again") + : root.tr("Download the entire album again") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + wrapMode: Text.WordWrap + } + Text { + width: parent.width + text: root.tr("Re-downloads every track and can repair corrupt offline copies.") + color: theme.textSecondary + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + } + } + MouseArea { + id: allArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.choose("all") + } + } + + Rectangle { + width: parent.width + height: missingCol.implicitHeight + 24 + radius: theme.radiusSm + color: missingArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.width: 1 + border.color: missingArea.containsMouse ? theme.accent : theme.borderSubtle + Row { + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + QbzIcon { + name: "cloud-download" + width: 18 + height: 18 + anchors.verticalCenter: parent.verticalCenter + tintName: "accent" + } + Column { + id: missingCol + width: parent.width - 30 + spacing: 3 + Text { + width: parent.width + text: root.tr("Only missing tracks") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + Text { + width: parent.width + text: root.tr("Keeps existing offline copies and downloads tracks that are not available yet.") + color: theme.textSecondary + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + } + } + MouseArea { + id: missingArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.choose("missing") + } + } + + Item { + width: parent.width + height: 36 + Rectangle { + anchors.right: parent.right + width: cancelLabel.implicitWidth + 32 + height: 36 + radius: theme.radiusSm + color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + Text { + id: cancelLabel + anchors.centerIn: parent + text: root.tr("Cancel") + color: theme.textPrimary + font.pixelSize: theme.fontBody + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.cancel() + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml b/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml new file mode 100644 index 000000000..0cbef2fe1 --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml @@ -0,0 +1,449 @@ +// PlaylistCreateModal — "New playlist" (primitives/CreatePlaylistModal.slint). +// +// WHAT THIS REPLACES. The sidebar "+" used to call `QbzShell.createPlaylist()`, +// which created a playlist literally named "New Playlist" and navigated to it. +// Four of this panel's five fields had no other door — and one of them, the +// offline-only toggle, had no door AT ALL for an online user, so someone with a +// Qobuz session could not create a LOCAL playlist without pulling the network +// cable. `playlist_create_qt`'s header has the table. +// +// Mounted ONCE in qml/shell/AppShell.qml beside PlaylistEditModal, with no `z` +// — that file's convention is DECLARATION ORDER, and ADR-009's ">= 3000" is +// satisfied structurally. +// +// ── SHAPE, from the reference ───────────────────────────────────────────── +// Name (LineEdit, Enter submits) · Description (multiline, optional) · +// Folder (only when the user HAS folders) · "Make playlist public" · +// "Offline-only playlist" (+ hint while locked) · Cancel / Create. +// +// ── THE DRAFTS LIVE HERE ────────────────────────────────────────────────── +// Name, description, folder index, public and offline-only are QML-LOCAL and +// reach Rust only as arguments to `createSubmit(...)` — MyQbzModals.qml's +// standing rule, the same one PlaylistEditModal.qml follows. Rust owns only +// `busy`, the offline lock and the folder list. +// +// ── THE FOLDER IS SENT AS AN ID, NOT AN INDEX ───────────────────────────── +// The reference keeps two parallel arrays (`folder-options` / `folder-ids`) and +// resolves the id from the index at submit time. Here the document is a list of +// `{id, name}` objects and the id travels: parallel arrays that must stay in +// step across a process boundary are a class of bug this port does not need, +// and index 0's EMPTY id is what "no folder" means — never the index itself. +// +// ── THE FOLDER PICKER'S VISIBILITY IS THE REFERENCE'S ───────────────────── +// `folder-options.length > 1` (CreatePlaylistModal.slint:118): the row is +// absent when the only option is "No folder". Plus one thing the reference does +// not need: it also hides on the OFFLINE-ONLY arm, because `folders_qt +// ::move_playlist` keys on a Qobuz `u64` and a `local:` has none. Showing +// a picker whose choice is silently dropped is the dead-affordance class the +// port refuses; the reference never hit it because its own local arm ignores +// the field just as quietly. +// +// ── PUBLIC DIMS UNDER OFFLINE-ONLY ──────────────────────────────────────── +// A local playlist has nothing to be public ON (reference :138-146). It dims to +// 0.4 and stops taking clicks — it is not merely ignored at submit. +// +// ── `busy` HOLDS THE MODAL OPEN ─────────────────────────────────────────── +// Rust closes it, and only after the write landed. `closeCreate()` is REFUSED +// while busy, so the close X and the scrim go inert for that window rather than +// rendering and no-opping. +// +// ── ESCAPE IS A WRAPPING FocusScope, NOT A SIBLING ONE ──────────────────── +// The name field takes active focus and QML propagates an unhandled key up the +// FOCUSED item's parent chain, so a sibling scope would be dead the moment the +// field is focused (the FolderModals.qml note). +// +// ── ENTER ───────────────────────────────────────────────────────────────── +// Through QbzLineEdit's `accepted`, NEVER `committed`: committed also fires on +// focus-out, and closing the modal blurs the field, which would create twice. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // Guarded parse: a raw JSON.parse in a binding throws on the pre-publish + // frame (PlaylistView.qml:55-61). + readonly property var doc: { + try { return JSON.parse(QbzPlaylistEdit.createJson) } catch (e) { return ({}) } + } + + readonly property bool createOpen: root.doc.open === true + readonly property bool busy: root.doc.busy === true + readonly property bool offlineLocked: root.doc.offlineLocked === true + readonly property var folders: root.doc.folders || [] + + // --- Drafts ----------------------------------------------------------- + property string draftName: "" + property string draftDescription: "" + property int draftFolderIndex: 0 + property bool draftIsPublic: false + property bool draftOfflineOnly: false + + readonly property bool canCreate: root.draftName.trim() !== "" && !root.busy + // The reference's `folder-options.length > 1`, plus the local-arm gate. + readonly property bool folderPickerVisible: + root.folders.length > 1 && !root.draftOfflineOnly + + visible: root.createOpen + enabled: root.visible + + // Seeds are consumed ONCE per open. A plain republish (the `busy` flip on + // submit) must NOT reseed — it would throw away what the user typed. + onCreateOpenChanged: { + if (root.createOpen) { + root.draftName = "" + root.draftDescription = "" + root.draftFolderIndex = 0 + root.draftIsPublic = false + // Offline forces it ON and locks it (reference :184-216). + root.draftOfflineOnly = root.offlineLocked + scope.forceActiveFocus() + nameField.focusField() + } else { + root._restoreShellFocus() + } + } + + // §1.4.3 (2026-08-03 hotkeys-port contract): the FocusScope grabs focus on + // open and Qt strands it on the now-invisible scope at close, which kills + // the AppShell key dispatcher until the next click. Restore the shell root. + function _restoreShellFocus() { + var p = root + while (p.parent) { + if (p.parent.isQbzShellRoot === true) { + p.parent.forceActiveFocus() + return + } + p = p.parent + } + } + + function submit() { + if (!root.canCreate) + return + // "" is ROOT. The id comes from the row, never from the index — and on + // the local arm the picker is not even rendered, so it is "" there by + // construction rather than by a second rule. + var fid = "" + if (root.folderPickerVisible + && root.draftFolderIndex > 0 + && root.draftFolderIndex < root.folders.length) + fid = String(root.folders[root.draftFolderIndex].id || "") + QbzPlaylistEdit.createSubmit(root.draftName, root.draftDescription, + fid, root.draftIsPublic, + root.draftOfflineOnly) + } + + FocusScope { + id: scope + anchors.fill: parent + visible: root.createOpen + enabled: root.createOpen + + // Taken HERE rather than only in the open handler: an invisible item + // cannot hold active focus, and a handler on the document property is + // not ordered against the `visible` binding it shares a frame with. + onVisibleChanged: if (scope.visible) scope.forceActiveFocus() + + Keys.onEscapePressed: function (event) { + QbzPlaylistEdit.closeCreate() + event.accepted = true + } + + // `radius` is load-bearing: this Item fills AppShell's ROUNDED content + // frame and Qt Quick's clip is a rectangular scissor, so an opaque + // full-bleed child paints into the frame's four bezel corners. + // #bf000000 is Slint's #000000bf CONVERTED — Slint is #RRGGBBAA, Qt is + // #AARRGGBB. + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzPlaylistEdit.closeCreate() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + width: Math.min(root.width - 80, 420) + height: col.implicitHeight + 40 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + // FIRST child: swallows clicks so they never reach the scrim. + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: col + x: 20 + y: 20 + width: parent.width - 40 + spacing: 16 + + // ================= Title + close X ====================== + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 28) + text: root.t("New playlist") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + opacity: root.busy ? 0.5 : 1.0 + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: "x" + tintName: closeArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + // `closeCreate()` is refused while the write is in + // flight, so the affordance goes with it rather + // than lying. + enabled: !root.busy + hoverEnabled: !root.busy + cursorShape: root.busy ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzPlaylistEdit.closeCreate() + } + } + } + + // ================= Name ================================= + Column { + width: parent.width + spacing: 8 + Text { + text: root.t("Name") + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + QbzLineEdit { + id: nameField + width: parent.width + text: root.draftName + placeholder: root.t("My Playlist") + enabled: !root.busy + onEdited: function (value) { root.draftName = value } + onAccepted: function (value) { + root.draftName = value + root.submit() + } + } + } + + // ================= Description ========================== + Column { + width: parent.width + spacing: 8 + Text { + text: root.t("Description (optional)") + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + QbzTextArea { + width: parent.width + height: 80 + text: root.draftDescription + enabled: !root.busy + onEdited: function (value) { root.draftDescription = value } + } + } + + // ================= Folder =============================== + // A Column skips invisible children, so this leaves no gap + // when the user has no folders (or is on the local arm). + Column { + width: parent.width + spacing: 8 + visible: root.folderPickerVisible + Text { + text: root.t("Folder") + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + QbzSelect { + width: parent.width + popupWidth: parent.width + enabled: !root.busy + // QbzSelect takes plain labels; the ids stay on + // root.folders and are resolved by INDEX at submit. + options: root.folders.map(function (f) { return String(f.name) }) + currentIndex: root.draftFolderIndex + onSelected: function (i) { root.draftFolderIndex = i } + } + } + + // ================= Public =============================== + // Meaningless for a local playlist, so it dims AND locks while + // offline-only is on (reference :138-146). + // + // An Item with a FIXED height, not a Row: a Row's height comes + // from its tallest child, so anchoring a child to + // `parent.verticalCenter` inside one is a binding loop. + Item { + width: parent.width + height: 20 + opacity: root.draftOfflineOnly ? 0.4 : 1.0 + Item { + id: publicHit + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + QbzCheckbox { + anchors.centerIn: parent + checked: root.draftIsPublic + enabled: !root.busy && !root.draftOfflineOnly + // QbzCheckbox never self-flips; the owner of the + // state does. + onToggled: root.draftIsPublic = !root.draftIsPublic + } + } + Text { + anchors.left: publicHit.right + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.t("Make playlist public") + color: theme.textSecondary + font.pixelSize: 15 + elide: Text.ElideRight + } + } + + // ================= Offline-only ========================= + // D8: creates a LOCAL playlist (library.db, id + // `local:`) that never reaches Qobuz. Forced ON and + // LOCKED while the app is offline, with the hint under it. + Column { + width: parent.width + spacing: 6 + Item { + width: parent.width + height: 20 + opacity: root.offlineLocked ? 0.7 : 1.0 + Item { + id: offlineHit + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + QbzCheckbox { + anchors.centerIn: parent + checked: root.draftOfflineOnly + enabled: !root.busy && !root.offlineLocked + onToggled: root.draftOfflineOnly = !root.draftOfflineOnly + } + } + Text { + anchors.left: offlineHit.right + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.t("Offline-only playlist") + color: theme.textSecondary + font.pixelSize: 15 + elide: Text.ElideRight + } + } + // Hint indented to the label's left edge (20px checkbox + + // 10px spacing), so it reads as the row's description. + Text { + visible: root.offlineLocked + x: 30 + width: parent.width - 30 + text: root.t("Created locally — you're offline") + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + + // ================= Footer =============================== + Item { + width: parent.width + height: 36 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + + // Cancel — the reference's neutral button: an + // elevated fill that lifts to surface-hover. + Rectangle { + width: cancelLabel.implicitWidth + 28 + height: 36 + radius: theme.radiusSm + color: (cancelArea.containsMouse && !root.busy) + ? theme.surfaceHover : theme.surfaceElevated + opacity: root.busy ? 0.5 : 1.0 + Text { + id: cancelLabel + anchors.centerIn: parent + text: root.t("Cancel") + color: theme.textPrimary + font.pixelSize: 15 + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + enabled: !root.busy + cursorShape: root.busy ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzPlaylistEdit.closeCreate() + } + } + + // Create. The reference's `#ffffffd6` hover on an + // accent fill and its hardcoded `#ffffff` label are + // NOT copied — both wash out on the light palettes, + // which is the same call PlaylistEditModal made. + QbzPrimaryButton { + label: root.busy ? root.t("Creating...") : root.t("Create") + btnHeight: 36 + labelSize: 15 + btnEnabled: root.canCreate + onClicked: root.submit() + } + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml new file mode 100644 index 000000000..6d85f9cd7 --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml @@ -0,0 +1,413 @@ +// PlaylistEditModal — THE playlist editor (primitives/EditPlaylistModal.slint): +// rename, description, the offline-only flag and delete, for BOTH kinds of +// playlist (`local:` and a Qobuz u64) and for every surface that offers +// "Edit playlist": +// +// * the manager's PmGridCard / PmListRow / PmTreePlaylistRow pencils, +// * qml/shell/SidebarRowMenu.qml's "Edit playlist" row, +// * qml/views/PlaylistView.qml's header pencil (contract block 7) — whose +// inline rename+delete Popup this file REPLACES. That popup could express +// neither a description nor the offline-only flag, and it carried a delete +// arm, so the replacement has to cover both verbs or deleting it removes +// working functionality (D20). +// +// Mounted ONCE in qml/shell/AppShell.qml as a direct child of the root, after +// FolderModals and before QbzToast, with no `z` — that file's convention is +// DECLARATION ORDER, and ADR-009's ">= 3000" is satisfied structurally. +// +// ── NO INPUTS ────────────────────────────────────────────────────────────── +// It reads QbzPlaylistEdit.editJson (§4.6) and owns its own drafts. QML has no +// lexical scope across files, so everything it reads is declared on its own +// root or comes from a singleton. +// +// ── THE DRAFTS LIVE HERE, THE ID DOES NOT ────────────────────────────────── +// Name, description and the offline-only checkbox are QML-LOCAL and reach Rust +// only as arguments to `save(name, description, offlineOnly)` (MyQbzModals.qml's +// standing rule). The id being edited is RUST state and is never echoed back, +// so a republish can never make this modal save under the wrong playlist. +// +// ── `descLoaded` IS NOT COSMETIC ─────────────────────────────────────────── +// When Rust could not resolve the real description, the field is NOT RENDERED +// and `save` sends `None`, i.e. "leave the stored description alone". A field +// the user never saw can never overwrite stored data. The reference seeds "" +// and always writes it back, which deletes the description on every rename +// (§5.2). Do not "simplify" this into an always-visible empty field. +// +// ── `busy` HOLDS THE MODAL OPEN ──────────────────────────────────────────── +// The modal never closes itself. Rust closes it, and only after the write +// landed (D22) — a failed rename keeps what the user typed on screen and +// toasts. `close()` is REFUSED while busy, so the close X and the scrim go +// inert for that window rather than rendering and no-opping. +// +// ── NO DELETE CONFIRM, ON PURPOSE ────────────────────────────────────────── +// The reference's danger button calls `EditPlaylistActions.delete()` straight +// (EditPlaylistModal.slint:150-155), unlike the FOLDER editor, whose reference +// raises a native rfd message box that this port replaced with +// QbzConfirmModal. Adding one here would be a redesign, not a port. +// +// ── ESCAPE IS A WRAPPING FocusScope, NOT A SIBLING ONE ───────────────────── +// The name field takes active focus and QML propagates an unhandled key up the +// focused item's PARENT chain, so a sibling scope would be dead the moment the +// field is focused (the FolderModals.qml note). QbzLineEdit's TextInput leaves +// Escape unaccepted on the plain arm, so it reaches us. +// +// ── ENTER ────────────────────────────────────────────────────────────────── +// Through QbzLineEdit's `accepted`, NEVER `committed`: committed also fires on +// focus-out, and closing the modal blurs the field, which would rename twice +// (MyQbzModals.qml:34-36). +// +// ── REUSED, NOT REDRAWN ──────────────────────────────────────────────────── +// QbzLineEdit the name field. Its width MUST be set — the plain arm is +// a fixed `width: 240` (QbzLineEdit.qml:62) where the Slint +// LineEdit stretches to the panel. +// QbzTextArea the description body, the port's multiline input (its one +// prior call site is MyQBZ's edit modal, the same job). +// QbzCheckbox the offline-only row. Already 18x18 r4, accent when +// checked, 2px muted ring when not, and it never self-flips +// — exactly what a QML-local draft needs (§5.21). +// QbzPrimaryButton Save. The reference's `#ffffffd6` hover on an accent fill +// and its hardcoded `#ffffff` label are NOT copied; both +// wash out on the light palettes. +// Delete button hand-drawn, byte-for-byte the ghost danger button +// FolderEditPanel.qml uses, for the same reason: the port's +// filled danger control is a different affordance. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // Guarded parse: a raw JSON.parse in a binding throws on the pre-publish + // frame (PlaylistView.qml:55-61). + readonly property var doc: { + try { return JSON.parse(QbzPlaylistEdit.editJson) } catch (e) { return ({}) } + } + + readonly property bool editOpen: root.doc.open === true + readonly property bool busy: root.doc.busy === true + readonly property bool isLocal: root.doc.isLocal === true + readonly property bool descLoaded: root.doc.descLoaded === true + + // --- Drafts ----------------------------------------------------------- + property string draftName: "" + property string draftDescription: "" + property bool draftOfflineOnly: false + + readonly property bool canSave: root.draftName.trim() !== "" && !root.busy + + visible: root.editOpen + enabled: root.visible + + // Seeds are consumed ONCE per open, and again when the editor is pointed + // at a different playlist without closing. A plain republish (the `busy` + // flip on save) must NOT reseed — it would throw away what the user typed. + readonly property string seedKey: + (root.editOpen ? "1" : "0") + "|" + (root.doc.id || "") + + onSeedKeyChanged: { + if (root.editOpen) { + root.draftName = root.doc.name || "" + root.draftDescription = root.doc.description || "" + root.draftOfflineOnly = root.doc.offlineOnly === true + scope.forceActiveFocus() + nameField.focusField() + } + } + + // §1.4.3 (2026-08-03 hotkeys-port contract): the FocusScope grabs focus + // on open and Qt strands it on the now-invisible scope at close, which + // kills the AppShell key dispatcher until the next click. Restore the + // shell root — the dispatcher's fallback focus item — on the close edge. + onEditOpenChanged: if (!root.editOpen) root._restoreShellFocus() + + function _restoreShellFocus() { + var p = root + while (p.parent) { + if (p.parent.isQbzShellRoot === true) { + p.parent.forceActiveFocus() + return + } + p = p.parent + } + } + + function submit() { + if (root.canSave) + QbzPlaylistEdit.save(root.draftName, root.draftDescription, + root.draftOfflineOnly) + } + + FocusScope { + id: scope + anchors.fill: parent + visible: root.editOpen + enabled: root.editOpen + + // Taken HERE rather than only in the open handler: an invisible item + // cannot hold active focus, and a handler on the document property is + // not ordered against the `visible` binding it shares a frame with. + onVisibleChanged: if (scope.visible) scope.forceActiveFocus() + + Keys.onEscapePressed: function (event) { + QbzPlaylistEdit.close() + event.accepted = true + } + + // `radius` is load-bearing: this Item fills AppShell's ROUNDED content + // frame and Qt Quick's clip is a rectangular scissor, so an opaque + // full-bleed child paints into the frame's four bezel corners + // (AppShell.qml:246-310). #bf000000 is Slint's #000000bf CONVERTED — + // Slint is #RRGGBBAA, Qt is #AARRGGBB. + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzPlaylistEdit.close() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + width: Math.min(root.width - 80, 420) + height: col.implicitHeight + 40 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + // FIRST child: swallows clicks so they never reach the scrim. + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: col + x: 20 + y: 20 + width: parent.width - 40 + spacing: 16 + + // ================= Title + close X ====================== + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 28) + text: root.t("Edit playlist") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + opacity: root.busy ? 0.5 : 1.0 + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: "x" + tintName: closeArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + // `close()` is refused while a write is in flight, + // so the affordance goes with it rather than lying. + enabled: !root.busy + hoverEnabled: !root.busy + cursorShape: root.busy ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzPlaylistEdit.close() + } + } + } + + // ================= Name ================================= + // No label above it — the reference has none here, only the + // placeholder (EditPlaylistModal.slint:66). + QbzLineEdit { + id: nameField + width: parent.width + text: root.draftName + placeholder: root.t("Playlist name") + enabled: !root.busy + onEdited: function (value) { root.draftName = value } + onAccepted: function (value) { + root.draftName = value + root.submit() + } + } + + // ================= Cover ================================ + // The shared editor is reached from the manager, sidebar and + // detail header, so cover editing belongs here too. Actions + // stay on the trailing modal grid; the native picker owns its + // own preview and the playlist surfaces repaint after write. + Item { + width: parent.width + height: 34 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.t("Cover") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + SettingsButton { + minWidth: 0 + text: root.t("Change cover") + iconName: "image-plus" + enabled: !root.busy + onClicked: QbzPlaylistEdit.chooseCover() + } + SettingsButton { + minWidth: 0 + text: root.t("Remove cover") + iconName: "trash-2" + danger: true + enabled: !root.busy + onClicked: QbzPlaylistEdit.removeCover() + } + } + } + + // ================= Description ========================== + // Rendered ONLY when Rust resolved the real one. A Column + // skips invisible children, so this leaves no gap. + Column { + width: parent.width + spacing: 6 + visible: root.descLoaded + Text { + text: root.t("Description") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + QbzTextArea { + width: parent.width + height: 90 + text: root.draftDescription + enabled: !root.busy + onEdited: function (value) { root.draftDescription = value } + } + } + + // ================= Offline-only ========================= + // LOCAL playlists only — the flag is a `local_playlists` + // column with no Qobuz analogue. Unmark it to make "Upload to + // Qobuz" available (D8). + // An Item with a FIXED height, not a Row: a Row's height comes + // from its tallest child, so anchoring a child to + // `parent.verticalCenter` inside one is a binding loop. Same + // shape FolderEditPanel.qml's hidden row uses. + Item { + width: parent.width + height: 20 + visible: root.isLocal + // 20x20 wrapper: the larger hit target the reference's + // TouchArea gives the 18px box (§5.21). + Item { + id: offlineHit + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + QbzCheckbox { + anchors.centerIn: parent + checked: root.draftOfflineOnly + enabled: !root.busy + // QbzCheckbox never self-flips; the owner of the + // state does. + onToggled: root.draftOfflineOnly = !root.draftOfflineOnly + } + } + Text { + anchors.left: offlineHit.right + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.t("Offline-only playlist") + color: theme.textSecondary + font.pixelSize: 15 + elide: Text.ElideRight + } + } + + // ================= Footer =============================== + Item { + width: parent.width + height: 36 + + // Delete — a ghost danger button: fill on hover only, 1px + // danger border, danger label. theme.danger / + // theme.dangerHover ARE the reference's #ef4444 / + // #ef444433 under the default theme (the engine defaults + // danger to rgb(ef,44,44) and derives danger_hover as a + // 0.2 tint, i.e. alpha 0x33) and the correct colours under + // the other 35. + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: delLabel.implicitWidth + 28 + height: 36 + radius: 8 + color: (delArea.containsMouse && !root.busy) ? theme.dangerHover + : "transparent" + border.width: 1 + border.color: theme.danger + opacity: root.busy ? 0.5 : 1.0 + Text { + id: delLabel + anchors.centerIn: parent + text: root.t("Delete") + color: theme.danger + font.pixelSize: 15 + font.weight: theme.weightMedium + } + MouseArea { + id: delArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.busy ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: if (!root.busy) QbzPlaylistEdit.deletePlaylist() + } + } + + QbzPrimaryButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + label: root.t("Save") + btnHeight: 36 + labelSize: 15 + btnEnabled: root.canSave + onClicked: root.submit() + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistImportModal.qml b/crates/qbz-qt/qml/controls/PlaylistImportModal.qml new file mode 100644 index 000000000..d8349612d --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistImportModal.qml @@ -0,0 +1,938 @@ +// "Import Playlist" — QML port of +// crates/qbz-ui/ui/primitives/PlaylistImportModal.slint (itself a 1:1 port of +// Tauri's PlaylistImportModal.svelte). +// +// Two-step flow: URL entry -> provider auto-detect (RUST-side, one round trip +// per keystroke, exactly as the reference does) -> fetch preview -> rename + +// optional folder -> import with a live progress bar, a status line and an +// append-only log, then an in-panel Summary block. Every interpolated string +// arrives PRE-FORMATTED in the document (playlist_import_qt.rs); this file +// renders them verbatim and never pluralises or interpolates a count itself. +// +// Mounted ONCE in AppShell next to PlaylistPickerModal, NOT inside the sidebar +// (05 §5.8.5): the two surfaces that open it — the sidebar `...` menu and the +// closed-sidebar flyout — have no lifetime guarantee across a navigation, and +// closing the modal must never cancel an in-flight import. +// +// CLOSE SEMANTICS (reference §1.8): the footer Close disables while loading +// (Tauri-verbatim); the header X and the scrim stay clickable mid-import — +// closing only hides the UI, the tokio task runs to completion and still +// toasts, assigns the folder and refreshes the sidebar. +// +// OFFLINE is gated in TWO layers (05 §5.8.7), both required: the sidebar row is +// inert while `QbzSession.offline`, AND this modal carries its own banner and a +// `canFetch` that Rust computes as `provider detected && !offline`. The URL +// field stays enabled — only the fetch is refused. +// +// PROVIDER MARKS. Spotify / Apple Music / Deezer are self-coloured marks and +// render UNTINTED out of qml/assets/brand/, the rule SourceIcon.qml calls +// load-bearing. Tidal's upstream asset is a black `fill` with no colour to +// lose, so it goes through QbzIcon at `textPrimary` instead: the reference +// hardcodes it to white (PlaylistImportModal.slint:271-279) which is +// illegible on the 11 light themes, and a monochrome glyph is exactly what the +// tint table is for. The header's Qobuz mark is the port's existing +// `qobuz-logo-filled.svg` brand asset for the same reason — the reference tints +// its outline logo white, which disappears on a light `surface-card`. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + // An unopened overlay is an invisible, non-interactive Item and costs + // nothing (the AppShell mount comment states the contract). + visible: doc.open === true + + readonly property var doc: JSON.parse(QbzPlaylistImport.importJson) + readonly property var logRows: doc.log || [] + readonly property bool loading: doc.loading === true + readonly property bool showPreview: doc.showPreview === true + readonly property bool importCompleted: doc.importCompleted === true + readonly property var folderOptions: doc.folderOptions || [] + + // --- Source picker (2.0.3 expansion) ---------------------------------- + // 0 URL · 1 Playlist file · 2 JSON · 3 ListenBrainz · 4 Last.fm. The order + // is `playlist_import_qt::source_kind`; the labels come from the document + // pre-localized, like every other string here. + readonly property int sourceIndex: doc.sourceIndex || 0 + readonly property bool srcUrl: root.sourceIndex === 0 + readonly property bool srcFile: root.sourceIndex === 1 + readonly property bool srcJson: root.sourceIndex === 2 + readonly property bool srcLb: root.sourceIndex === 3 + readonly property bool srcLastfm: root.sourceIndex === 4 + /// File and JSON share their whole block except the caveat line. + readonly property bool srcAnyFile: root.srcFile || root.srcJson + readonly property bool srcAnyService: root.srcLb || root.srcLastfm + + // Bumped by every QbzPlaylistImport.open(). The reference remounts the + // whole Svelte component per open and Slint reproduces it by clearing 14 + // properties; here Rust resets its document and this counter tells the two + // QML-local text mirrors to drop what the user last typed (05 §5.8.3). + readonly property int resetSeq: doc.resetSeq || 0 + onResetSeqChanged: { + urlInput.text = "" + nameInput.text = "" + serviceInput.text = "" + } + + // Absolute qrc prefix for the brand SVGs — same rule as QbzIcon: a relative + // URL resolves against the CONSUMER's document depth. + readonly property string brandDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/brand/" + + QbzTheme { id: theme } + + // Scrim — click outside dismisses, EVEN MID-IMPORT (§1.8: closing only + // hides the UI, the task keeps running). + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzPlaylistImport.close() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + id: card + anchors.centerIn: parent + // Tauri: 560px max, min-height 420px, max-height 90vh. + width: Math.min(parent.width - 80, 560) + // 140 = header 68 + 2 hairlines + footer 70 (16/34/20); the body + // scrolls inside the Flickable past the 90 % clamp. + height: Math.min(Math.max(420, bodyCol.implicitHeight + 140), parent.height * 0.9) + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + // Swallow clicks so they never reach the scrim. + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + readonly property int contentWidth: card.width - 48 + + // ---------------------------- header ---------------------------- + Item { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 68 + + Image { + id: qobuzMark + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + width: 26 + height: 26 + source: root.brandDir + "qobuz-logo-filled.svg" + fillMode: Image.PreserveAspectFit + sourceSize.width: 52 + sourceSize.height: 52 + smooth: true + opacity: 0.9 + } + Text { + anchors.left: qobuzMark.right + anchors.leftMargin: 10 + anchors.right: closeX.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Import Playlist", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + // X close — allowed mid-import (§1.8). + Item { + id: closeX + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: closeXArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeXArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzPlaylistImport.close() + } + } + } + Rectangle { + id: headerDiv + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: theme.borderSubtle + } + + // ---------------------------- footer ---------------------------- + // Declared before the body so the body can anchor to the divider; the + // footer is pinned to the card bottom either way. + Item { + id: footerBar + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 70 + + Row { + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.bottom: parent.bottom + anchors.bottomMargin: 20 + spacing: 12 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Close", QbzSession.trRev) + btnHeight: 34 + minWidth: 0 + // Tauri-verbatim: the FOOTER close disables while loading; + // the header X and the scrim do not. + enabled: !root.loading + onClicked: QbzPlaylistImport.close() + } + // The single accent confirm (ADR-008). Step A fetches, step B + // imports; it stays visible-but-disabled after completion. + QbzPrimaryButton { + anchors.verticalCenter: parent.verticalCenter + btnHeight: 34 + label: root.showPreview + ? (root.loading ? QbzSession.tr("Importing...", QbzSession.trRev) + : QbzSession.tr("Import", QbzSession.trRev)) + : (root.loading ? QbzSession.tr("Fetching...", QbzSession.trRev) + : QbzSession.tr("Fetch playlist", QbzSession.trRev)) + btnEnabled: root.showPreview + ? (!root.loading && !root.importCompleted) + : (root.doc.canFetch === true && !root.loading) + onClicked: { + if (root.showPreview) + QbzPlaylistImport.execute() + else + QbzPlaylistImport.fetch() + } + } + } + } + Rectangle { + id: footerDiv + anchors.bottom: footerBar.top + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: theme.borderSubtle + } + + // ----------------------------- body ----------------------------- + // Scrolls when the progress panel outgrows the card. + Flickable { + id: bodyFlick + anchors.top: headerDiv.bottom + anchors.bottom: footerDiv.top + anchors.left: parent.left + anchors.right: parent.right + clip: true + contentWidth: width + contentHeight: bodyCol.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + Column { + id: bodyCol + width: card.width + padding: 24 + spacing: 16 + + // --- Offline banner --------------------------------------- + // The fetch is gated (Rust drives `canFetch` false); the URL + // input stays enabled, exactly as the reference does. + // + // The shared control, with the deltas BlacklistManagerView.qml + // already records for it: the copy goes in `title` because that + // is the TONE-coloured line, the glyph is `info` rather than + // `cloud-off`, and the tone alphas are 10 %/30 % against the + // reference's #eab3081a / #eab3084d (identical arithmetic, the + // theme's warning token instead of a literal). + WarningBanner { + width: card.contentWidth + visible: QbzSession.offline + variant: "warning" + title: QbzSession.tr("This feature requires internet connection", QbzSession.trRev) + } + + // --- Error banner (preview or execute failure) ------------- + WarningBanner { + width: card.contentWidth + visible: (root.doc.error || "") !== "" + variant: "error" + title: root.doc.error || "" + } + + // --- Source picker ---------------------------------------- + // The one control that is always visible. Everything below it + // is one source's block; they are mutually exclusive and a + // Column skips invisible children, so exactly one appears and + // nothing leaves a gap. + Column { + width: card.contentWidth + spacing: 8 + Text { + text: QbzSession.tr("Source", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + QbzSelect { + width: parent.width + popupWidth: parent.width + enabled: !root.loading + options: root.doc.sourceOptions || [] + currentIndex: root.sourceIndex + onSelected: function (i) { QbzPlaylistImport.sourceChanged(i) } + } + } + + // --- URL input -------------------------------------------- + // Every keystroke routes the provider detection through Rust + // (the reference does the same because Slint 1.16 strings have + // no `.contains`; here it is what keeps ONE detector — the + // crate's `detect_provider_key` — as the source of truth + // instead of a second JS copy that would drift). + Column { + width: card.contentWidth + spacing: 8 + visible: root.srcUrl + Text { + text: QbzSession.tr("Playlist URL", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + // The standard 43px input box (the PlaylistPickerModal + // recipe — QbzLineEdit is 34px, commits only on Enter/blur + // in its plain arm and has no `enabled` gate, so it cannot + // serve a per-keystroke field that must disable mid-fetch). + Rectangle { + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: urlInput.activeFocus ? theme.accent : theme.borderSubtle + opacity: root.loading ? 0.5 : 1.0 + + TextInput { + id: urlInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: !root.loading + onTextEdited: QbzPlaylistImport.urlEdited(text) + onAccepted: { + if (!root.showPreview && root.doc.canFetch === true && !root.loading) + QbzPlaylistImport.fetch() + } + // Re-seed from the document while the field is NOT + // being edited (the PlaylistPickerModal shape): user + // typing breaks a plain `text:` binding for good, so + // a reset would never reach the field. + Binding { + target: urlInput + property: "text" + value: root.doc.url || "" + when: !urlInput.activeFocus + } + } + Text { + // Placeholder. Plain string: not localized in the + // reference either (it is a URL shape, not copy). + visible: urlInput.text === "" + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: Text.AlignVCenter + text: "https://open.spotify.com/playlist/..." + color: theme.textMuted + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + } + } + + // --- Allowed sources -------------------------------------- + // The detected provider's mark at full opacity, the rest dimmed + // (the reference's §1.5 homologation: no glow / translate / + // scale, opacity 0.45 idle / 1.0 active). + Column { + width: card.contentWidth + spacing: 8 + // URL ONLY. Four dimmed streaming logos over a file picker + // would say the file has to come from one of them. + visible: root.srcUrl + Text { + text: QbzSession.tr("ALLOWED SOURCES", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + font.letterSpacing: 0.5 + } + Row { + spacing: 10 + Image { + width: 70 + height: 24 + source: root.brandDir + "spotify-logo.svg" + fillMode: Image.PreserveAspectFit + sourceSize.width: 140 + sourceSize.height: 48 + smooth: true + opacity: root.doc.activeProvider === "spotify" ? 1.0 : 0.45 + } + Image { + width: 70 + height: 24 + source: root.brandDir + "apple-music-logo.svg" + fillMode: Image.PreserveAspectFit + sourceSize.width: 140 + sourceSize.height: 48 + smooth: true + opacity: root.doc.activeProvider === "apple" ? 1.0 : 0.45 + } + // Monochrome mark -> the tint table, so it reads on the + // dark AND the light themes (see the file header). + QbzIcon { + width: 70 + height: 24 + name: "tidal-tidal" + tintName: "textPrimary" + opacity: root.doc.activeProvider === "tidal" ? 1.0 : 0.45 + } + Image { + width: 70 + height: 24 + source: root.brandDir + "deezer-logo.svg" + fillMode: Image.PreserveAspectFit + sourceSize.width: 140 + sourceSize.height: 48 + smooth: true + opacity: root.doc.activeProvider === "deezer" ? 1.0 : 0.45 + } + } + } + + // --- File / JSON block ------------------------------------ + // ONLY THE TRACK LIST IS READ. The disclaimer is not fine + // print: a user handing over an .m3u reasonably expects the + // referenced audio to be imported, and it never is. It is a + // persistent row next to the picker, not a tooltip. + Column { + width: card.contentWidth + spacing: 8 + visible: root.srcAnyFile + + Row { + spacing: 12 + // Ghost button, the port's neutral secondary shape. + Rectangle { + width: pickLabel.implicitWidth + 28 + height: 36 + radius: theme.radiusSm + color: (pickArea.containsMouse && !root.loading) + ? theme.surfaceHover : theme.surfaceElevated + opacity: root.loading ? 0.5 : 1.0 + Text { + id: pickLabel + anchors.centerIn: parent + text: QbzSession.tr("Choose file…", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontLink + } + MouseArea { + id: pickArea + anchors.fill: parent + hoverEnabled: true + enabled: !root.loading + cursorShape: root.loading ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzPlaylistImport.pickFile() + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, card.contentWidth - pickLabel.implicitWidth - 40) + text: (root.doc.pickedFileName || "") !== "" + ? root.doc.pickedFileName + : QbzSession.tr("No file selected", QbzSession.trRev) + color: (root.doc.pickedFileName || "") !== "" + ? theme.textPrimary : theme.textMuted + font.pixelSize: theme.fontLink + elide: Text.ElideMiddle + } + } + + Text { + width: parent.width + wrapMode: Text.WordWrap + text: QbzSession.tr("Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + + // JSON carries one extra caveat: the parse is best-effort + // and the preview COUNT is the user's gate on it. + Text { + visible: root.srcJson + width: parent.width + wrapMode: Text.WordWrap + text: QbzSession.tr("JSON is read best-effort — check the track count below before importing.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + + // --- ListenBrainz / Last.fm block ------------------------- + // One handle field for both, because they take the same thing: + // a public username or a URL. Nothing is authenticated; a + // connected account only PREFILLS the field. + Column { + width: card.contentWidth + spacing: 8 + visible: root.srcAnyService + + Text { + text: root.srcLb + ? QbzSession.tr("Username or playlist URL", QbzSession.trRev) + : QbzSession.tr("Username or profile URL", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + // The same 43px box recipe as the URL field, for the same + // reason: QbzLineEdit commits on Enter/blur and this must + // recompute per keystroke. + Rectangle { + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: serviceInput.activeFocus ? theme.accent : theme.borderSubtle + opacity: root.loading ? 0.5 : 1.0 + + TextInput { + id: serviceInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: !root.loading + onTextEdited: QbzPlaylistImport.serviceInputEdited(text) + onAccepted: { + if (!root.showPreview && root.doc.canFetch === true && !root.loading) + QbzPlaylistImport.fetch() + } + Binding { + target: serviceInput + property: "text" + value: root.doc.serviceUser || "" + when: !serviceInput.activeFocus + } + } + Text { + visible: serviceInput.text === "" + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: Text.AlignVCenter + text: QbzSession.tr("Enter a public username.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + } + + // Last.fm PROFILE -> pick one of three stations. A + // PLAYLIST url (lastfmMode 1) skips this entirely and + // imports what the URL names. + Column { + width: parent.width + spacing: 8 + visible: root.srcLastfm && (root.doc.lastfmMode || 0) === 0 + Text { + text: QbzSession.tr("Station", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + QbzSelect { + width: parent.width + popupWidth: parent.width + enabled: !root.loading + options: root.doc.stationOptions || [] + currentIndex: root.doc.stationIndex || 0 + onSelected: function (i) { QbzPlaylistImport.setStationIndex(i) } + } + } + + // ListenBrainz USERNAME -> its "created for you" list. A + // pasted /playlist/ resolves on its own, so the + // picker only appears once there is something to pick. + Column { + width: parent.width + spacing: 8 + visible: root.srcLb && (root.doc.lbPlaylistOptions || []).length > 0 + Text { + text: QbzSession.tr("Playlist", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + QbzSelect { + width: parent.width + popupWidth: parent.width + enabled: !root.loading + options: root.doc.lbPlaylistOptions || [] + currentIndex: root.doc.lbPlaylistIndex || 0 + onSelected: function (i) { QbzPlaylistImport.setLbPlaylistIndex(i) } + } + } + Text { + visible: root.srcLb && root.doc.lbListLoading === true + text: QbzSession.tr("Loading...", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + + // --- Customization panel (step B) ------------------------- + // Rename + optional folder. Editing the URL reverts to step A + // (`showPreview` recomputes Rust-side on every keystroke). + Rectangle { + width: card.contentWidth + visible: root.showPreview + height: customCol.implicitHeight + radius: theme.radiusMd + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + id: customCol + width: parent.width + padding: 16 + spacing: 12 + + Column { + width: parent.width - 32 + spacing: 8 + Text { + text: QbzSession.tr("Playlist name", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + Rectangle { + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle + opacity: nameInput.enabled ? 1.0 : 0.5 + + TextInput { + id: nameInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: !root.loading && !root.importCompleted + onTextEdited: QbzPlaylistImport.nameEdited(text) + onAccepted: { + // Enter here executes (step B). + if (!root.loading && !root.importCompleted) + QbzPlaylistImport.execute() + } + // Rust prefills this with the fetched + // playlist name, so the re-seed is not just + // a reset path here — it is how the name + // arrives at all. + Binding { + target: nameInput + property: "text" + value: root.doc.customName || "" + when: !nameInput.activeFocus + } + } + } + } + + // Folder dropdown — only when the user HAS folders + // (index 0 is always "No folder", so length > 1). + Column { + width: parent.width - 32 + spacing: 8 + visible: root.folderOptions.length > 1 + Text { + text: QbzSession.tr("Folder", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + QbzSelect { + options: root.folderOptions + currentIndex: root.doc.folderIndex || 0 + menuWidth: parent.width + // The reference caps its menu at 380px; here the + // control is full-width, so the popup matches it. + popupWidth: parent.width + searchable: root.folderOptions.length > 8 + onSelected: function (i) { QbzPlaylistImport.setFolderIndex(i) } + } + } + } + } + + // --- Progress panel --------------------------------------- + // Spinner header, determinate bar, status + current-track + // lines, the append-only log, and the Summary block. + Rectangle { + width: card.contentWidth + visible: root.doc.progressVisible === true + height: progressCol.implicitHeight + radius: theme.radiusMd + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + id: progressCol + width: parent.width + padding: 16 + spacing: 10 + + Item { + width: parent.width - 32 + height: 20 + Text { + anchors.left: parent.left + anchors.right: spinnerSlot.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Conversion progress", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + elide: Text.ElideRight + } + // Tauri: a 14px accent ring while loading. + Item { + id: spinnerSlot + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 14 + height: 14 + QbzSpinner { + anchors.centerIn: parent + size: 14 + visible: root.loading + spinning: root.loading + } + } + } + + Column { + width: parent.width - 32 + spacing: 6 + visible: root.doc.hasProgress === true + + // Determinate 6px bar (the LocalLibrarySettings + // recipe); the track uses the subtle border tone + // because the panel is already surface-elevated. + Rectangle { + width: parent.width + height: 6 + radius: 3 + color: theme.borderSubtle + clip: true + Rectangle { + width: parent.width * Math.max(0, Math.min(1, root.doc.progress || 0)) + height: parent.height + radius: 3 + color: theme.accent + } + } + // Status line — the 12px Tauri metric, fixed height + // (the CJK floor femtovg needed; harmless here and + // it keeps the panel from reflowing per event). + Text { + width: parent.width + height: 18 + text: root.doc.statusLine || "" + color: theme.textSecondary + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + // Current-track line — "Artist - Title" while + // matching, "Part i/n" while adding. + Text { + width: parent.width + visible: (root.doc.currentTrack || "") !== "" + height: visible ? 16 : 0 + text: root.doc.currentTrack || "" + color: theme.textMuted + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + // Append-only log. The height is static arithmetic over + // the ROW COUNT, never row geometry (the reference calls + // that out as a recursion footgun). No autoscroll — + // Tauri parity. Render-only rows: no MouseAreas here. + Flickable { + width: parent.width - 32 + visible: root.logRows.length > 0 + height: visible ? Math.min(root.logRows.length * 20, 160) : 0 + contentWidth: width + contentHeight: root.logRows.length * 20 + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + width: parent.width + Repeater { + model: root.logRows + delegate: Text { + required property var modelData + width: parent ? parent.width : 0 + height: 20 + text: modelData.message || "" + // Hex literals kept — the reference + // hardcodes them (Tauri did too), and + // the port has no success/danger glyph + // ramp for a 13px log row. + color: modelData.status === "success" ? "#34d399" + : (modelData.status === "error" ? "#f87171" : theme.textMuted) + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + } + + // Summary block — pre-formatted lines, hairline + // separated, "" = hidden. + Column { + width: parent.width - 32 + spacing: 6 + visible: (root.doc.summaryPlaylist || "") !== "" + + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + Text { + text: QbzSession.tr("Summary", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + Text { + width: parent.width + height: 18 + text: root.doc.summaryPlaylist || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: parent.width + height: 18 + text: root.doc.summaryMatched || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: parent.width + height: 18 + text: root.doc.summarySkipped || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + // Only when the SOURCE repeated a track. A + // duplicate is not a skip, and folding it into one + // is what made a 453-of-469 match read as 198. + Text { + width: parent.width + visible: (root.doc.summaryDuplicates || "") !== "" + height: visible ? 18 : 0 + text: root.doc.summaryDuplicates || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: parent.width + visible: (root.doc.summaryParts || "") !== "" + height: visible ? 18 : 0 + text: root.doc.summaryParts || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + } + } + } + + // The 14px gutter scrollbar, inset 4px from the right — the port's + // shared control, attached as a SIBLING of the Flickable (it is an + // Item with a `target`, not a QtQuick.Controls ScrollBar). + QbzScrollBar { + target: bodyFlick + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: bodyFlick.top + anchors.bottom: bodyFlick.bottom + } + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml b/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml new file mode 100644 index 000000000..250310e1c --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml @@ -0,0 +1,900 @@ +// "Add to playlist" picker — QML port of +// crates/qbz-ui/ui/primitives/PlaylistPickerModal.slint (+ its +// PlaylistDuplicateConfirmModal sibling, folded in here because the two +// share one document and one carried track set). +// +// A focus-opened dropdown lists the user's playlists; clicking a row only +// SELECTS it (the selection becomes the input's placeholder); the footer +// button fires the actual add. "+ Create new playlist" is always the first +// dropdown row and reveals an inline name field confirmed by the same +// footer button ("Create & Add"). +// +// Mounted ONCE in AppShell next to AddToMixtapeModal: every host that opens +// it (the queue footer and row menu today) can be unmounted by the time the +// user picks a target, so the modal must outlive them. +// +// Data: QbzPlaylistPicker.pickerJson (playlist_picker_qt.rs PickerDoc). The +// name filter runs in RUST (the QbzMyQbzAdd.search convention), so the row +// list arrives already filtered; the 8-row window and the overflow line are +// applied here against `total`/`playlists.length`. +// +// LOCAL playlists are listed alongside the Qobuz ones (a hard-drive glyph +// leads the row, `isLocal` in the document) and are the ONLY rows while +// offline — a Qobuz playlist cannot be written to without a session, so +// offering one would be a dead target (D3/D11). The carried payload is +// likewise either Qobuz catalog ids or LocalLibrary refs; `localMode` only +// changes the counter's wording ("Adding {} local track(s)"), never the row +// list — a local selection can legitimately target a Qobuz playlist, which +// Rust routes through the library.db sidecar. + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + // An unopened overlay is an invisible, non-interactive Item and costs + // nothing (the AppShell mount comment states the contract). + visible: doc.open === true || dup.open === true + + readonly property var doc: JSON.parse(QbzPlaylistPicker.pickerJson) + readonly property var rows: doc.playlists || [] + readonly property var dup: doc.dup || ({}) + readonly property int trackCount: doc.trackCount || 0 + // Session-local pinned target (last successful add); null when unset, + // deleted, unwritable, or filtered out by the current query. + readonly property var lastUsed: doc.lastUsed || null + // Containment strip: writable playlists already holding carried tracks, + // honest per alreadyState ("loading"|"complete"|"updating"|"unavailable"). + readonly property var alreadyIn: doc.alreadyIn || [] + readonly property string alreadyState: doc.alreadyState || "" + // The column renders only when it can back a claim: complete with + // matches, or updating (where partial matches show under an explicit + // caveat). It is a SIDE column, not an in-flow strip, so the dropdown + // can never cover it. + readonly property bool alreadyShowing: + root.alreadyState === "updating" + || (root.alreadyState === "complete" && root.alreadyIn.length > 0) + // The carried payload is LocalLibrary refs (counter wording only). + readonly property bool localMode: doc.localMode === true + readonly property bool creatingOpen: doc.creatingOpen === true + readonly property bool creating: doc.creating === true + + // Rows past the 8-row window collapse into the "{} more playlist(s)..." + // line, exactly as the .slint's filter-rank window does. + readonly property int windowSize: 8 + readonly property int shown: Math.min(rows.length, windowSize) + readonly property int overflow: Math.max(0, rows.length - windowSize) + + // --- Local selection state (the .slint keeps these on the `if open:` + // subtree so a remount resets them; here they are reset when `open` + // flips, which is the same lifetime without a conditional Loader). --- + property bool dropdownOpen: false + property string selectedId: "" + property string selectedLabel: "" + property string filterText: "" + property string createName: "" + + onVisibleChanged: if (!visible) resetLocals() + function resetLocals() { + dropdownOpen = false + selectedId = "" + selectedLabel = "" + filterText = "" + createName = "" + } + + function selectRow(row) { + selectedId = row.id + selectedLabel = (row.tracksLine || "") === "" + ? row.name : row.name + " (" + row.tracksLine + ")" + // The reference clears the query on selection (Tauri §2.4). + filterText = "" + QbzPlaylistPicker.search("") + QbzPlaylistPicker.showCreate(false) + dropdownOpen = false + } + + function confirm() { + if (root.creating) + return + if (root.creatingOpen) { + if (createName.trim() !== "") + QbzPlaylistPicker.createAndAdd(createName) + } else if (selectedId !== "") { + QbzPlaylistPicker.pick(selectedId) + } + } + + QbzTheme { id: theme } + + // ======================= The picker ================================== + Item { + anchors.fill: parent + visible: root.doc.open === true && root.dup.open !== true + + // Scrim — click outside dismisses. The wheel is eaten too: these + // overlays are plain Items, not Popups, so no modal grab stops the + // page underneath from scrolling (the DiscoverConfigModal contract). + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzPlaylistPicker.close() + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + id: card + anchors.centerIn: parent + width: Math.min(parent.width - 80, 735) + // Tauri: min-height 400 / max-height 90vh. The body stretches so + // the footer pins to the bottom. + height: Math.min(Math.max(400, panel.implicitHeight), parent.height * 0.9) + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + // NO clip: the dropdown legitimately overflows the card bottom. + + // The "Already in" side column's slot. Zero when absent, so the + // main content takes the whole card. + readonly property real rightPanelW: root.alreadyShowing ? 221 : 0 + + // Swallow clicks so they never reach the scrim; doubles as + // "a click inside the card closes the list" (Tauri parity). + // The wheel stops here too — same reason as the scrim. + MouseArea { + anchors.fill: parent + onClicked: root.dropdownOpen = false + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: panel + width: parent.width + + // --- Header ------------------------------------------- + Item { + width: parent.width + height: 68 + Text { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: closeX.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Add to Playlist", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + id: closeX + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: closeArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzPlaylistPicker.close() + } + } + } + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + + // --- Body (the MAIN column; the "Already in" side column + // takes the right slot when present) ------------------- + Column { + id: body + width: parent.width - card.rightPanelW + padding: 24 + spacing: 16 + + // Loading — the playlists are still on the wire. + Item { + width: parent.width - 48 + height: visible ? 30 : 0 + visible: root.doc.loading === true + QbzSpinner { + anchors.horizontalCenter: parent.horizontalCenter + size: 30 + } + } + + Column { + width: parent.width - 48 + spacing: 8 + + Item { + width: parent.width + height: 20 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Choose playlist", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + // Two msgids, not one with an inserted word: the + // .slint counter switches the whole string on + // `local-mode` so every locale can order it its + // own way. + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: (root.localMode + ? QbzSession.tr("Adding {} local track(s)", QbzSession.trRev) + : QbzSession.tr("Adding {} track(s)", QbzSession.trRev)) + .replace("{}", root.trackCount) + color: theme.textMuted + font.pixelSize: theme.fontLink + } + } + + // The search / selection input (43px, the Tauri metric). + Rectangle { + id: inputBox + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle + + TextInput { + id: searchInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 36 // room for the clear chip + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: root.doc.loading !== true + text: root.filterText + onTextEdited: { + root.filterText = text + root.dropdownOpen = true // typing re-opens + QbzPlaylistPicker.search(text) + } + onAccepted: root.confirm() + // Tauri: the list opens on focus. + onActiveFocusChanged: if (activeFocus) root.dropdownOpen = true + // Re-seed while not being edited (the + // AddToMixtapeModal Binding shape). + Binding { + target: searchInput + property: "text" + value: root.filterText + when: !searchInput.activeFocus + } + } + // Placeholder, doubling as the SELECTION label — + // the filter is cleared on select, so the field is + // empty exactly when the label should show. + Text { + visible: searchInput.text === "" + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 36 + verticalAlignment: Text.AlignVCenter + text: root.creatingOpen + ? QbzSession.tr("+ Create new playlist", QbzSession.trRev) + : (root.selectedLabel !== "" + ? root.selectedLabel + : QbzSession.tr("Search playlists...", QbzSession.trRev)) + color: theme.textMuted + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + // Clear-selection chip — only with a selection (or + // the inline-create state) and the list closed. + Rectangle { + visible: (root.selectedId !== "" || root.creatingOpen) + && !root.dropdownOpen + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + radius: 10 + color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 12 + height: 12 + tintName: chipArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.selectedId = "" + root.selectedLabel = "" + root.createName = "" + QbzPlaylistPicker.showCreate(false) + } + } + } + } + } + + // Inline create — a second form group BELOW the picker + // (Tauri §1.4.6); the footer button confirms it. + Column { + width: parent.width - 48 + spacing: 8 + visible: root.creatingOpen + Text { + text: QbzSession.tr("Playlist Name", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + Rectangle { + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle + TextInput { + id: nameInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: !root.creating + text: root.createName + onTextEdited: root.createName = text + onAccepted: root.confirm() + Binding { + target: nameInput + property: "text" + value: root.createName + when: !nameInput.activeFocus + } + } + Text { + visible: nameInput.text === "" + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: Text.AlignVCenter + text: QbzSession.tr("My Playlist", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + } + } + + } + } + + // --- "Already in" side column ----------------------------- + // The containment answer as a right-hand column instead of an + // in-flow strip: the dropdown overlays the MAIN column only, so + // this list stays readable while the playlist list is open. + // Shown if and only if the index can back a claim (complete with + // matches, or updating under the explicit caveat) — an empty + // answer from an incomplete index renders nothing. + Item { + visible: root.alreadyShowing + anchors.top: parent.top + anchors.topMargin: 69 + anchors.bottom: footerDivider.top + anchors.right: parent.right + width: card.rightPanelW + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 1 + color: theme.borderSubtle + } + + Column { + anchors.fill: parent + anchors.leftMargin: 17 + anchors.rightMargin: 16 + anchors.topMargin: 16 + spacing: 8 + + Text { + text: QbzSession.tr("Already in", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + Text { + visible: root.alreadyState === "updating" + width: parent.width + text: QbzSession.tr("Playlist index updating...", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.italic: true + wrapMode: Text.WordWrap + } + + ListView { + width: parent.width + height: Math.max(0, parent.height - y - 12) + clip: true + spacing: 2 + boundsBehavior: Flickable.StopAtBounds + model: root.alreadyIn + delegate: Rectangle { + required property var modelData + width: parent ? parent.width : 0 + height: 34 + radius: theme.radiusSm + color: alreadyArea.containsMouse + ? theme.surfaceHover : "transparent" + QbzIcon { + id: alreadyGlyph + visible: modelData.isLocal === true + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + name: "hard-drive" + width: 12 + height: 12 + tintName: "muted" + } + Text { + anchors.left: alreadyGlyph.visible + ? alreadyGlyph.right : parent.left + anchors.leftMargin: 6 + anchors.right: alreadyCount.visible + ? alreadyCount.left : parent.right + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: modelData.name + color: theme.textPrimary + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + // The multi-track answer: "2 of 5". A full or + // single-track containment says it by presence. + Text { + id: alreadyCount + visible: root.trackCount > 1 + && modelData.contained < root.trackCount + anchors.right: parent.right + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("{} of {}", QbzSession.trRev) + .replace("{}", modelData.contained) + .replace("{}", root.trackCount) + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + MouseArea { + id: alreadyArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // Selecting it is the useful action: "add the + // missing 3 of 5" is one click plus the + // dup-confirm's only-new answer. + onClicked: root.selectRow(modelData) + } + } + } + } + } + + // --- Footer, pinned to the card bottom -------------------- + // Keep a real footer band around the modal-standard 36px + // controls. The old divider was anchored directly to the Row's + // top edge, so both buttons physically touched the separator. + Item { + id: footerBar + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 69 + + Row { + id: footerRow + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Cancel", QbzSession.trRev) + btnHeight: 36 + minWidth: 0 + onClicked: QbzPlaylistPicker.close() + } + // The single accent confirm (ADR-008). Disabled until + // valid; QbzPrimaryButton supplies the active theme's + // primary fill and matching on-accent label colour. + QbzPrimaryButton { + anchors.verticalCenter: parent.verticalCenter + btnHeight: 36 + labelSize: 15 + label: root.creating + ? QbzSession.tr("Saving...", QbzSession.trRev) + : (root.creatingOpen + ? QbzSession.tr("Create & Add", QbzSession.trRev) + : QbzSession.tr("Add", QbzSession.trRev)) + btnEnabled: !root.creating + && (root.creatingOpen + ? root.createName.trim() !== "" + : root.selectedId !== "") + onClicked: root.confirm() + } + } + } + Rectangle { + id: footerDivider + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: footerBar.top + height: 1 + color: theme.borderSubtle + } + + // --- Dropdown overlay ------------------------------------- + // Declared LAST inside the card so it paints above the body and + // the footer (declaration order IS z-order in QML), and it + // legitimately overflows the unclipped card bottom. + Rectangle { + id: dropdown + visible: root.dropdownOpen && root.doc.loading !== true + x: 24 + // Anchored below the input by static arithmetic over fixed + // metrics — never an absolute-position binding: header 68 + + // 1 divider + 24 body pad + 20 label row + 8 spacing + + // 43 input + 4 gap. + y: 68 + 1 + 24 + 20 + 8 + 43 + 4 + // MAIN column only — the "Already in" side column stays + // readable while the list is open (that is its whole point). + width: parent.width - card.rightPanelW - 48 + // create row 41 + 1 divider, pinned last-used 41 + 1, 41 per + // shown row, 41 no-results, 1 + 34 overflow. Tauri max-height + // 320 + border. + height: Math.min(2 + 42 + (root.lastUsed !== null ? 42 : 0) + + root.shown * 41 + + ((root.rows.length === 0 && root.filterText !== "") ? 41 : 0) + + (root.overflow > 0 ? 35 : 0), 322) + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + // Keep clicks on the list chrome from reaching the card + // swallower, which would close the list under the pointer. + MouseArea { anchors.fill: parent } + + Flickable { + anchors.fill: parent + anchors.margins: 1 + contentWidth: width + contentHeight: dropInner.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: dropInner + width: parent.width + + // "+ Create new playlist" — ALWAYS first, never + // filtered out (Tauri parity). + Rectangle { + width: parent.width + height: 41 + color: createArea.containsMouse ? theme.surfaceHover : "transparent" + Text { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: Text.AlignVCenter + text: QbzSession.tr("+ Create new playlist", QbzSession.trRev) + color: theme.accent + font.pixelSize: theme.fontLink + font.weight: theme.weightMedium + elide: Text.ElideRight + } + MouseArea { + id: createArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.selectedId = "" + root.selectedLabel = "" + root.filterText = "" + QbzPlaylistPicker.search("") + QbzPlaylistPicker.showCreate(true) + root.dropdownOpen = false + } + } + } + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + + // Session's last successful target, pinned above the + // results. Rust already validated it (still listed, + // still writable) and de-duplicated it from `rows`; + // under a search it only survives a matching query. + Rectangle { + visible: root.lastUsed !== null + width: parent.width + height: visible ? 41 : 0 + color: lastUsedArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + id: lastUsedGlyph + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + name: "clock" + width: 14 + height: 14 + tintName: "muted" + } + Text { + anchors.left: lastUsedGlyph.right + anchors.leftMargin: 8 + anchors.right: lastUsedCaption.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: root.lastUsed ? root.lastUsed.name : "" + color: theme.textPrimary + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + Text { + id: lastUsedCaption + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Last playlist", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.italic: true + } + MouseArea { + id: lastUsedArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.selectRow(root.lastUsed) + } + } + Rectangle { + visible: root.lastUsed !== null + width: parent.width + height: visible ? 1 : 0 + color: theme.borderSubtle + } + + Repeater { + model: root.rows.slice(0, root.windowSize) + delegate: Rectangle { + required property var modelData + width: parent ? parent.width : 0 + height: 41 + color: rowArea.containsMouse ? theme.surfaceHover : "transparent" + // LOCAL playlists (library.db) keep a small + // hard-drive glyph LEADING the name, muted — + // PlaylistPickerModal.slint:62-69. It is what + // tells a local target apart from a Qobuz one + // in a list that mixes both. + QbzIcon { + id: localGlyph + visible: modelData.isLocal === true + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + name: "hard-drive" + width: 14 + height: 14 + tintName: "muted" + } + Text { + anchors.left: localGlyph.visible ? localGlyph.right : parent.left + anchors.leftMargin: localGlyph.visible ? 8 : 12 + anchors.right: countText.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: modelData.name + color: theme.textPrimary + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + Text { + id: countText + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: modelData.tracksLine || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.selectRow(modelData) + } + } + } + + Text { + visible: root.rows.length === 0 && root.filterText !== "" + width: parent.width + height: visible ? 41 : 0 + text: QbzSession.tr("No playlists found", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + // Overflow line — the rows past the 8-row window. + Rectangle { + visible: root.overflow > 0 + width: parent.width + height: visible ? 1 : 0 + color: theme.borderSubtle + } + Text { + visible: root.overflow > 0 + width: parent.width + height: visible ? 34 : 0 + text: QbzSession.tr("{} more playlist(s)...", QbzSession.trRev) + .replace("{}", root.overflow) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.italic: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } + } + } + + // ============= Duplicate-confirm sub-modal =========================== + // Opens INSTEAD of adding when the chosen playlist already holds some of + // the carried tracks. It replaces the picker rather than stacking on it + // (the picker's own state is already parked in Rust). + Item { + anchors.fill: parent + visible: root.dup.open === true + + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzPlaylistPicker.cancelDuplicates() + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + anchors.centerIn: parent + width: Math.min(parent.width - 80, 460) + height: dupPanel.implicitHeight + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + MouseArea { + anchors.fill: parent + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: dupPanel + width: parent.width + padding: 24 + spacing: 16 + + Item { + width: parent.width - 48 + height: 28 + Text { + anchors.left: parent.left + anchors.right: dupClose.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Some of these tracks were already added", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + id: dupClose + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: dupCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: dupCloseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzPlaylistPicker.cancelDuplicates() + } + } + } + + Text { + width: parent.width - 48 + text: QbzSession.tr("This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates.", QbzSession.trRev) + .replace("{}", root.dup.duplicateCount || 0) + .replace("{}", root.trackCount) + color: theme.textSecondary + font.pixelSize: theme.fontLink + wrapMode: Text.WordWrap + } + + Row { + anchors.right: parent.right + anchors.rightMargin: 24 + spacing: 12 + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: root.dup.busy === true + ? QbzSession.tr("Adding...", QbzSession.trRev) + : QbzSession.tr("Add all tracks", QbzSession.trRev) + btnHeight: 34 + minWidth: 0 + enabled: root.dup.busy !== true + onClicked: QbzPlaylistPicker.confirmDuplicates(true) + } + QbzPrimaryButton { + anchors.verticalCenter: parent.verticalCenter + btnHeight: 34 + label: root.dup.busy === true + ? QbzSession.tr("Adding...", QbzSession.trRev) + : QbzSession.tr("Add only new tracks", QbzSession.trRev) + btnEnabled: root.dup.busy !== true + onClicked: QbzPlaylistPicker.confirmDuplicates(false) + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml b/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml new file mode 100644 index 000000000..8369182a3 --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml @@ -0,0 +1,92 @@ +// "Filter by category" trigger for the Home rail of Qobuz Playlists — port of +// `crates/qbz-ui/ui/discover/PlaylistTagFilter.slint:66-118`. +// +// 220x30, radius 6, NO outline: the reference states the size contract +// explicitly ("sm variant — matches QbzSelect { sm: true } used by the toolbar +// selects: 30px tall, 6px radius, no outline"), so this is the small toolbar +// control, not the 34px browse-header one that BrowseGenreButton draws. +// +// Extracted rather than inlined into HomeView for the reason BrowseGenreButton +// states about itself: one control, drawn identically wherever it is mounted. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + property int count: 0 + signal clicked() + + // The selected tag NAMES, for the applied-filters tooltip. The host has + // them (it holds the tag catalogue); this control only knows the count. + property var selectedNames: [] + + QbzTheme { id: theme } + + width: 220 + height: 30 + radius: 6 + // Translucent at rest under the dynamic background, like every other chip + // in this toolbar band (PlaylistTagFilter.slint:77). + color: tagArea.containsMouse + ? theme.surfaceHover + : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + + QbzFilterTip { + id: tagTip + ownerKey: "home-playlist-tags" + anchor: root + groups: root.selectedNames.length > 0 + ? [{ group: QbzSession.tr("Category", QbzSession.trRev), + values: root.selectedNames }] + : [] + } + + Row { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + QbzIcon { + name: "list-filter" + width: 13 + height: 13 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + Text { + width: parent.width - 13 - 14 - 2 * parent.spacing + anchors.verticalCenter: parent.verticalCenter + text: root.count > 0 + ? QbzSession.tr("{} selected", QbzSession.trRev) + .replace("{}", root.count) + : QbzSession.tr("Filter by category", QbzSession.trRev) + color: root.count > 0 ? theme.textPrimary : theme.textSecondary + font.pixelSize: 13 + elide: Text.ElideRight + } + QbzIcon { + name: "chevron-down" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + } + + MouseArea { + id: tagArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + onEntered: tagTip.enter() + onExited: tagTip.exit() + onPressed: tagTip.exit() + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistTagFilterPopup.qml b/crates/qbz-qt/qml/controls/PlaylistTagFilterPopup.qml new file mode 100644 index 000000000..7d59297b9 --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistTagFilterPopup.qml @@ -0,0 +1,210 @@ +// Qobuz-Playlists category filter — the dropdown of the Home rail's +// "Filter by category" trigger. Port of +// `crates/qbz-ui/ui/discover/PlaylistTagFilter.slint:120-183`. +// +// The filter is CLIENT-side over the 40 cards already in hand: the tags ship in +// the same /discover/index response as the playlists, so opening this costs no +// request. The selection is a UNION (a card passes if it carries ANY selected +// slug) and an empty selection shows everything. +// +// The selection lives in RUST (`home_qt::toggle_playlist_tag`), not in a +// property here or on HomeView: this view is destroyed on every navigation, so +// a QML-side selection would be forgotten the moment the user opened an album +// and came back. Slint keeps it in `TAB_SECTIONS` for the same reason and says +// so: "Client-side; survives a tab switch." +// +// ANCHORING. The trigger sits INSIDE the Home flickable, at a y that depends on +// the scroll position and on the rail order the Discover configurator dictates, +// so the fixed `anchorTop` of GenreFilterPopup is not available here. The +// position is captured by `mapToItem` AT OPEN and stored as NUMBERS — never a +// reference to the trigger — so a rebuilt rail cannot dangle under an open +// popup. Same rule, same reason, as controls/QbzTooltip.qml:127-133. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + // [{ slug, name }] — the offered set, from QbzHome.playlistTagsJson. + property var tags: [] + // Selected slugs, from the same document. + property var selected: [] + + readonly property bool opened: root._open + property bool _open: false + property real _x: 0 + property real _y: 0 + + signal toggled(string slug) + signal cleared() + + QbzTheme { id: theme } + + // 220 = the trigger's width (PlaylistTagFilter.slint:69), so the card lines + // up under it exactly. + readonly property int cardW: 220 + readonly property int rowH: 32 + readonly property int footerH: 32 + readonly property int listMax: 320 + readonly property real listH: Math.min(root.tags.length * root.rowH, root.listMax) + readonly property real cardH: root.listH + root.footerH + 10 + + anchors.fill: parent + visible: root._open + z: 3000 + + function openAt(item) { + if (!item) + return + var p = item.mapToItem(root, 0, 0) + // 4px of air under the trigger (PlaylistTagFilter.slint:122), clamped + // so the card never leaves the window. + root._x = Math.max(8, Math.min(root.width - root.cardW - 8, p.x)) + root._y = Math.max(8, Math.min(root.height - root.cardH - 8, + p.y + item.height + 4)) + root._open = true + } + function close() { + root._open = false + } + function toggle(item) { + if (root._open) + root.close() + else + root.openAt(item) + } + + // Click-out backdrop. Declared FIRST so the card below covers it. + MouseArea { + anchors.fill: parent + onClicked: root.close() + // A MouseArea eats presses, NOT wheel events. Without this the page + // behind an open popup kept scrolling under the pointer. + onWheel: function (wheel) { wheel.accepted = true } + } + + Rectangle { + x: Math.round(root._x) + y: Math.round(root._y) + width: root.cardW + height: root.cardH + color: theme.surfaceMain + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderMuted + clip: true + + // Swallow clicks so the backdrop does not close the card, and the + // wheel so a scroll over the footer does not reach the page. + MouseArea { + anchors.fill: parent + onWheel: function (wheel) { wheel.accepted = true } + } + + Flickable { + id: listFlick + x: 0 + y: 5 + width: parent.width + height: root.listH + contentWidth: width + contentHeight: listCol.height + boundsBehavior: Flickable.StopAtBounds + clip: true + + Column { + id: listCol + width: listFlick.width + + Repeater { + model: root.tags + delegate: Rectangle { + id: tagRow + width: listCol.width + height: root.rowH + color: tagArea.containsMouse ? theme.surfaceHover : "transparent" + + readonly property string slug: modelData.slug || "" + readonly property bool picked: + (root.selected || []).indexOf(tagRow.slug) >= 0 + + Row { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Rectangle { + width: 16 + height: 16 + anchors.verticalCenter: parent.verticalCenter + radius: 4 + color: tagRow.picked ? theme.accent : "transparent" + border.width: tagRow.picked ? 0 : 1.5 + border.color: theme.textMuted + QbzIcon { + anchors.centerIn: parent + visible: tagRow.picked + name: "check" + width: 11 + height: 11 + // The reference hardcodes #ffffff + // (PlaylistTagFilter.slint:43). This port + // does not: white on a pale accent measures + // under 2.6:1 on 16 of the 35 palettes, so + // every check in this build takes the + // measured selector instead. + tintName: theme.accentGlyphTint + } + } + Text { + width: parent.width - 24 - parent.spacing + anchors.verticalCenter: parent.verticalCenter + text: modelData.name || "" + color: theme.textPrimary + font.pixelSize: 12 + font.weight: tagRow.picked ? theme.weightMedium + : theme.weightRegular + elide: Text.ElideRight + } + } + + MouseArea { + id: tagArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggled(tagRow.slug) + } + } + } + } + } + + // "All categories" — clears the selection. Dimmed and inert when there + // is nothing to clear (PlaylistTagFilter.slint:159-180). + Item { + width: parent.width + height: root.footerH + y: root.listH + 5 + opacity: (root.selected || []).length > 0 ? 1.0 : 0.5 + + Text { + anchors.centerIn: parent + text: QbzSession.tr("All categories", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 12 + } + MouseArea { + anchors.fill: parent + enabled: (root.selected || []).length > 0 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.cleared() + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/PmFolderIcon.qml b/crates/qbz-qt/qml/controls/PmFolderIcon.qml new file mode 100644 index 000000000..55da635cf --- /dev/null +++ b/crates/qbz-qt/qml/controls/PmFolderIcon.qml @@ -0,0 +1,117 @@ +// PmFolderIcon — the folder icon tile (primitives/PmFolderIcon.slint, 48 +// lines): a rounded colour square carrying either one of the seven lucide +// preset glyphs or a user-picked custom image. +// +// It lives in qml/controls/, not in qml/views/playlistmanager/, because it is +// shared chrome rather than a view part: the three manager consumers today are +// the folder card, the folder chip and the tree folder row, and the folder +// EDITOR (controls/FolderEditPanel.qml) is the fourth the moment it grows a +// live preview tile — the reference's FolderEditModal.slint has none, so the +// port draws none either (rule 5: no control the reference does not have). A +// view-part directory would make a control import a view (contract D12). +// +// ── FLAT FIELDS, NOT A FOLDER OBJECT ─────────────────────────────────────── +// The reference takes one `PmFolderItem` struct. Here the publishers carry +// DIFFERENT shapes — `QbzPlaylistManager.foldersJson` entries (§4.1) and +// `QbzFolderEdit.editJson` (§4.5, which additionally carries the swatch and +// preset constants and the editor's busy flag, and whose `iconColor` is NOT +// validity-gated because it is a swatch SEED, not a paint) — so passing "the +// folder object" would mean one of them silently handing over the wrong keys. +// QML does not report that: a property the target type does not declare is an +// ignored line, and a key the object does not carry is `undefined`. The +// interface is therefore the six flat fields of contract §2.5 and nothing +// else. +// +// ── THE GLYPH SITS UNDER THE IMAGE, NOT OPPOSITE IT ──────────────────────── +// The reference pairs `if has-custom-image: Image` with +// `if !has-custom-image: QbzIcon`, so a tile whose image fails to decode — or +// has not decoded yet — is a bare coloured square. Here the glyph is always +// drawn and the image is layered over it: identical once the image loads, +// degrades to the preset glyph when it does not (§5.20). +// +// ── TINT IS "white", DELIBERATELY ────────────────────────────────────────── +// A fixed #ffffff, matching the reference's hardcoded tint — NOT +// "textPrimary". The host is a saturated, user-chosen colour tile under every +// theme, and a theme-following tint would paint a dark glyph on it on the 11 +// light themes. ("primary" is the legacy alias of "white"; QbzIcon.qml:55-65 +// says do not add new call sites for it.) +// +// ── NO `clip: true` ──────────────────────────────────────────────────────── +// `Rectangle { radius; clip: true }` does NOT round in Qt Quick — `clip` is a +// rectangular scissor. The fill Rectangle rounds itself and RoundedImage does +// the image's corners with a mask. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + /// Edge length. The root binds BOTH dimensions off it — the reference's + /// component does the same, and a sketch that omits it renders 0x0. + property int size: 40 + /// One of the seven ids in §4.5. ANY unknown value renders "folder", which + /// is also where the id "folder" itself lands: it is not a case in the + /// reference's id -> glyph chain at all and falls through to the default + /// arm. Both are upstream intent; both are kept. + property string iconPreset: "folder" + /// "" means "use the theme accent"; only read when `hasColor` is true. + property string iconColor: "" + /// False when the stored value was empty, a gradient or a CSS var — the + /// validity gate lives in RUST (§5.20), so a stored gradient string can + /// never reach `Qt.color()` here. + property bool hasColor: false + /// `icon_type == "custom" && custom_image_path.is_some()` — BOTH, decided + /// in Rust. A stale path is reachable, so this is never derived from the + /// path alone. + property bool hasCustomImage: false + /// A percent-encoded `file://…` URL, "" when there is none. A bare + /// `/home/...` string would be resolved against this component's `qrc:` + /// base and fail silently (D25). + property string customImagePath: "" + + /// Glyph size and corner radius are DERIVED, because §2.5 gives this + /// control `size` as its only geometry input. The three reference call + /// sites pass 64/32/12 (grid card, PlaylistManagerView.slint:314), + /// 36/20/8 (list chip, :397) and 28/15/6 (tree row, :903); the bands below + /// reproduce all three exactly and interpolate sanely in between. Both are + /// plain properties rather than readonly ones, so a call site that wants + /// to pin the reference numbers can just assign them. + property int glyphSize: root.size >= 56 ? Math.round(root.size * 0.5) + : root.size >= 32 ? Math.round(root.size * 0.556) + : Math.round(root.size * 0.536) + property int tileRadius: root.size >= 56 ? 12 : root.size >= 32 ? 8 : 6 + + QbzTheme { id: theme } + + width: root.size + height: root.size + radius: root.tileRadius + color: root.hasColor && root.iconColor !== "" ? root.iconColor : theme.accent + + QbzIcon { + anchors.centerIn: parent + width: root.glyphSize + height: root.glyphSize + tintName: "white" + name: root.iconPreset === "heart" ? "heart" + : root.iconPreset === "star" ? "star" + : root.iconPreset === "music" ? "music" + : root.iconPreset === "disc" ? "disc" + : root.iconPreset === "library" ? "library" + // The id/glyph mismatch is upstream intent, not a typo: the preset + // persisted as "headphones" has always drawn audio-lines. + : root.iconPreset === "headphones" ? "audio-lines" + : "folder" + } + + RoundedImage { + anchors.fill: parent + visible: root.hasCustomImage && root.customImagePath !== "" + source: (root.hasCustomImage && root.customImagePath !== "") + ? root.customImagePath : "" + radius: root.tileRadius + fit: "crop" + } +} diff --git a/crates/qbz-qt/qml/controls/QbzAlphaStrip.qml b/crates/qbz-qt/qml/controls/QbzAlphaStrip.qml new file mode 100644 index 000000000..bd4ab1320 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzAlphaStrip.qml @@ -0,0 +1,91 @@ +// Vertical A-Z jump strip (favorites/FavoritesView.slint:398 `AlphaStrip`; +// LocalLibraryView.slint:577 is a 1:1 lift of the same component). 18px +// gutter, 15px rows, 9px semibold muted letters that go accent on hover. +// Emits jump(ordinal, index); the host scrolls its own view — exactly like +// the Slint, which scrolls proportionally on the grouped grids (:1996) and by +// row index on the uniform track list (:1687). +// +// PROMOTED from views/local/AlphaStrip.qml, unchanged except for the import +// depth — same move QbzMultiSelectBar made and for the same reason: the Slint +// component was always shared (Local Library albums/artists/tracks AND the +// Library favorites tabs), so the "local" folder was a naming accident. The +// old views/local/AlphaStrip.qml is GONE; this file is the only copy. +// Call sites: views/local/{LocalAlbumsTab,LocalArtistsTab,LocalTracksTab}.qml, +// views/library/{LibraryAlbumsList}.qml and views/LibraryView.qml. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + /// [{ letter, index }] — index = the row ordinal in the flat model. + property var jumps: [] + /// Paint the stable # A-Z affordance even when a query has no row for a + /// letter. Missing buckets are deliberately inert and dimmed; present + /// buckets retain their exact native/global row index. + property bool completeAlphabet: false + signal jump(int ordinal, int index) + + function entryAt(position) { + if (!root.completeAlphabet) + return (root.jumps || [])[position] || ({ "letter": "", "index": -1 }) + var targets = ({}) + var source = root.jumps || [] + for (var i = 0; i < source.length; i++) { + var letter = String(source[i].letter || "#").toUpperCase() + if (letter < "A" || letter > "Z") letter = "#" + if (targets[letter] === undefined) + targets[letter] = Number(source[i].index) + } + var letter = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".charAt(position) + return { "letter": letter, + "index": targets[letter] === undefined ? -1 : targets[letter] } + } + + QbzTheme { id: theme } + + width: 18 + + Column { + anchors.centerIn: parent + spacing: 0 + Repeater { + // An integer model makes the full rail structural: it always + // creates exactly 27 delegates, regardless of when the async + // jump document lands or how a JS-array binding is invalidated. + model: root.completeAlphabet ? 27 : (root.jumps || []).length + delegate: Item { + id: cell + required property int index + readonly property var entry: root.entryAt(index) + width: 18 + // A complete rail is 27 entries (# + A-Z). Keep the reference + // 15px row when it fits, then compress uniformly on short + // windows instead of painting letters outside the viewport. + height: Math.max(1, Math.min(15, + root.height / Math.max(1, root.completeAlphabet + ? 27 : (root.jumps || []).length))) + Text { + anchors.fill: parent + text: cell.entry.letter + color: cell.entry.index < 0 ? theme.alphaTier(24) + : cellArea.containsMouse ? theme.accent : theme.textMuted + font.pixelSize: Math.min(9, Math.max(7, parent.height - 1)) + font.weight: theme.weightSemibold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + MouseArea { + id: cellArea + anchors.fill: parent + enabled: cell.entry.index >= 0 + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.jump(cell.index, cell.entry.index) + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/QbzCheckbox.qml b/crates/qbz-qt/qml/controls/QbzCheckbox.qml new file mode 100644 index 000000000..6bc6306ce --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzCheckbox.qml @@ -0,0 +1,71 @@ +// QbzCheckbox (primitives/QbzCheckbox.slint) — 18px square, r4, accent fill +// with an accent-text check when on, muted border when off. Emits toggled(); +// like the Slint original it never self-flips (the owner of the state does). +// +// Used by Settings > Local Library (folder selection) and the Plex library +// picker, i.e. every place the Slint uses its own QbzCheckbox. + +import QtQuick +import "../theme" + +Rectangle { + // Explicit density opt-in; desktop geometry remains the default. + property bool kioskHost: false + + id: root + + property bool checked: false + signal toggled() + + QbzTheme { id: theme } + + width: kioskHost ? 44 : 18 + height: kioskHost ? 44 : 18 + radius: 4 + color: root.checked ? theme.accent : "transparent" + border.width: root.activeFocus ? 2 : (root.checked ? 0 : 2) + border.color: root.activeFocus + ? (root.checked ? theme.accentGlyphColor : theme.accent) + : theme.textMuted + activeFocusOnTab: root.enabled + Accessible.role: Accessible.CheckBox + Accessible.checked: root.checked + Accessible.onToggleAction: if (root.enabled) root.toggled() + + Keys.onPressed: function (event) { + if (root.enabled && !event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.toggled() + event.accepted = true + } + } + + QbzIcon { + anchors.centerIn: parent + visible: root.checked + name: "check" + // The glyph sits ON the accent fill. primitives/QbzCheckbox.slint:24 + // says `tint: Theme.accent-text` and this was 1:1 with it; it now + // goes through the selector, which RETURNS accent-text on 34 of the + // 35 palettes — so this is not a divergence from :24 so much as a + // floor under it. The one row it changes is rose-pine-dawn, where + // accent-text #575279 on accent #d7827e is 2.56:1, under the 3:1 + // non-text floor, and the selector hands back black at 7.38:1. + // Routing every on-accent glyph through one property is also what + // stops this control and views/local/SelectCheck.qml from drifting + // apart again — upstream contradicts itself between the two + // (SelectionCheckbox.slint:37 hardcodes #ffffff for the same job). + tintName: theme.accentGlyphTint + width: 12 + height: 12 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: root.forceActiveFocus() + onClicked: root.toggled() + } +} diff --git a/crates/qbz-qt/qml/controls/QbzCircleAction.qml b/crates/qbz-qt/qml/controls/QbzCircleAction.qml new file mode 100644 index 000000000..466b326c0 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzCircleAction.qml @@ -0,0 +1,219 @@ +// QbzCircleAction — the circular header action, BOTH arms of Slint +// primitives/CircleAction.slint. Consolidated in phase 22 from THREE copies +// (AlbumView.CircleBtn ≡ ArtistView.CircleBtn, PlaylistView.CircleBtn = +// superset with the `primary` + `btnEnabled` arms). +// +// Numbers, read off primitives/CircleAction.slint: +// :24 diameter — primary 44, secondary 32 (the hierarchy lives HERE, not +// in the callers; the port had 40 and was uniformly small). Compact +// headers may deliberately collapse the primary to the secondary +// footprint without giving up its accent palette. +// :66 glyph — primary 19, secondary 15; the compact primary follows the +// secondary footprint here too. +// :48 ON-SURFACE arm (`overlay: false`, the default): primary = accent +// disc, secondary = surface-elevated disc, hover/active surface-hover +// :60 ring — border-strong (the port used border-muted) +// :70 glyph tint — active accent; else primary WHITE, secondary +// text-primary. The port painted the on-surface primary glyph BLACK, +// which is the OTHER arm's colour. +// :56 OVERLAY arm (`overlay: true`) — for buttons sitting on the dark +// artwork atmosphere (the album / artist header once the header +// gradient is on): primary = solid white disc with a BLACK glyph, +// secondary = #ffffff24 fill (#ffffff3d hover/active), 1.5px +// #ffffffcc ring, white glyph. +// NOTE: the card hover-overlay circles are still the separate +// CardOverlayButton.qml — same palette, different sizing contract. +// +// --- LIGHT-THEME LEGIBILITY (Slint 2.0.2, "CircleAction buttons legible on +// --- light themes" — docs/release-2.0.2/CHANGELOG.md:213) ---------------- +// The `on-surface` arm IS that fix: CircleAction.slint:30-37 exists exactly +// because "the default light-on-dark palette (white fills/rings/icons) +// vanishes on light themes' plain surface". Its secondary glyph is +// `Theme.text-primary` (CircleAction.slint:73) — the theme's max-contrast +// colour, DARK on a light theme. +// +// The port could not express that at first: icon tints were PRE-BAKED SVG +// variants only, and the "primary" bake is a hardcoded `fill="#ffffff"` — it +// is NOT text-primary, it is white. So the on-surface secondary (a +// `surface-elevated` disc, i.e. near-white on a light theme) painted a WHITE +// glyph on it: the exact vanishing act the .slint arm was written to stop. +// The stopgap was `onSurfaceTint = theme.isDark ? "primary" : "black"`, a +// 2-value approximation of text-primary. +// +// It is no longer needed. QbzIcon resolves theme-following tints through +// src/icon_tint_qt.rs, which bakes the live theme's ACTUAL text-primary hex, +// so this arm now says "textPrimary" and is exact under all 36 themes. +// +// The OVERLAY arm keeps its literals: it sits on hosts that are +// theme-independent — the artwork band, the solid white disc — and Slint +// hardcodes them for the same reason (CircleAction.slint:70-74, `#ffffff` / +// `#000000` literals next to `Theme.text-primary`). It spells them +// "white"/"black" now, not "primary", so no one re-reads the legacy alias as +// the theme token. +// +// The on-surface PRIMARY glyph USED to be in that group and is not any more. +// Its host is NOT theme-independent — it is `theme.accent` — and the fixed +// #ffffff CircleAction.slint:72 puts on it is unreadable on 16 of the 35 +// shipped palettes (worst: high-contrast 1.70:1, then ikari 1.74, wcag-dark +// 1.82). It now takes `theme.accentGlyphTint`, the measured +// on-accent selector; see the note at the tint binding and the long rationale +// in theme/QbzTheme.qml. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + property string name: "" + property bool active: false + property bool primary: false + /// Preserve the primary action palette while using the secondary action's + /// footprint. Album headers use this when their global compact mode is on. + property bool compactPrimary: false + /// Optional footprint for exceptionally dense hosts. Zero keeps the + /// canonical 44/32px sizes; compact album headers use 28px for every + /// action so Play no longer dominates the row. + property int diameterOverride: 0 + property bool btnEnabled: true + /// While the action's work is in flight the glyph swaps for a spinning + /// arc (CircleAction.slint:18 / :77 — the reference has this arm and the + /// port did not, so every slow header action read as a dead click). + /// Clicks are gated for the duration: a second click cannot start the + /// same work twice, and it cannot re-open a dropdown over a request the + /// user already made. + property bool loading: false + /// Light-on-dark palette for a button painted over artwork/atmosphere + /// (CircleAction.slint's default arm; `on-surface: false` there). + property bool overlay: false + signal clicked(var mouse) + + QbzTheme { id: theme } + + /// Slint's `Theme.text-primary` glyph tint (CircleAction.slint:73), + /// runtime-tinted so it is the real token under every theme. + readonly property string surfaceTint: "textPrimary" + + readonly property bool largePrimary: primary && !compactPrimary + readonly property int resolvedDiameter: diameterOverride > 0 + ? diameterOverride : (largePrimary ? 44 : 32) + + width: resolvedDiameter + height: resolvedDiameter + radius: width / 2 + color: overlay + ? (primary + ? (cbArea.containsMouse && btnEnabled ? "#d6ffffff" : "#ffffff") + : ((cbArea.containsMouse || active) ? "#3dffffff" : "#24ffffff")) + : (primary + ? (cbArea.containsMouse && btnEnabled ? theme.accentHover : theme.accent) + // Resting fill translucent under the dynamic background + // (CircleAction.slint:53-55). The overlay variant above sits on + // artwork and keeps its white scale. + : ((cbArea.containsMouse || active) + ? theme.surfaceHover + : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated))) + border.width: primary ? 0 : 1.5 + border.color: overlay ? "#ccffffff" : theme.borderStrong + // A loading button keeps FULL opacity — it is working, not unavailable, + // and dimming it would say the opposite of what the spinner says. Only + // `btnEnabled` dims. + opacity: btnEnabled ? 1.0 : 0.4 + + // --- The spinner arm -------------------------------------------------- + // ON THE SHARED SHELL PULSE, never a RotationAnimation. Qt Quick has no + // partial redraws, so any continuous animator presents the WHOLE window at + // display rate — ~1.2% GPU per present on the owner's hybrid laptop + // (qt-frontend/2026-08-11-scenegraph-batches §9, THE PULSE LAW). A 60 Hz + // RotationAnimation for the two-to-five seconds a radio takes to build is + // exactly the bill that doctrine exists to refuse; theme/QbzSpinner.qml + // still uses one and is NOT reused here for that reason. + // + // 12 degrees per ~30 Hz pulse = one turn per second, the same rate and the + // same arithmetic as the offline manager's downloading glyph + // (OfflineManagerView.qml:154) and the transport's loading ring. + // + // The Connections is DISABLED unless the button is actually spinning and + // on screen, so at rest this component writes nothing at all — the other + // half of the law. + property real spinPhase: 0 + Connections { + target: QbzShell + enabled: root.loading && root.visible + function onPulseMsChanged() { root.spinPhase = (root.spinPhase + 12) % 360 } + } + QbzIcon { + visible: root.loading + name: "loader-circle" + // CircleAction.slint:78 `size: diameter * 0.45` — 19.8 on the 44px + // primary, 14.4 on the 32px secondary, i.e. deliberately a hair + // smaller than the glyph it replaces so the disc does not look fuller + // while it waits. + width: Math.round(root.width * 0.45) + height: Math.round(root.height * 0.45) + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + rotation: root.spinPhase + // CircleAction.slint:80-83, the same four-way selector the glyph tint + // uses below: dark on the solid white overlay primary, white on the + // overlay ghost, themed on-surface. + tintName: root.overlay + ? (root.primary ? "black" : "white") + : (root.primary ? theme.accentGlyphTint : root.surfaceTint) + } + + QbzIcon { + visible: !root.loading + name: root.name + width: root.diameterOverride > 0 + ? Math.round(root.resolvedDiameter * 0.46) + : (root.largePrimary ? 19 : 15) + height: width + // Whole-pixel centring, both axes — CircleAction.slint:67-68 rounds + // them explicitly for the same reason: 44-19 and 32-15 are both ODD, + // so `anchors.centerIn` lands the glyph on 12.5px / 8.5px and the + // software renderer resamples it soft, right next to the now + // pixel-crisp 28px QbzNavButton chevrons. + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + // CircleAction.slint:70-74: + // active -> Theme.accent + // on-surface + primary -> #ffffff <- DELIBERATE DIVERGENCE, see + // below; this port uses the measured + // on-accent selector instead + // on-surface + secondary -> Theme.text-primary <- the light-theme + // case; a fixed white would vanish here + // overlay + primary -> #000000 (glyph on the white disc) + // overlay + secondary -> #ffffff + // + // The ONE divergence is the on-surface PRIMARY glyph. Its host is the + // 44px `theme.accent` disc (`color:` above, accentHover on hover — + // which is why the selector folds both fills in) and CircleAction.slint:72 + // hardcodes #ffffff on it — which measures 1.70:1 on high-contrast, + // 1.74 on ikari, 1.82 on wcag-dark, 1.85 on kurosaki and so on for + // 16 of the 35 shipped palettes (the two accessibility themes lead + // that list, which is the strongest part of the argument). + // `theme.accentGlyphTint` is that decision made against the + // live accent (QbzTheme.qml, "ON AN ACCENT FILL"); it still resolves + // to a white glyph on Dark / Light / OLED, so the default themes' + // play button is unchanged. + // + // The OVERLAY arm keeps its literals: those hosts (the solid white + // disc, the artwork band) are theme-independent, so there is nothing + // to measure against and nothing to diverge from. + tintName: root.active + ? "accent" + : (root.overlay + ? (root.primary ? "black" : "white") + : (root.primary ? theme.accentGlyphTint : root.surfaceTint)) + } + MouseArea { + id: cbArea + anchors.fill: parent + enabled: parent.btnEnabled && !parent.loading + hoverEnabled: true + cursorShape: (parent.btnEnabled && !parent.loading) + ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: function (mouse) { parent.clicked(mouse) } + } +} diff --git a/crates/qbz-qt/qml/controls/QbzColorPicker.qml b/crates/qbz-qt/qml/controls/QbzColorPicker.qml new file mode 100644 index 000000000..bd40c9588 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzColorPicker.qml @@ -0,0 +1,271 @@ +// QbzColorPicker — the QML port of primitives/ColorPicker.slint: an inline, +// expandable HSV colour picker (SV square + hue strip + preview + editable +// HEX field). Used by the custom-theme editor; one instance is mounted at a +// time, behind the open swatch. +// +// Design decisions carried over from the reference, and one that is NOT: +// +// - INLINE, never a Popup. The parent mounts exactly one picker at a time, +// so there is no z-order or positioning fight (ColorPicker.slint:4-6). +// - The picker OWNS its hue/sat/val state, seeded from `value` on mount and +// on EXTERNAL change only. A fully derived model would lose the hue at +// s == 0 or v == 0 (ColorPicker.slint:7-9). Two guards keep it: `dragging` +// skips mid-drag feedback, and a hex compare skips the parent's own +// round-tripped colour. +// - NO Timer / NumberAnimation / Behavior. The reference has none, and the +// shell's repaint-pulse rule forbids adding any: the picker repaints on +// user input and costs nothing at rest. (The reference's stated reason is +// a Slint compiler panic on Timer-in-`if`; ours is the pulse contract. +// Same conclusion, different rationale — do not "restore" an animation.) +// +// HUE DOMAIN. The Slint stores hue in 0..360 (`Colors.hsv`). `Qt.hsva()` takes +// ALL FOUR arguments in 0..1, so `hue` here is NORMALISED 0..1 and every +// boundary — the SV base colour, the strip marker position, the pointer maths +// — works in that domain. Feeding 217 into Qt.hsva clamps to 1.0 and paints +// every swatch red. +// +// ENCODING. `picked(hex)` and the HEX field speak 6-digit `#rrggbb` — the +// EDIT/PERSIST encoding of the custom-theme base tokens (qbz_theme +// Rgba::to_hex). `value` comes in as a `color`, so it accepts the +// `#aarrggbb` render encoding the bridge publishes; the alpha is ignored, +// base tokens being opaque by contract. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + /// The current value, driven by the parent. Read on mount + external change. + property color value: "#ff4285f4" + /// Emitted live (drag included) with a 6-digit `#rrggbb` string. Named + /// `picked`, not the reference's `changed`: in QML `changed` collides + /// visually with the auto-generated `Changed` handlers and reads + /// as one at every call site. Same semantics. + signal picked(string hex) + /// Emitted when a colour drag ENDS — the owner persists here, so a settled + /// drag does not wait out the write debounce. + signal released() + /// Emitted when the HEX field is committed (the parent parses + feeds back). + signal hexCommitted(string hex) + + QbzTheme { id: theme } + + // --- owned HSV state ------------------------------------------------- + property real hue: 0.6028 // 0..1 (Qt.hsva domain — see the header) + property real sat: 0.74 // 0..1 + property real val: 0.96 // 0..1 + // True only while an SV/hue drag is in flight, so the round-tripped colour + // the parent feeds back does not fight the crosshair mid-drag. + property bool dragging: false + + readonly property color current: Qt.hsva(root.hue, root.sat, root.val, 1) + readonly property string currentHex: _hex(root.current) + + /// A QML `color` to 6-digit `#rrggbb`. `.r/.g/.b` are 0..1 floats that came + /// from 8-bit channels, so this round-trips exactly. + function _hex(c) { + function b(x) { + var v = Math.max(0, Math.min(255, Math.round(x * 255))) + return (v < 16 ? "0" : "") + v.toString(16) + } + return "#" + b(c.r) + b(c.g) + b(c.b) + } + + /// Seed the owned HSV from `value` (mount + external change). Mirrors + /// ColorPicker.slint:51-69, with hue normalised to 0..1. + function decompose() { + var r = root.value.r, g = root.value.g, b = root.value.b + var mx = Math.max(r, Math.max(g, b)) + var mn = Math.min(r, Math.min(g, b)) + var d = mx - mn + root.val = mx + root.sat = mx <= 0 ? 0 : d / mx + if (d <= 0) + root.hue = 0 // gray: hue undefined -> 0 + else if (mx === r) + root.hue = ((((g - b) / d) % 6) + 6) % 6 / 6 // JS % keeps the sign + else if (mx === g) + root.hue = (((b - r) / d) + 2) / 6 + else + root.hue = (((r - g) / d) + 4) / 6 + } + + Component.onCompleted: root.decompose() + + // Reseed on EXTERNAL changes only. The comparison is on the 8-bit HEX and + // not on the colours themselves: `value` is our own emitted hex parsed + // back, so it is byte-quantised, while `current` is float HSV output — + // comparing those directly would report a difference for every edit and + // re-decompose (resetting the hue whenever s or v hits 0), which is the + // exact failure ColorPicker.slint:74-79 documents. + onValueChanged: { + if (!root.dragging && root._hex(root.value) !== root.currentHex) + root.decompose() + } + + // --- layout ---------------------------------------------------------- + color: theme.surfaceCard + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + implicitHeight: col.implicitHeight + 24 + height: implicitHeight + + Column { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 10 + + // Saturation / Value square. + Rectangle { + id: sv + width: parent.width + height: 140 + radius: theme.radiusSm + clip: true + // Base: the pure hue. + color: Qt.hsva(root.hue, 1, 1, 1) + // Both overlays carry the parent's radius: Qt's `clip` scissors to + // the bounding RECT, unlike Slint's, which honours border-radius — + // so a square-cornered child paints over the rounded corners the + // reference renders (ColorPicker.slint:118-124). + // + // White -> transparent (left to right) = saturation. + Rectangle { + anchors.fill: parent + radius: sv.radius + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "#ffffffff" } + GradientStop { position: 1.0; color: "#00ffffff" } + } + } + // Transparent -> black (top to bottom) = value. + Rectangle { + anchors.fill: parent + radius: sv.radius + gradient: Gradient { + GradientStop { position: 0.0; color: "#00000000" } + GradientStop { position: 1.0; color: "#ff000000" } + } + } + // Crosshair: x = saturation, y = (1 - value). + Rectangle { + width: 14 + height: 14 + radius: 7 + x: Math.max(0, Math.min(sv.width - width, root.sat * sv.width)) + y: Math.max(0, Math.min(sv.height - height, (1 - root.val) * sv.height)) + color: "transparent" + border.width: 2 + border.color: "#ffffffff" + Rectangle { + x: 2 + y: 2 + width: parent.width - 4 + height: parent.height - 4 + radius: width / 2 + color: "transparent" + border.width: 1 + border.color: "#80000000" + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.CrossCursor + onPressed: function (m) { root.dragging = true; root._pickSv(m.x, m.y) } + onPositionChanged: function (m) { if (root.dragging) root._pickSv(m.x, m.y) } + onReleased: { root.dragging = false; root.released() } + onCanceled: { root.dragging = false; root.released() } + } + } + + // Hue strip. + Rectangle { + id: hueStrip + width: parent.width + height: 14 + radius: 7 + clip: true + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0000; color: "#ffff0000" } + GradientStop { position: 0.1666; color: "#ffffff00" } + GradientStop { position: 0.3333; color: "#ff00ff00" } + GradientStop { position: 0.5000; color: "#ff00ffff" } + GradientStop { position: 0.6666; color: "#ff0000ff" } + GradientStop { position: 0.8333; color: "#ffff00ff" } + GradientStop { position: 1.0000; color: "#ffff0000" } + } + // Marker. + Rectangle { + width: 4 + height: parent.height + 4 + y: -2 + x: Math.max(0, Math.min(hueStrip.width - width, root.hue * hueStrip.width)) + radius: 2 + color: "#ffffffff" + border.width: 1 + border.color: "#80000000" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: function (m) { root.dragging = true; root._pickHue(m.x) } + onPositionChanged: function (m) { if (root.dragging) root._pickHue(m.x) } + onReleased: { root.dragging = false; root.released() } + onCanceled: { root.dragging = false; root.released() } + } + } + + // Preview swatch + editable HEX field. + Row { + width: parent.width + spacing: 10 + Rectangle { + width: 40 + height: 34 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: root.current + } + QbzLineEdit { + // The plain arm is a fixed 240 wide; the picker spans the + // settings column, so the field takes what the swatch leaves. + width: Math.max(120, parent.width - 50) + anchors.verticalCenter: parent.verticalCenter + text: root.currentHex + placeholder: "#rrggbb" + // Enter commits. No hotkey-guard wiring is needed: the shell + // derives "a text input has focus" structurally from the + // active focus item (AppShell.qml, the `afi instanceof + // TextInput` gate inside Keys.onPressed), so the Slint + // UiFocusState dance (ColorPicker.slint:229-234, a workaround + // for #619) has no counterpart here. + onAccepted: function (v) { root.hexCommitted(v) } + // Commit only well-formed #rrggbb (7 chars); a shorter string + // is an in-progress edit and is ignored until it is valid. + onEdited: function (v) { if (v.length === 7) root.hexCommitted(v) } + } + } + } + + // Both pointer handlers build the hex from the values they just assigned + // rather than reading `currentHex`, so the emission cannot depend on + // binding evaluation order. + function _pickSv(mx, my) { + root.sat = Math.max(0, Math.min(1, mx / sv.width)) + root.val = Math.max(0, Math.min(1, 1 - my / sv.height)) + root.picked(root._hex(Qt.hsva(root.hue, root.sat, root.val, 1))) + } + function _pickHue(mx) { + root.hue = Math.max(0, Math.min(1, mx / hueStrip.width)) + root.picked(root._hex(Qt.hsva(root.hue, root.sat, root.val, 1))) + } +} diff --git a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml new file mode 100644 index 000000000..5b33fb77f --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml @@ -0,0 +1,287 @@ +// QbzConfirmModal — THE port's confirm dialog. Before this file the tree had +// no confirm surface at all (grep -rl confirm qml/ found only LyricsPanel and +// ArtistView, neither a dialog), so every destructive action either mutated +// straight away or was not shipped. It is written generic on purpose: the +// Blacklist manager's Clear-All is the first caller, and MyQBZ delete, +// playlist delete and the offline-cache purge all need the same surface. +// +// Geometry is 1:1 with the Slint Clear-All modal +// (settings/BlacklistManagerView.slint:992-1113), which itself mirrors +// PlaylistDuplicateConfirmModal: +// scrim full-bleed #000000bf, click dismisses +// panel width min(host - 80, 400), height = content, centered, Radius.md, +// surface-main, 1px border-subtle, drop-shadow blur 32 #00000080 +// padding 24, spacing 16; title heading/semibold, body 15 text-secondary +// buttons right-aligned, spacing 10, both 36px tall, Radius.sm, +// width = label + 32 +// danger fill Theme.danger, hover Theme.danger.darker(0.12) — NOT +// `danger-hover`, which is a translucent tint for GHOST danger +// surfaces and would wash a filled button out (the Slint comment at +// :1080-1089 spells this out at length); label hard #ffffff +// +// MOUNT IT LAST inside the view root with `anchors.fill: parent`. ADR-009's +// ">= 3000" is satisfied both structurally (declaration order IS z-order) and +// by the explicit z: 3100 below — the same layer the port's only other in-pane +// modal scrim sits on (controls/DiscoverConfigModal.qml:56), so the two never +// fight. +// +// The shadow is FAKED (a translucent rounded rect behind the panel) — the +// LoginScreen.qml:30-49 precedent, including its 2026-07-29 note: the old +// "effects render nothing here" doctrine is RETIRED (both effect modules are +// installed and this port runs on the GPU; RoundedImage.qml detects the +// software path with GraphicsInfo.api instead of assuming it). A real blurred +// drop shadow is a visual change owing its own parity pass, so the fake stays +// for now, exactly as LoginScreen's does. The port's only other in-pane modal +// (controls/DiscoverConfigModal.qml) draws no shadow at all. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + property bool kioskHost: false + id: root + + property string title: "" + property string body: "" + /// "" is not a valid label — the default is the shared msgid, as a BINDING + /// so a live language switch re-translates it (QbzSession.trRev). + property string cancelLabel: QbzSession.tr("Cancel", QbzSession.trRev) + property string confirmLabel: "" + /// Filled-red confirm button (the Clear-All / delete voice). false gives + /// the accent fill instead, for a non-destructive confirmation. + /// + /// NAMED `danger`, not `destructive`: spec 03 §6.10/§10 specifies `danger`, + /// and `controls/SettingsButton.qml:15` already carries `property bool + /// danger` for exactly this concept (red border + red label + a `favorite` + /// glyph tint). Two names for one idea across two neighbouring controls is + /// how a call site ends up setting a property that does not exist — which in + /// QML is not an error, just a silently-ignored line. + property bool danger: true + + readonly property bool opened: root._open + property bool _open: false + + signal confirmed() + signal cancelled() + + QbzTheme { id: theme } + + function open() { + root._open = true + // AFTER _open, so `visible` is already true — an invisible item cannot + // take active focus and the Esc handler would be dead. + Qt.callLater(function () { cancelButton.forceActiveFocus() }) + } + function close() { + root._open = false + root._restoreShellFocus() + } + // §1.4.3 (2026-08-03 hotkeys-port contract): a modal control GRABS focus + // on open and Qt strands activeFocus on that now-invisible item at close, + // which kills the AppShell key dispatcher until the next click. Restore + // the shell root — the dispatcher's fallback focus item — on every close + // path. Duck-walk, because this modal mounts under several parents (the + // Blacklist manager, FolderModals, MyQBZ delete). + function _restoreShellFocus() { + var p = root + while (p.parent) { + if (p.parent.isQbzShellRoot === true) { + p.parent.forceActiveFocus() + return + } + p = p.parent + } + } + function _cancel() { + root.close() + root.cancelled() + } + function _confirm() { + root.close() + root.confirmed() + } + + visible: root._open + enabled: root._open + z: 3100 + + Keys.onEscapePressed: function (event) { + root._cancel() + event.accepted = true + } + + // Scrim. `radius` is load-bearing, not decoration: this Item fills the + // view root, which fills AppShell's rounded content frame, and Qt Quick's + // `clip` is a rectangular scissor that does not follow `radius` — so an + // opaque full-bleed child paints into the frame's four bezel corners + // (AppShell.qml:252-259, which names DiscoverConfigModal's scrim as the + // one that had to be fixed exactly this way). + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: root._cancel() + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + } + + // Faked drop shadow (blur 32 / #00000080) — see the header note. + Rectangle { + x: panel.x + y: panel.y + 8 + width: panel.width + height: panel.height + radius: theme.radiusMd + color: "#80000000" + opacity: 0.5 + } + + Rectangle { + id: panel + width: Math.min(root.width - (root.kioskHost ? 24 : 80), root.kioskHost ? 640 : 400) + // The Slint panel is `height: panel.preferred-height` over a + // VerticalLayout with padding 24 -> content + 48. + height: panelCol.implicitHeight + 48 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceMain + border.width: 1 + border.color: theme.borderSubtle + + // Swallow clicks so they never reach the scrim (the Slint's empty + // panel TouchArea, :1030). + MouseArea { + anchors.fill: parent + // Wheel-lock (the DiscoverConfigModal rule). + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + id: panelCol + x: 24 + y: 24 + width: parent.width - 48 + spacing: 16 + + Text { + width: parent.width + text: root.title + color: theme.textPrimary + font.pixelSize: root.kioskHost ? (theme.fontHeading) * 1.2 : (theme.fontHeading) + font.weight: theme.weightSemibold + wrapMode: Text.WordWrap + } + Text { + visible: root.body !== "" + width: parent.width + text: root.body + color: theme.textSecondary + font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody) + wrapMode: Text.WordWrap + } + Item { + width: parent.width + height: root.kioskHost ? 44 : 36 + Row { + anchors.right: parent.right + spacing: 10 + + Rectangle { + id: cancelButton + width: cancelLbl.implicitWidth + 32 + height: root.kioskHost ? 44 : 36 + radius: theme.radiusSm + color: cancelArea.containsMouse ? theme.surfaceHover + : theme.surfaceElevated + activeFocusOnTab: root.opened + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: root.cancelLabel + Accessible.onPressAction: root._cancel() + KeyNavigation.tab: confirmButton + KeyNavigation.backtab: confirmButton + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root._cancel() + event.accepted = true + } + } + Text { + id: cancelLbl + anchors.centerIn: parent + text: root.cancelLabel + color: theme.textPrimary + font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody) + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: cancelButton.forceActiveFocus() + onClicked: root._cancel() + } + } + Rectangle { + id: confirmButton + width: confirmLbl.implicitWidth + 32 + height: root.kioskHost ? 44 : 36 + radius: theme.radiusSm + color: root.danger + ? (confirmArea.containsMouse ? Qt.darker(theme.danger, 1.12) + : theme.danger) + : (confirmArea.containsMouse ? theme.accentHover : theme.accent) + activeFocusOnTab: root.opened + border.width: activeFocus ? 2 : 0 + border.color: root.danger ? "#ffffff" : theme.accentGlyphColor + Accessible.role: Accessible.Button + Accessible.name: root.confirmLabel + Accessible.onPressAction: root._confirm() + KeyNavigation.tab: cancelButton + KeyNavigation.backtab: cancelButton + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root._confirm() + event.accepted = true + } + } + Text { + id: confirmLbl + anchors.centerIn: parent + text: root.confirmLabel + // The Slint hard-codes #ffffff on the filled danger + // button and that is correct here — `danger` is a + // fixed red under every palette. The accent arm goes + // through the theme's on-accent selector instead + // (QbzTheme.qml:314-317), which is the port's + // owner-approved WCAG deviation. + color: root.danger ? "#ffffff" : theme.accentGlyphColor + font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody) + font.weight: theme.weightMedium + } + MouseArea { + id: confirmArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: confirmButton.forceActiveFocus() + onClicked: root._confirm() + } + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/QbzContextMenu.qml b/crates/qbz-qt/qml/controls/QbzContextMenu.qml new file mode 100644 index 000000000..ea97ed870 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzContextMenu.qml @@ -0,0 +1,106 @@ +// QbzContextMenu — the shared popup surface for the app's ⋯ / context +// menus, with CORRECT positioning (phase 12): +// +// - openAtCursor(item, localX, localY): context menus open AT THE +// POINTER, like the Slint unified TrackContextMenu (TrackRow.slint: +// `absolute-position - layout-offset + local-pointer`, clamped into +// the row/window). Coordinates are mapped to the window and the menu +// is parented to the Overlay, so a menu never renders inside the +// triggering card/row. +// - openBelowRight(item): button menus anchor under their trigger, +// right edge aligned (the QbzSelect / sort-menu convention). +// - Both clamp into the window with an 8px margin (Slint clamps the x +// to `width - menu-width - 12`) so a menu never opens off-screen — +// Popup does NOT do this on its own. +// +// The background matches ContextMenu.slint (surface-main, Radius.sm, +// 1px border-muted). Rows go in the default content (33px, icon 15 + +// label 13 per the existing per-site convention). +// +// Right-click parity: every ⋯ site also opens ITS menu on a right press +// through the same `openAtCursor(area, mouse.x, mouse.y)` — the button and +// the context gesture must never show different menus. + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz +import "../theme" + +Popup { + property bool kioskHost: false + id: root + + parent: Overlay.overlay + width: menuWidth + padding: 5 + closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape + + property int menuWidth: 196 + default property alias menuContent: col.data + /// Row gap inside the panel. Defaults to the Column's own 0 — every + /// existing call site is unaffected — and exists because a call site CANNOT + /// reach `col`: QML gives a component no access to another file's ids, so + /// `col.spacing: 1` inside a `QbzContextMenu { … }` block is a grouped + /// assignment against a property the type does not have and fails the WHOLE + /// consuming file at load. The Playlist Manager's three menus set 1, which + /// is the reference's `spacing: 1px` + /// (playlist/PlaylistManagerView.slint:1153, :1197, :816). + property alias contentSpacing: col.spacing + + QbzTheme { id: theme } + + background: Rectangle { + color: theme.surfaceMain + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderMuted + } + implicitHeight: col.implicitHeight + topPadding + bottomPadding + height: kioskHost && parent ? Math.min(implicitHeight, parent.height - 16) : implicitHeight + contentItem: Flickable { + implicitHeight: col.implicitHeight + contentWidth: width + contentHeight: col.implicitHeight + interactive: root.kioskHost + clip: root.kioskHost + boundsBehavior: Flickable.StopAtBounds + Column { id: col; width: parent.width } + } + + // Final placement is computed BEFORE open() — the popup's implicit + // height is already known (static content), and `Window.window` is + // only reliably attached on the SOURCE item, not on the popup. + function _place(gx, gy, win) { + if (win) { + x = Math.max(8, Math.min(gx, win.width - width - 8)) + y = Math.max(8, Math.min(gy, win.height - (root.kioskHost ? height : implicitHeight) - 8)) + } else { + x = gx + y = gy + } + open() + } + + /// Context menu at the pointer. `localX/localY` are the click coords + /// inside `sourceItem` (the MouseArea that caught the click). + function openAtCursor(sourceItem, localX, localY) { + var g = sourceItem.mapToItem(null, localX, localY) + _place(g.x, g.y, sourceItem.Window.window) + } + + /// Button menu: below the trigger, right edges aligned. + function openBelowRight(sourceItem) { + var g = sourceItem.mapToItem(null, sourceItem.width - width, sourceItem.height + 4) + _place(g.x, g.y, sourceItem.Window.window) + } + + /// Button menu: below the trigger, LEFT edges aligned. The mirror of + /// openBelowRight, for triggers that sit at the LEFT end of their row (the + /// MyQBZ hero overflow and the Disco builder's per-row type override), where + /// a right-aligned panel would hang off the trigger to the left and, on a + /// narrow window, get clamped by _place into visibly the wrong place. + function openBelowLeft(sourceItem) { + var g = sourceItem.mapToItem(null, 0, sourceItem.height + 4) + _place(g.x, g.y, sourceItem.Window.window) + } +} diff --git a/crates/qbz-qt/qml/controls/QbzEmptyState.qml b/crates/qbz-qt/qml/controls/QbzEmptyState.qml new file mode 100644 index 000000000..8a9912ae3 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzEmptyState.qml @@ -0,0 +1,71 @@ +// QbzEmptyState — the plain centered empty state (title + optional icon / +// body / action), consolidated in phase 22 from HomeView.RecentPlaceholder +// and QueuePanel's empty queue. +// +// NOT used by SearchView or Cortinilla, contrary to what this header used to +// claim: both draw their own no-results line inline, because the cortinilla's +// is a single 28px row inside a dropdown rather than a centred block. Slint +// has no shared EmptyState either (per-surface inline). +// +// Arms: `iconName` ("" = none), `title`, +// `body` ("" = hidden), `actionLabel` + `actionClicked()` ("" = no action). + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Column { + id: emptyRoot + property string iconName: "" + property string title: "" + property string body: "" + property string actionLabel: "" + // Per-surface metrics. The consolidated default is the phase-22 one (40px + // glyph, full opacity, primary semibold title); the Blacklist manager's + // three empty branches are a 48px glyph at 0.3 with a MUTED regular title + // (BlacklistManagerView.slint:702-732, :801-831, :898-928), which is a + // call-site number, not a second component. + property int iconSize: 40 + property real iconOpacity: 1.0 + property bool titleMuted: false + property int titleWeight: theme.weightSemibold + signal actionClicked() + + QbzTheme { id: theme } + + spacing: 0 + QbzIcon { + visible: emptyRoot.iconName !== "" + name: emptyRoot.iconName + width: emptyRoot.iconSize + height: emptyRoot.iconSize + opacity: emptyRoot.iconOpacity + anchors.horizontalCenter: parent.horizontalCenter + tintName: "muted" + } + Item { visible: iconName !== ""; width: 1; height: 14 } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: emptyRoot.title + color: emptyRoot.titleMuted ? theme.textMuted : theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: emptyRoot.titleWeight + } + Item { visible: body !== ""; width: 1; height: 6 } + Text { + visible: body !== "" + anchors.horizontalCenter: parent.horizontalCenter + text: body + color: theme.textMuted + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + Item { visible: actionLabel !== ""; width: 1; height: 14 } + SettingsButton { + visible: actionLabel !== "" + anchors.horizontalCenter: parent.horizontalCenter + text: actionLabel + onClicked: parent.actionClicked() + } +} diff --git a/crates/qbz-qt/qml/controls/QbzFilterTip.qml b/crates/qbz-qt/qml/controls/QbzFilterTip.qml new file mode 100644 index 000000000..fabaed9da --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzFilterTip.qml @@ -0,0 +1,96 @@ +// Applied-filters tooltip trigger — the two-line wiring a filter control mounts +// beside itself so hovering it says WHAT is currently filtered, not just that +// something is. +// +// Non-visual. It writes the shell's `filterTipJson` channel +// (`shell_bridge.rs`), which `AppShell.qml` feeds to the one `QbzTooltip` +// overlay. That indirection is not ceremony: a funnel lives five levels inside +// a view's toolbar and the overlay is a sibling of the whole shell, so a QML +// `id` cannot reach it, and the art preview solved the identical problem the +// identical way. +// +// THE HOST BUILDS `groups`, and there is no way around it: of the app's filter +// surfaces roughly a third keep their state in QML properties and the rest in +// Rust documents, so nothing here could read one source and be right. What this +// does own is the CONTRACT — `[{ group: "