diff --git a/.gitea/ISSUE_TEMPLATE/bug.yaml b/.gitea/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 00000000..646dd4bc --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,103 @@ +name: Bug report +about: Something behaves incorrectly +title: "" +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Security vulnerabilities do **not** go here — see + [SECURITY.md](../../SECURITY.md). + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What you did, what you expected, and what you got instead. + placeholder: | + 1. Opened an album from the Music library + 2. Tapped the third track + 3. Playback started from the first track instead + validations: + required: true + + - type: input + id: version + attributes: + label: JellyTau version + description: Settings scrolls to the bottom, or the filename you installed. + placeholder: "0.9.1" + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + options: + - Linux (AppImage) + - Linux (deb) + - Linux (rpm) + - Linux (Arch package) + - Windows + - Android + validations: + required: true + + - type: markdown + attributes: + value: | + ### Playback questions + + If this involves playback, these three answers decide which of several + very different code paths you were on. "I don't know" is a fine answer. + + - type: dropdown + id: source + attributes: + label: Was the media streaming or downloaded? + options: + - Streaming from the server + - Downloaded for offline use + - Not playback-related + validations: + required: true + + - type: dropdown + id: transcode + attributes: + label: Was the server transcoding? + description: Jellyfin's dashboard shows this while something is playing. + options: + - Direct play + - Transcoding + - Don't know + - Not playback-related + + - type: dropdown + id: kind + attributes: + label: Music or video? + options: + - Music + - Video (movie) + - Video (TV episode) + - Not playback-related + + - type: textarea + id: logs + attributes: + label: Logs + description: | + Android: `adb logcat | grep -i jellytau`. + Linux: run from a terminal, or `RUST_LOG=debug jellytau` for more. + In the app, `localStorage.setItem("jellytau:logLevel","debug")` in the + webview console turns the frontend up too. + render: shell + + - type: textarea + id: server + attributes: + label: Jellyfin server + description: Version, and anything unusual about the library layout. + placeholder: "10.9.11, series stored without season folders" diff --git a/.gitea/ISSUE_TEMPLATE/feature.yaml b/.gitea/ISSUE_TEMPLATE/feature.yaml new file mode 100644 index 00000000..cb0be786 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature.yaml @@ -0,0 +1,37 @@ +name: Feature request +about: Suggest something JellyTau should do +title: "" +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: | + The situation, not the solution. "I listen to albums in a fixed order and + lose my place when I switch devices" tells us more than "add a sync + button", and often has a better answer than the one you had in mind. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would you like it to do? + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Which platforms does this matter on? + multiple: true + options: + - Linux + - Windows + - Android + + - type: textarea + id: alternatives + attributes: + label: Anything you have tried, or how other clients handle it diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..9851d64c --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## What and why + + + +## How it was verified + + + +## Checklist + +- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` +- [ ] `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `cargo test` +- [ ] `bun run check:boundary` — no Jellyfin taxonomy in the frontend +- [ ] New requirement-implementing code carries a `TRACES:` comment, and every + ID it names exists in `docs/requirements.md` (`bun run traces:validate`) +- [ ] **Bug fix:** a test that reproduces it was written *first* and observed + failing before the fix +- [ ] Android source edits were made in `src-tauri/android/src` and synced with + `scripts/sync-android-sources.sh` (never edit `gen/` directly) diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index 996fee72..9c013c25 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -28,7 +28,7 @@ jobs: if: "!startsWith(github.event.head_commit.message, 'chore(release)')" runs-on: linux/amd64 container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository @@ -82,10 +82,38 @@ jobs: - name: Check documentation links run: bash scripts/check-doc-links.sh + # Formatting, linting and type-checking were all configured in this repo + # and enforced by nothing: .prettierrc described a tree where 199 files did + # not match it, eslint.config.js ran in no workflow and in no hook, and + # `bun run check` ran only in build-release.yml — i.e. a type error could + # sit on master until somebody cut a tag. These three steps are what make + # those configs load-bearing. All are project deps installed by + # `bun install`; nothing is fetched at job time. + - name: Check formatting + run: bun run format:check + + # RATCHET — this number only ever goes DOWN. Same policy as MIN_THRESHOLD + # in traceability-check.yml and the coverage thresholds in + # vitest.config.ts. 159 is what the tree carried when the gate went in; the + # backlog is real findings (dead bindings, unkeyed {#each}, `any` at the + # IPC boundary) that eslint.config.js documents rule by rule, each parked + # at "warn" until its class is cleared and it can be promoted to "error". + # Lower this as you clear them. Never raise it to make a build pass. + - name: Lint + run: bun run lint -- --max-warnings=159 + + - name: Check TypeScript + run: | + bunx svelte-kit sync + bun run check + + # Coverage rather than a bare `bun run test`: same suite, plus the + # thresholds in vitest.config.ts, so a large untested module or a deleted + # test fails here instead of being noticed months later. - name: Run frontend tests run: | bunx svelte-kit sync - bun run test + bun run test:coverage # CLAUDE.md has required `cargo fmt` + `cargo clippy` before every commit # for as long as the rule has existed, but nothing in CI checked either, @@ -128,7 +156,7 @@ jobs: runs-on: linux/amd64 needs: test container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk @@ -180,3 +208,60 @@ jobs: export AR_aarch64_linux_android="$TC/llvm-ar" cd src-tauri cargo check --target aarch64-linux-android --lib + + # Supply-chain gate. Until this job existed the project had no vulnerability + # scanning of any kind: nothing checked the ~500-crate Rust graph or the JS + # dependencies against a CVE feed, and nothing checked that everything we + # redistribute is licence-compatible with shipping JellyTau under MIT. + # + # The first run of this found eight vulnerabilities and one unsoundness + # (bytes, four in rustls-webpki, time, two in quick-xml, rand) — all fixed by + # `cargo update`, none of which anybody had reason to run. + # + # Runs in parallel with android-check rather than after `test`: a dependency + # advisory has nothing to do with whether the tests pass, and finding out + # sooner is the point. + security: + name: Supply Chain + runs-on: linux/amd64 + container: + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache Rust dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + # cargo-deny is baked into the builder image. It fetches the RustSec + # advisory database at run time — that is *data*, like the crates + # `bun install` fetches, not a toolchain install, so the 🔴 rule in + # CLAUDE.md is not in play here. + # + # Config and every documented exception live in src-tauri/deny.toml. + # Vulnerabilities and unsoundness are hard failures with no override; + # unmaintained transitive crates that have no safe upgrade (Tauri's GTK3 + # stack, the unic-* tables) are ignored there by ID, each with a reason. + - name: cargo-deny (advisories, licences, bans, sources) + run: | + cd src-tauri + cargo deny check + + # Advisory for now, deliberately. The Rust graph was clean after one + # update pass, so gating it costs nothing; the JS graph has not been + # audited before and a first run that fails the build teaches everyone to + # ignore this job. Promote to a hard gate once the output is empty and + # stays empty — same approach that got clippy from advisory to -D warnings. + - name: bun audit (advisory) + run: | + bun install + bun audit || echo "::warning::bun audit reported findings — advisory for now, see CLAUDE.md" diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml index 95d2d1c1..2ed80ae1 100644 --- a/.gitea/workflows/build-release.yml +++ b/.gitea/workflows/build-release.yml @@ -21,7 +21,7 @@ jobs: name: Run Tests runs-on: linux/amd64 container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -94,7 +94,7 @@ jobs: runs-on: linux/amd64 needs: test container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -138,10 +138,19 @@ jobs: run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}" if: startsWith(github.ref, 'refs/tags/v') + # TAURI_SKIP_UPDATER is gone: it was suppressing the updater artifacts + # (.AppImage.tar.gz + .sig) that the update manifest points at, back when + # there was no updater to feed. With the signing key present, `tauri build` + # emits and signs them. + # + # If TAURI_SIGNING_PRIVATE_KEY is ever absent the build fails loudly rather + # than quietly shipping an unsigned release that no client will accept -- + # which is the behaviour we want. - name: Build for Linux run: bun run tauri build env: - TAURI_SKIP_UPDATER: true + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - name: Prepare Linux artifacts run: | @@ -160,14 +169,27 @@ jobs: # step (which is why v0.9.0 and v0.9.1 built but never published). # Without nullglob an unmatched pattern stays literal, so test each # candidate instead. Same POSIX-only rule as traceability-check.yml. + # + # The .AppImage.tar.gz + .sig pair is what the updater downloads and + # verifies; the plain .AppImage is what a human downloads. Both ship. for bundle in \ src-tauri/target/release/bundle/appimage/*.AppImage \ + src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz \ + src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz.sig \ src-tauri/target/release/bundle/deb/*.deb \ src-tauri/target/release/bundle/rpm/*.rpm; do [ -e "$bundle" ] || continue cp -v "$bundle" dist/linux/ done + # An AppImage that did not build means no updater artifact either, and + # the release notes have advertised an AppImage for months. Fail rather + # than publish a release whose manifest points at nothing. + if ! ls dist/linux/*.AppImage >/dev/null 2>&1; then + echo "::error::No AppImage produced -- check bundle.targets in tauri.conf.json" + exit 1 + fi + # A release with no Linux package is a failure, not a quiet success. if [ -z "$(ls -A dist/linux/)" ]; then echo "::error::No Linux bundles found under src-tauri/target/release/bundle/" @@ -190,7 +212,7 @@ jobs: # baked into the builder image. No toolchain installs here — the image has # cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target. container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -244,6 +266,9 @@ jobs: - name: Build Windows (NSIS installer + exe) run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - name: List Windows artifacts run: ls -lah dist/windows/ @@ -260,7 +285,7 @@ jobs: runs-on: linux/amd64 needs: test container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk @@ -359,7 +384,7 @@ jobs: needs: [build-linux, build-windows, build-android] if: startsWith(github.ref, 'refs/tags/v') container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -388,64 +413,194 @@ jobs: name: jellytau-android path: artifacts/android/ + # Software Bill of Materials, one per half of the app. Without it there is + # no answer to "does this release contain ?" other than + # rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder + # image; the JS side is read straight from the lockfile bun install used. + - name: Generate SBOM + run: | + set -e + mkdir -p artifacts/sbom + cd src-tauri + cargo cyclonedx --format json + find . -maxdepth 2 -name "*.cdx.json" -exec cp -v {} ../artifacts/sbom/ \; + cd .. + bun install --frozen-lockfile + bun pm ls --all > artifacts/sbom/frontend-dependencies.txt + ls -lah artifacts/sbom/ + + # Checksums over everything being published. A release of unsigned Linux + # and Windows binaries with no checksum gives a user no way at all to tell + # a corrupted or substituted download from a good one — and the AppImage + # and NSIS installer are both fetched over plain HTTP redirects. + # + # Written with paths relative to the asset directory so `sha256sum -c + # SHA256SUMS` works in the directory a user downloaded into. + # The update manifest. Built before the checksums so latest.json is not + # itself hashed into SHA256SUMS (it is metadata about the release, not a + # download), and after the artifacts exist so the signatures can be read. + # + # Why a dedicated `updater` branch and a raw-file URL: this Gitea serves + # /releases/download// but returns 404 for + # /releases/latest/download/, so there is no stable "latest release" + # URL to point a client at. The gitea-pages branch is force-pushed whole by + # publish-docs.yml, so hosting the manifest there would delete it on the + # next docs build. An orphan branch that only ever contains latest.json is + # the one location both stable and ours. + - name: Build update manifest (latest.json) + id: manifest + run: | + set -e + VERSION="${{ steps.tag_name.outputs.VERSION }}" + # The manifest carries the bare version; the tag carries the v prefix. + PLAIN="${VERSION#v}" + BASE="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${VERSION}" + + # Tauri matches on "-". We ship one desktop arch today. + APPIMAGE_SIG="" + NSIS_SIG="" + APPIMAGE_URL="" + NSIS_URL="" + + for f in artifacts/linux/*.AppImage.tar.gz; do + [ -e "$f" ] || continue + APPIMAGE_URL="${BASE}/$(basename "$f")" + [ -e "$f.sig" ] && APPIMAGE_SIG="$(cat "$f.sig")" + done + + for f in artifacts/windows/*-setup.exe; do + [ -e "$f" ] || continue + NSIS_URL="${BASE}/$(basename "$f")" + [ -e "$f.sig" ] && NSIS_SIG="$(cat "$f.sig")" + done + + # A manifest with an empty signature is worse than no manifest: the + # client rejects it after downloading the whole payload. + if [ -z "$APPIMAGE_SIG" ] || [ -z "$NSIS_SIG" ]; then + echo "::error::Missing updater signature (appimage='$APPIMAGE_SIG' nsis='$NSIS_SIG')." + echo "::error::Check that TAURI_SIGNING_PRIVATE_KEY reached both desktop build jobs." + exit 1 + fi + + # Release notes for the update prompt come from the traceability graph, + # same source as the release body. + NOTES="$(bun run release:notes 2>/dev/null | head -c 4000 || echo "See the release page for details.")" + + jq -n \ + --arg version "$PLAIN" \ + --arg notes "$NOTES" \ + --arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg lin_sig "$APPIMAGE_SIG" --arg lin_url "$APPIMAGE_URL" \ + --arg win_sig "$NSIS_SIG" --arg win_url "$NSIS_URL" \ + '{ + version: $version, + notes: $notes, + pub_date: $pub_date, + platforms: { + "linux-x86_64": { signature: $lin_sig, url: $lin_url }, + "windows-x86_64": { signature: $win_sig, url: $win_url } + } + }' > latest.json + + echo "📄 latest.json:" + cat latest.json + + - name: Publish latest.json to the updater branch + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}" + HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')" + REMOTE="https://oauth2:${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git" + + # Built in a scratch repo, NOT by switching branches in the checkout. + # `git checkout --orphan` here would leave every later step standing on + # a one-commit branch -- and the next step but one runs + # `bun run release:notes`, which resolves a commit range against the + # real history and would silently produce nothing. + WORK="$RUNNER_TEMP/updater-branch" + rm -rf "$WORK" + mkdir -p "$WORK" + cp latest.json "$WORK/latest.json" + cd "$WORK" + git init -q + git config user.email "ci@jellytau" + git config user.name "JellyTau CI" + git add latest.json + git commit -qm "chore(updater): manifest for ${{ steps.tag_name.outputs.VERSION }}" + echo "🚀 Force-pushing update manifest to the updater branch" + # Force-push: the branch holds exactly one file and no history worth + # keeping, same shape as publish-docs.yml's gitea-pages. + git push -f "$REMOTE" HEAD:refs/heads/updater + echo "✅ Served at ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/raw/branch/updater/latest.json" + + - name: Generate SHA256SUMS + run: | + set -e + mkdir -p artifacts/release + find artifacts/linux artifacts/windows artifacts/android -type f -exec cp -v {} artifacts/release/ \; + cd artifacts/release + sha256sum * > SHA256SUMS + echo "🔐 Published checksums:" + cat SHA256SUMS + # Verify what we just wrote, so a broken checksum file fails the + # release rather than shipping and failing for users. + sha256sum -c SHA256SUMS + + # Release notes come from the traceability graph, not from a hardcoded + # heredoc. scripts/release-notes.ts resolves the commit range's changed + # files to their TRACES ids and then to requirement descriptions, grouping + # UR into Features and DR/IR into Improvements -- which is what CLAUDE.md + # has asked for all along, while this workflow pasted a fixed block of + # install instructions and a line saying "see CHANGELOG.md for detailed + # changes". It also linked "GitHub Issues" on a Gitea-hosted project. - name: Prepare release notes id: release_notes run: | + set -e VERSION="${{ steps.tag_name.outputs.VERSION }}" - echo "## JellyTau $VERSION Release" > release_notes.md - echo "" >> release_notes.md - echo "### Downloads" >> release_notes.md - echo "" >> release_notes.md - echo "#### Linux" >> release_notes.md - echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md - echo "- **DEB** - Install via \`sudo dpkg -i JellyTau_*.deb\` (Ubuntu/Debian)" >> release_notes.md - echo "- **RPM** - Install via \`sudo rpm -i JellyTau-*.rpm\` (Fedora/openSUSE)" >> release_notes.md - echo "" >> release_notes.md - echo "#### Windows" >> release_notes.md - echo "- **Installer (.exe)** - Run \`JellyTau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md - echo "" >> release_notes.md - echo "#### Android" >> release_notes.md - echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md - echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md - echo "" >> release_notes.md - echo "### What's New" >> release_notes.md - echo "" >> release_notes.md - echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md - echo "" >> release_notes.md - echo "### Installation" >> release_notes.md - echo "" >> release_notes.md - echo "#### Linux (AppImage)" >> release_notes.md - echo "\`\`\`bash" >> release_notes.md - echo "chmod +x JellyTau_*.AppImage" >> release_notes.md - echo "./JellyTau_*.AppImage" >> release_notes.md - echo "\`\`\`" >> release_notes.md - echo "" >> release_notes.md - echo "#### Linux (DEB)" >> release_notes.md - echo "\`\`\`bash" >> release_notes.md - echo "sudo dpkg -i JellyTau_*.deb" >> release_notes.md - echo "jellytau" >> release_notes.md - echo "\`\`\`" >> release_notes.md - echo "" >> release_notes.md - echo "#### Android" >> release_notes.md - echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md - echo "- Play Store: Coming soon" >> release_notes.md - echo "" >> release_notes.md - echo "### Known Issues" >> release_notes.md - echo "" >> release_notes.md - echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md - echo "" >> release_notes.md - echo "### Requirements" >> release_notes.md - echo "" >> release_notes.md - echo "**Linux:**" >> release_notes.md - echo "- 64-bit Linux system" >> release_notes.md - echo "- GLIBC 2.29+" >> release_notes.md - echo "" >> release_notes.md - echo "**Android:**" >> release_notes.md - echo "- Android 8.0 or higher" >> release_notes.md - echo "- 50MB free storage" >> release_notes.md - echo "" >> release_notes.md - echo "---" >> release_notes.md - echo "Built with Tauri, SvelteKit, and Rust" >> release_notes.md + { + echo "## JellyTau $VERSION" + echo "" + # A generated summary of what actually changed; falls back to a + # pointer rather than failing the release if the range is odd. + bun run release:notes 2>/dev/null || echo "See the commit log for changes in this release." + echo "" + echo "### Downloads" + echo "" + echo "| Platform | File |" + echo "|---|---|" + echo "| Linux (portable) | \`*.AppImage\` — \`chmod +x\` and run |" + echo "| Linux (Debian/Ubuntu) | \`*.deb\` — \`sudo dpkg -i\` |" + echo "| Linux (Fedora/openSUSE) | \`*.rpm\` — \`sudo rpm -i\` |" + echo "| Windows | \`*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run. |" + echo "| Android | \`*.apk\` sideload, or \`*.aab\` for Play Console |" + echo "" + echo "Desktop builds update themselves from here on: JellyTau checks this" + echo "release feed and can install a new version in place." + echo "" + echo "### Verifying your download" + echo "" + echo "\`\`\`bash" + echo "sha256sum -c SHA256SUMS" + echo "\`\`\`" + echo "" + echo "\`SHA256SUMS\` covers every file in this release. An SBOM" + echo "(\`*.cdx.json\`, \`frontend-dependencies.txt\`) lists what went into it." + echo "" + echo "### Requirements" + echo "" + echo "- **Linux:** 64-bit, GLIBC 2.29+" + echo "- **Windows:** 64-bit Windows 10 or later" + echo "- **Android:** 8.0 or later, ~50 MB free" + echo "" + echo "---" + echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues" + } > release_notes.md + echo "📝 Release notes:" + cat release_notes.md - name: Publish Gitea release & upload assets env: @@ -485,7 +640,10 @@ jobs: fi echo "Release id=$RELEASE_ID" - for f in artifacts/android/* artifacts/linux/* artifacts/windows/*; do + # artifacts/release/ holds a copy of every platform artifact plus the + # SHA256SUMS generated over exactly that set, so the checksums describe + # precisely what is uploaded. artifacts/sbom/ rides along. + for f in artifacts/release/* artifacts/sbom/*; do [ -f "$f" ] || continue echo "⬆️ Uploading $(basename "$f")" curl -fsS -X POST \ diff --git a/.gitea/workflows/publish-docs.yml b/.gitea/workflows/publish-docs.yml index 6aa1b400..a740666c2 100644 --- a/.gitea/workflows/publish-docs.yml +++ b/.gitea/workflows/publish-docs.yml @@ -21,7 +21,7 @@ jobs: name: Build & publish docs to gitea-pages runs-on: linux/amd64 container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout code @@ -34,14 +34,13 @@ jobs: - name: Install dependencies run: bun install - - name: Install mdBook - run: | - set -e - MDBOOK_VERSION=v0.4.40 - URL="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" - echo "⬇️ Downloading mdBook ${MDBOOK_VERSION}" - curl -fsSL "$URL" | tar -xz -C /usr/local/bin - mdbook --version + # mdBook is baked into jellytau-builder (Dockerfile.builder, MDBOOK_VERSION). + # It used to be curl'd from GitHub releases straight into /usr/local/bin + # right here, which was a toolchain install at job time — the exact thing + # CLAUDE.md's 🔴 rule forbids — and made every docs publish depend on + # GitHub's CDN answering. To move the version, bump it in the image. + - name: Confirm mdBook is present + run: mdbook --version - name: Regenerate traceability matrix (keep published copy current) run: bun run traces:markdown diff --git a/.gitea/workflows/traceability-check.yml b/.gitea/workflows/traceability-check.yml index 52043f0d..58e9562d 100644 --- a/.gitea/workflows/traceability-check.yml +++ b/.gitea/workflows/traceability-check.yml @@ -17,7 +17,7 @@ jobs: runs-on: linux/amd64 name: Check Requirement Traces container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository @@ -46,7 +46,7 @@ jobs: # hardcode them here. This step previously divided by frozen literals # (UR/39, IR/24, DR/48, JA/3, total 114) while the file had grown to # 211 requirements, so it reported 158% coverage and the threshold - # below could never trip. See docs/specs/traceability-gate-repair.md. + # below could never trip. See docs/traceability-ci.md. TOTAL_TRACES=$(jq '.totalTraces' traces-report.json) COVERED=$(jq '.coverage.covered' traces-report.json) TOTAL_REQS=$(jq '.coverage.total' traces-report.json) @@ -94,7 +94,7 @@ jobs: # # Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts; # scripts/extract-traces.test.ts fails if the two drift apart. - MIN_THRESHOLD=88 + MIN_THRESHOLD=89 if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)" exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 6dc8f00d..1312d5ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,8 @@ bun run check # svelte-check (types) bun run test # vitest (frontend unit/integration) bun run test:rust # cargo test (scripts/test-rust.sh) bun run test:all # full suite (scripts/test-all.sh) -bun run test:e2e # webdriverio e2e +bun run lint # eslint (src/, scripts/, root configs) +bun run format:check # prettier # Android — canonical entry points (see scripts/): bun run android:build # debug APK @@ -61,7 +62,8 @@ only against the mirror if one exists; the canonical remote is ## Before Committing -- Frontend: `bun run check` and `bun run test` must pass. +- Frontend: `bun run check`, `bun run test`, `bun run format:check` and + `bun run lint` (0 errors; the warning count is a CI ratchet) must pass. - Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`. - **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin item-type category sets) leaked into the frontend. See below. @@ -112,10 +114,12 @@ rename that missed a call site can no longer pass silently. **CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not GitHub. `traceability-check.yml` fails the build if coverage drops below -**82%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower +**89%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower it to make a build pass) or if any traced ID is undefined; `build-and-test.yml` -runs frontend + Rust tests, `cargo fmt --check`, an advisory `cargo clippy`, and -an Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) +runs frontend tests **with coverage thresholds**, `bun run check`, `format:check`, +a `--max-warnings` eslint ratchet, Rust tests, `cargo fmt --check`, `cargo clippy +-D warnings`, and an Android `cargo check`. See +[docs/traceability-ci.md](docs/traceability-ci.md) and [docs/traces-quick-ref.md](docs/traces-quick-ref.md). ### Traces drive release notes @@ -212,7 +216,9 @@ and [docs/build/build-release.md](docs/build/build-release.md). ## Writing specs -New feature specs go in [docs/specs/](docs/specs/). **Start from +New feature specs go in [docs/specs/](docs/specs/) — see its +[README](docs/specs/README.md) for the index and what is already built. +**Start from [SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md)** — its "Layer assignment" section forces each piece of *logic* to be placed in the correct layer (Rust = domain, frontend = presentation) *with a reason*, which is what prevents boundary leaks. @@ -221,6 +227,34 @@ Before accepting a spec, run it past a spec around "no Rust changes required" — correct layer placement is the goal, not minimal backend churn. +### 🔴 A spec becomes an architecture doc when it ships + +`docs/specs/` holds **only work that has not shipped**. There is no "Implemented" +resting state for a spec file: when the last acceptance criterion is met, fold +the design into [docs/architecture/](docs/architecture/README.md) and **delete +the spec in the same commit**. + +This is not tidying. A directory that mixes promises with descriptions makes both +unreliable — you cannot tell from a file whether it describes the build or +proposes a change to it, and stale specs then quietly disagree with the code +while reading as authority. + +- **Every spec names its destination up front** — the template's "Destination on + completion" line. Deciding at spec time which architecture doc will absorb it + is a design check in itself: a feature that fits no existing doc is usually a + feature whose layer assignment is unclear. +- **Carry the reasoning, not the plan.** The architecture doc gets the *why* a + future change still needs — invariants, rejected alternatives that would be + re-attempted, the defect a piece of code exists to prevent. Acceptance + criteria, phase breakdowns and migration steps die with the spec; git history + keeps them. +- **Deferred work outlives its spec.** Anything the spec listed as out-of-scope + and still worth doing goes beside the code it concerns, not into the void. +- **Rewrite inbound references before deleting** — source comments and CI + scripts cite spec paths, and `check-doc-links` only sees markdown. +- **Partially implemented is a real status.** A spec stays until *all* of it + ships, with the header naming what is left. + ## Conventions ### Rust Backend diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..ea3cca5d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,47 @@ +# Code of Conduct + +## The short version + +Be decent to people. Assume the person you are talking to is acting in good +faith and knows things you do not. + +## What that means here + +**Expected:** + +- Criticise code, decisions and ideas — not the people who wrote them. +- Accept that "no" is a complete answer. This is a small project with a + maintainer who has finite time; a declined feature request is not a slight. +- Give people room to be new. Everyone was once confused by Tauri's IPC. +- Assume a bug report is someone trying to help, even when it arrives terse or + frustrated. + +**Not accepted:** + +- Harassment, personal attacks, or demeaning remarks — including about someone's + identity, background, or level of experience. +- Sexualised language or imagery, and unwelcome attention of any kind. +- Publishing someone's private information without their permission. +- Persistently derailing discussions, or badgering people who have already + answered you. + +## Scope + +This applies in the issue tracker, pull requests, commit messages and any other +project space, and to anyone taking part — maintainer included. + +## Reporting + +Email **duncan@tourolle.paris**. Reports are read by the maintainer and handled +privately. + +Responses range from a quiet word through to removing comments or blocking an +account, depending on what happened. If a report concerns the maintainer, and +that makes reporting to them pointless, you are free to say so publicly — a +project this size has no separate committee to appeal to, and pretending +otherwise would be dishonest. + +## Attribution + +Adapted in spirit from the [Contributor Covenant](https://www.contributor-covenant.org), +shortened to what a single-maintainer project can actually honour. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..9d366c8c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,123 @@ +# Contributing to JellyTau + +Thanks for looking. This file is the short version of how the project is built +and what has to be true before a change lands. The long version lives in +[CLAUDE.md](CLAUDE.md) and [docs/architecture/](docs/architecture/README.md), +which are maintained rather than decorative — read them before a structural +change. + +## Getting set up + +Package manager is **bun**. You will also need a Rust toolchain (the exact +version is pinned in [src-tauri/rust-toolchain.toml](src-tauri/rust-toolchain.toml) +— rustup honours it automatically) and the Tauri Linux dependencies. + +```bash +bun install +bun run hooks:install # do this once: it enables the pre-commit gates +bun run tauri dev +``` + +`hooks:install` points `core.hooksPath` at [scripts/hooks/](scripts/hooks/), so +hook updates arrive with a `git pull` instead of needing a re-install. + +## What has to pass + +Everything below runs in CI, and the fast half runs in the pre-commit hook. None +of it is advisory: + +```bash +bun run check # svelte-check — 0 errors +bun run test # vitest +bun run format:check # prettier +bun run lint # eslint — 0 errors; the warning count is a ratchet +bun run check:boundary # no Jellyfin taxonomy in the frontend +bun run test:rust # cargo test +cd src-tauri && cargo fmt --all && cargo clippy --all-targets -- -D warnings +cd src-tauri && cargo deny check # advisories, licences, bans, sources +``` + +`bun run test:all` runs the whole set. + +Several of these are **ratchets** — a number that only ever moves in the +improving direction: + +| Ratchet | Where | Rule | +|---|---|---| +| eslint `--max-warnings` | [.gitea/workflows/build-and-test.yml](.gitea/workflows/build-and-test.yml) | only goes down | +| Coverage thresholds | [vitest.config.ts](vitest.config.ts) | only go up | +| Traceability coverage | [.gitea/workflows/traceability-check.yml](.gitea/workflows/traceability-check.yml) | only goes up | + +Never relax one to make a build pass. Fix the thing it caught. + +## The two rules that surprise people + +**1. Bug fixes start with a failing test.** Write a test that reproduces the bug +and *watch it fail* before you touch the fix. A test written against +already-fixed code can pass for the wrong reason and guards nothing. If the logic +is trapped in a component, extract the pure part into a plain `.ts` module and +test that — see `episodeStrip.ts` or `TrackList.logic.ts` for the pattern. + +**2. Domain vocabulary lives in Rust.** The frontend is presentation-only. It +must not encode Jellyfin's *taxonomy* — for example, the set of item types that +makes up a category like "Music". Send an opaque scope across the IPC boundary +and let the backend expand it. `bun run check:boundary` is a tripwire, not a +proof: it only flags item-type array literals, so a green run does not mean you +are clear. [docs/specs/scoped-search-boundary.md](docs/specs/scoped-search-boundary.md) +describes the leak that made this a rule. + +## Traceability + +Code that implements a requirement carries a `TRACES:` comment naming the +requirement IDs, and a tool builds the matrix from those comments: + +```rust +/// TRACES: UR-005 | DR-001 +``` + +Every ID must exist as a row in [docs/requirements.md](docs/requirements.md) — +`bun run traces:validate` fails on a typo or a stale rename. Internal helpers and +requirement-less code stay untraced; do not sprinkle IDs to raise the number. + +If you add a requirement, add its row. If you implement one, tag the code. + +## Commits and pull requests + +- Conventional-commit subjects: `fix(player): …`, `feat(updater): …`, `ci: …`. +- Explain **why** in the body, not what the diff already shows. The commit log + is the main record of why things are the way they are here, and it is used to + draft release notes. +- One concern per commit. A formatting sweep and a behaviour change in the same + commit is unreviewable. +- Rebase rather than merge-commit onto `master`. + +## Specs + +New features start from [docs/specs/SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md). +Its "Layer assignment" section is the point: each piece of logic gets placed in +Rust or the frontend *with a reason*. Review against +[docs/specs/SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do +not frame a spec around "no Rust changes required" — correct placement is the +goal, not minimal backend churn. + +## CI + +CI is **Gitea Actions** (`.gitea/workflows/`), not GitHub. + +🔴 **CI installs no system tools.** Every build, test and packaging tool must +already be in the Docker builder image. If a job needs a tool the image lacks, +add it to [Dockerfile.builder](Dockerfile.builder), rebuild and push the image, +and pin the new tag — do not `apt-get` it at job time. Details in +[docs/build/ci-operations.md](docs/build/ci-operations.md). + +Fetching the project's own declared dependencies (`bun install`, cargo crates, +an advisory database) is not a toolchain install and is fine. + +## Reporting bugs + +Use the issue templates. For anything involving playback, include what the +platform was, whether the media was streaming or downloaded, and whether it was +transcoding — those three answers determine which of several code paths you were +actually on. + +Security issues go to [SECURITY.md](SECURITY.md), not the tracker. diff --git a/Dockerfile.builder b/Dockerfile.builder index 853b9db8..8f581d59 100644 --- a/Dockerfile.builder +++ b/Dockerfile.builder @@ -152,6 +152,29 @@ RUN . $HOME/.cargo/env && \ rustup target add x86_64-pc-windows-msvc && \ cargo install --locked cargo-xwin +# --------------------------------------------------------------------------- +# Supply-chain and docs tooling. +# +# cargo-deny — advisories/licences/bans/sources gate (src-tauri/deny.toml), +# run by the `security` job. It fetches the RustSec advisory +# database at run time; that is *data*, not a toolchain, so it +# does not breach the no-installs-in-CI rule. +# cargo-cyclonedx — SBOM for the Rust half of a release. +# mdbook — builds the docs site. It used to be curl'd from GitHub +# releases *inside* the job (publish-docs.yml), which was both a +# breach of that rule and a hard dependency on GitHub's CDN +# being up at publish time. Pinned to the version that job used. +ENV MDBOOK_VERSION=v0.4.40 +RUN . $HOME/.cargo/env && \ + cargo install --locked cargo-deny cargo-cyclonedx && \ + wget -q "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ + -O /tmp/mdbook.tar.gz && \ + tar -xzf /tmp/mdbook.tar.gz -C /usr/local/bin && \ + rm /tmp/mdbook.tar.gz && \ + cargo deny --version && \ + cargo cyclonedx --version && \ + mdbook --version + WORKDIR /app ENTRYPOINT ["/bin/bash"] diff --git a/README.md b/README.md index 5d04954b..f14850f6 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,29 @@ For the full set of build, test, and Android helper scripts, see | Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) | | Release checklist | [docs/release-checklist.md](docs/release-checklist.md) | | UX flows | [docs/ux-flows.md](docs/ux-flows.md) | +| CI operations (builder image, secrets, runner) | [docs/build/ci-operations.md](docs/build/ci-operations.md) | + +## Contributing + +[CONTRIBUTING.md](CONTRIBUTING.md) covers the setup, the gates a change has to +pass, and the two rules that catch people out (bug fixes start with a failing +test; Jellyfin's taxonomy stays in Rust). Please also read the +[Code of Conduct](CODE_OF_CONDUCT.md). + +Found a security problem? Do not open an issue — see [SECURITY.md](SECURITY.md). + +## Verifying a download + +Every release publishes `SHA256SUMS` covering all of its artifacts, plus an SBOM +of what went into the build: + +```bash +sha256sum -c SHA256SUMS +``` + +Desktop builds update themselves from Settings → Updates, verifying each payload +against JellyTau's signing key before installing. Android installs are handled by +the system installer, so the app links to the releases page instead. ## Recommended IDE Setup diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..30462b0c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,60 @@ +# Security Policy + +## Reporting a vulnerability + +Email **duncan@tourolle.paris** with `[JellyTau security]` in the subject. +Please do **not** open a public issue for a vulnerability — JellyTau handles +Jellyfin credentials and media, and an unfixed issue in a public tracker is an +advisory for everyone running it. + +Include what you have: what the problem is, how to reproduce it, the version and +platform, and what you think an attacker could do with it. A rough report is +worth more than a polished one that never gets sent. + +You can expect an acknowledgement within a week. If a fix is warranted it will +ship in the next release, and you will be credited in the release notes unless +you would rather not be. + +## Supported versions + +JellyTau is a single-maintainer project without long-term support branches. +**Only the latest release receives fixes.** Desktop builds can update themselves +(Settings → Updates); on Android, install the latest APK from the releases page. + +## What is in scope + +The application and its build pipeline: + +- The Tauri backend (`src-tauri/`) and the Svelte frontend (`src/`) +- Credential storage — the system keyring and its encrypted-file fallback +- The Android player service and its JNI bridge +- The loopback media server used for downloaded playback +- The release pipeline: artifact signing, the update manifest, the builder image + +**Out of scope:** vulnerabilities in Jellyfin itself (report those to the +Jellyfin project), and issues that require an already-compromised device or a +malicious server the user deliberately configured and trusted. + +## What the project already does + +Not a guarantee, but so you know what has been considered: + +- **Credentials** never go in plaintext config: the system keyring is used where + available, with an AES-GCM encrypted file as fallback (see + [docs/architecture/09-security.md](docs/architecture/09-security.md)). +- **The webview runs under a restrictive CSP**, and the asset protocol is scoped + to the thumbnail cache directory only. +- **Path confinement** is enforced on the cache and download roots — a + server-supplied id cannot decide where a file lands (DR-210, DR-211). +- **Queries and URLs bind or encode their inputs** rather than interpolating + them (DR-212). +- **Dependencies are scanned on every build** by `cargo deny` against the RustSec + advisory database, and licence-checked against an allow-list (DR-216). +- **Releases carry `SHA256SUMS` and an SBOM**, so you can verify a download and + find out what went into it. +- **Desktop updates are signature-verified** against a key held only in CI before + anything is installed (DR-217). + +Windows installers are **not** Authenticode-signed — SmartScreen will warn on +first run. That is a cost and identity problem, not an oversight; verify the +download against `SHA256SUMS` instead. diff --git a/bun.lock b/bun.lock index 5a5ccf6a..661e1e14 100644 --- a/bun.lock +++ b/bun.lock @@ -6,8 +6,11 @@ "name": "jellytau", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-log": "^2.9.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.3.2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "hls.js": "^1.6.15", "svelte-dnd-action": "^0.9.69", }, @@ -35,7 +38,7 @@ "typescript": "~5.6.2", "typescript-eslint": "^8.67.0", "vite": "^6.0.3", - "vitest": ">=1.0.0 <5.0.0", + "vitest": "^4.1.10", }, }, }, @@ -278,10 +281,16 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], + "@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw=="], + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="], "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], + + "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/svelte": ["@testing-library/svelte@5.3.1", "", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.0.0" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w=="], @@ -328,17 +337,17 @@ "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="], - "@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], - "@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="], + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], - "@vitest/runner": ["@vitest/runner@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" } }, "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q=="], + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], - "@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], - "@vitest/spy": ["@vitest/spy@4.0.16", "", {}, "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw=="], + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], "@vitest/ui": ["@vitest/ui@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.16" } }, "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg=="], @@ -410,7 +419,7 @@ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], @@ -706,7 +715,7 @@ "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], - "vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], @@ -752,17 +761,19 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tauri-apps/plugin-log/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], + + "@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], + "@vitest/expect/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - "@vitest/expect/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "@vitest/runner/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - "@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], - - "@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], + "@vitest/snapshot/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], "@vitest/ui/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], @@ -788,13 +799,9 @@ "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], - "vitest/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], + "vitest/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - "vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - - "vitest/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], - - "@vitest/runner/@vitest/utils/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "@vitest/ui/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="], "svelte-eslint-parser/espree/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], diff --git a/docker-compose.yml b/docker-compose.yml index 191e0647..1c6030bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: # Test service - runs tests only @@ -31,7 +31,7 @@ services: depends_on: - test ports: - - "5172:5172" # In case you want to run dev server + - "5172:5172" # In case you want to run dev server # Linux desktop packages - deb + rpm + pacman into ./dist desktop-linux-build: diff --git a/docs-site/SUMMARY.md b/docs-site/SUMMARY.md index 855a495e..ce86b824 100644 --- a/docs-site/SUMMARY.md +++ b/docs-site/SUMMARY.md @@ -26,47 +26,21 @@ - [UX Flows](ux-flows.md) -# Specs — Writing One +# Specs — Pending Work +- [Specs Index](specs/README.md) - [Spec Template](specs/SPEC-TEMPLATE.md) - [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md) - -# Specs — Playback & Player - - [Playback Backend Unification](specs/playback-backend-unification.md) +- [Linux Native Video Spike](specs/linux-native-video-spike.md) - [Player Facade Enforcement](specs/player-facade-enforcement.md) -- [Playback Documentation Corrections](specs/playback-docs-corrections.md) -- [Video Background Audio](specs/video-background-audio.md) -- [Android Native Video Spike](specs/android-native-video-spike.md) -- [Android Audio Settings Parity](specs/android-audio-settings-parity.md) -- [Audio Equalizer](specs/audio-equalizer.md) - [Windows Native Audio Backend](specs/windows-native-audio-backend.md) - [libmpv2 Migration](specs/libmpv2-migration.md) -- [Streaming Bitrate Cap](specs/streaming-bitrate-cap.md) - [Read-Through Media Cache](specs/read-through-media-cache.md) - -# Specs — Library & Browsing - - [Scoped Search](specs/scoped-search.md) - [Scoped Search Boundary](specs/scoped-search-boundary.md) - [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md) -- [Locally-Indexed Search](specs/catalog-index-search.md) -- [Favourites Browsing](specs/favorites-browsing.md) -- [Library Mosaic](specs/library-mosaic.md) -- [Series Current-Episode Navigation](specs/series-current-episode-navigation.md) -- [Account Menu](specs/account-menu.md) - [Frontend Domain Model](specs/frontend-domain-model.md) - -# Specs — Downloads & Offline - -- [Downloads as an Offline Library](specs/downloads-as-offline-library.md) -- [Offline Downloaded-Only Filter](specs/offline-downloaded-only-filter.md) - -# Specs — Tooling & Build - -- [Traceability Gate Repair](specs/traceability-gate-repair.md) -- [Boundary Tripwire Hardening](specs/boundary-tripwire-hardening.md) -- [Requirement-Coverage Script Removal](specs/req-coverage-script-removal.md) - [Build Provenance](specs/build-provenance.md) # Build & Release diff --git a/docs/architecture/01-rust-backend.md b/docs/architecture/01-rust-backend.md index 9044af49..8486aeb0 100644 --- a/docs/architecture/01-rust-backend.md +++ b/docs/architecture/01-rust-backend.md @@ -376,57 +376,77 @@ flowchart TB ## Favorites System **Location**: -- Service: `src/lib/services/favorites.ts` -- Component: `src/lib/components/FavoriteButton.svelte` -- Backend: `src-tauri/src/commands/storage.rs` +- Commands: `src-tauri/src/commands/favorites.rs` (offline drain), + `src-tauri/src/commands/repository.rs` (query + toggle), + `src-tauri/src/commands/storage/` (local `user_data` writes) +- Repository: `get_favorites` on the trait, implemented by `online.rs`, + `offline.rs` and `hybrid.rs` +- Frontend: `src/lib/services/favorites.ts`, + `src/lib/components/FavoriteButton.svelte`, `/library/favorites` -The favorites system implements optimistic updates with server synchronization: +Favouriting has two halves that are easy to confuse: **marking** an item, which +has existed since UR-017, and **browsing** what was marked, which arrived with +UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it. + +### Marking + +Optimistic local write, then server sync: ```mermaid flowchart TB UI[FavoriteButton] -->|Click| Service[toggleFavorite] - Service -->|1. Optimistic| LocalDB[(SQLite user_data)] - Service -->|2. Sync| JellyfinAPI[Jellyfin API] - Service -->|3. Mark Synced| LocalDB - - JellyfinAPI -->|POST| MarkFav["/Users/{id}/FavoriteItems/{itemId}"] - JellyfinAPI -->|DELETE| UnmarkFav["/Users/{id}/FavoriteItems/{itemId}"] - - LocalDB -->|is_favorite
pending_sync| UserData[user_data table] + Service -->|"1. Optimistic"| LocalDB[("SQLite user_data
is_favorite, pending_sync")] + Service -->|"2. Sync"| Repo[Repository] + Repo -->|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"] + Service -->|"3. Mark synced"| LocalDB + Drain["spawn_favorites_drain
(background task)"] -->|"pending_sync = 1"| Repo ``` -**Flow**: -1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages) -2. `toggleFavorite()` service function handles the logic: - - Updates local SQLite database immediately (optimistic update) - - Attempts to sync with Jellyfin server - - Marks as synced if successful, otherwise leaves `pending_sync = 1` -3. UI reflects the change immediately without waiting for server response +1. The local row is updated immediately, so the heart fills without a round trip. +2. The repository is asked to mark or unmark on the server. +3. On success `pending_sync` is cleared; on failure the row stays pending. +4. A **background drain** (`spawn_favorites_drain`, started in `lib.rs` setup) + retries pending rows, so a favourite marked offline still reaches the server + (DR-120). This is the same pattern as the sync-queue drain — see + [Background workers](#background-workers). -**Components**: +### Browsing -- **FavoriteButton.svelte**: Reusable heart button component - - Configurable size (sm/md/lg) - - Red when favorited, gray when not - - Loading state during toggle - - Bindable `isFavorite` prop for two-way binding +`get_favorites(scope, options)` answers "what did this user favourite", across +libraries, with the **scope owned by Rust** — the frontend sends a +[`SearchScope`](#search-scope-and-the-taxonomy-boundary) variant and never names +an item type. `HybridRepository` splits it the same way it splits every query: -- **Integration Points**: - - MiniPlayer: Shows favorite button for audio tracks (hidden on small screens) - - Full AudioPlayer: Shows favorite button (planned) - - Album/Artist detail pages: Shows favorite button (planned) +| Method | Used for | +|--------|----------| +| `get_favorites_cache_only` | The instant leg — the local `user_data` join | +| `get_favorites_server_only` | The reconciliation leg | +| `get_favorites` | Cache-first with server merge, per the repository's usual policy | -**Database Schema**: -- `user_data.is_favorite`: Boolean flag (stored as INTEGER 0/1) -- `user_data.pending_sync`: Indicates if local changes need syncing +`GetItemsOptions.favorites_only` is the other entry point: it filters an +*existing* library listing rather than starting a cross-library query (DR-116), +which is what a library page's favourites filter uses. -**Tauri Commands**: -- `storage_toggle_favorite`: Updates favorite status in local database -- `storage_mark_synced`: Clears pending_sync flag after successful sync +Server favourite state is mirrored into the local `user_data` table on catalog +sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up +here — before this, `MediaItem.user_data` was left empty and no query anywhere +asked for favourites. -**API Methods**: -- `LibraryApi.markFavorite(itemId)`: POST to Jellyfin -- `LibraryApi.unmarkFavorite(itemId)`: DELETE from Jellyfin +**Tauri commands**: + +| Command | Description | +|---------|-------------| +| `repository_get_favorites` | Cross-library favourites for a scope | +| `repository_mark_favorite` / `repository_unmark_favorite` | Toggle on the server, through the repository | +| `storage_toggle_favorite` | Local optimistic write (`is_favorite`, `pending_sync`) | +| `storage_mark_synced` | Clear `pending_sync` after a successful server write | + +**Frontend surfaces** (DR-117 … DR-119): the `/library/favorites` page with a +scope selector, favourite rows on home (`favoriteMovies` / `favoriteShows` / +`favoriteMusic` in `stores/home.ts`), a favourites tile per category in the +library mosaic, and `FavoriteButton` mounted wherever a whole item is shown — +movie, series, episode, album, artist and playlist detail views as well as the +mini player. ## Player Backend Trait @@ -569,3 +589,116 @@ async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: | `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` | | `player_get_autoplay_settings` | - | `AutoplaySettings` | | `player_on_playback_ended` | - | `()` | + +## Domain Vocabulary Owned by Rust + +The frontend is presentation-only and must not encode Jellyfin's *taxonomy* — the +rule in [CLAUDE.md](../../CLAUDE.md) and +[scoped-search-boundary.md](../specs/scoped-search-boundary.md). These are the +places where that vocabulary actually lives. + +### Search scope and the taxonomy boundary + +**Location**: `src-tauri/src/repository/types.rs` + +`SearchScope` is the canonical example the boundary rule is taught from. The +frontend sends an opaque variant; Rust expands it into Jellyfin item types: + +```rust +pub enum SearchScope { All, Music, Movies, Tv } + +impl SearchScope { + /// The Jellyfin item types this scope requests, or `None` for `All`. + pub fn item_types(self) -> Option> { … } + + /// The scope a library of this Jellyfin `CollectionType` belongs to. + pub fn for_collection_type(collection_type: &str) -> Option { … } +} +``` + +Two details that are load-bearing: + +- `All` returns `None`, **not** the union of every listed type. An explicit + `includeItemTypes` list filters out anything not named in it, so a union would + silently drop People, folders, and any type nobody enumerated. Callers must + omit the filter entirely on `None`. +- `for_collection_type` maps a Jellyfin `CollectionType` to a favourites + category (DR-175). It changes when *Jellyfin* renames a collection type, not + when the library page is redesigned — which is the test for whether something + belongs on this side of the boundary. + +⚠️ **The result side has not moved yet.** `GROUP_ITEM_TYPES` in +`src/lib/utils/searchScope.ts` still maps result groups to item types in the +frontend, and `check:boundary` does not match its shape. Tracked as Stage 2 of +[scoped-search-boundary-implementation.md](../specs/scoped-search-boundary-implementation.md). + +### Library exclusions + +**Location**: `src-tauri/src/repository/exclusions.rs` (TRACES: UR-076 | DR-209) + +Folders the user has chosen to keep out of music browsing — a "Podcasts" folder +inside a music library being the canonical case. Excluded **by item id**, not by +name, in a process-wide `RwLock>` restored from the database at +startup, and applied by the repository layer to every music query (libraries, +artists, albums, genres, search, home rows). + +The id is normalised (`trim`, strip `-`, lowercase) because Jellyfin writes the +same GUID both dashed and undashed depending on the endpoint. The predecessor was +a frontend filter matching the English string "Podcasts" — wrong in three ways at +once, and the reason this lives in the repository. + +The set is process-wide rather than a field on a repository for the same reason +as `online::STREAMING_QUALITY`: it is a preference about *this user's browsing*, +not about a server session, so it must survive a repository being rebuilt on +re-login. + +### Streaming quality ladder + +**Location**: `src-tauri/src/settings.rs` (TRACES: UR-074 | DR-162) + +`StreamingQuality` is a bandwidth ladder (`Original`, 20/10/8/4/2/1 Mbps, +720 kbps), not a resolution picker: it exists to fit a connection, and the +resolution cap is chosen *from* the bitrate so the encoder does not spend a small +budget on pixels it cannot afford. + +| Method | Answers | +|--------|---------| +| `max_bitrate()` | Total bits/s (video + audio), `None` for `Original` | +| `audio_bitrate()` | The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom | +| `video_bitrate()` | Total minus audio, so the two together honour the ceiling | +| `max_height()` | Resolution ceiling that suits the bitrate | + +The ceiling goes to `PlaybackInfo` as `MaxStreamingBitrate` **and** into the +device profile. Sending it there — not just on the transcode URL — is what makes +the cap real: a stream the server decides to *direct play* is served at the +source file's own bitrate, and no URL parameter afterwards can reduce it. + +The frontend names a variant and nothing else; the labels the picker shows are +served over IPC by `player_get_streaming_qualities`. + +## Background workers + +Three long-lived tasks are spawned from the Tauri `setup` hook in `lib.rs`. All +three exist because *when* something happens is a backend policy, not something +a page load should decide. + +| Worker | Location | Responsibility | +|--------|----------|----------------| +| `spawn_catalog_indexer` | `commands/catalog.rs` | Keeps the local FTS5 catalog fresh (DR-109, IR-030) | +| `spawn_favorites_drain` | `commands/favorites.rs` | Retries favourite toggles made while offline (DR-120) | +| `spawn_sync_queue_drain` | `commands/sync_drain.rs` | Drains the offline mutation queue (DR-131) | + +### Catalog indexer + +Replaces the frontend's startup-only `syncCatalog()` call. It ticks on +`CATALOG_INDEX_TICK` and runs a pass when three things hold: a repository exists, +the server is reachable, and the index is due per `index_is_due`. A tick is +nearly free — one indexed `app_settings` lookup — which is what makes it +responsive to events it cannot subscribe to, such as signing in: a fresh install +would otherwise sit unindexed until the next scheduled pass. + +`index_is_due` treats both "never indexed" and an unparseable stored timestamp as +due; a corrupt timestamp should trigger a re-index, not silently freeze the +catalog. A failed pass is never fatal — it leaves the existing index in place and +warns. Progress is emitted on `CATALOG_INDEX_EVENT` for the staleness hint in the +UI. diff --git a/docs/architecture/02-svelte-frontend.md b/docs/architecture/02-svelte-frontend.md index a423eb54..539253bb 100644 --- a/docs/architecture/02-svelte-frontend.md +++ b/docs/architecture/02-svelte-frontend.md @@ -538,6 +538,14 @@ sequenceDiagram ## Auto-Play Episode Limit +> ⚠️ **Autoplay is season-bounded.** `player/mod.rs:fetch_next_episode_for_item` +> does not cross a season boundary, so autoplay stops at the end of a season even +> though the "More Episodes" strip runs past it. Fixing it should reuse +> `repository_get_series_episodes`, but it touches the playback state machine and +> the Android JNI advance path (see the `AutoplayDecision` deadlock note in +> [CLAUDE.md](../../CLAUDE.md)) — its own change, not a drive-by. + + **Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs` **TRACES**: UR-023 | DR-049 @@ -657,3 +665,180 @@ The playlist UI provides full CRUD operations for Jellyfin playlists with offlin All playlist mutations are queued for offline sync: - `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename` - `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem` + +## App Shell and Chrome + +**Location**: `src/lib/utils/layoutShell.ts` (pure rules), +`src/lib/components/AppHeader.svelte`, +`src/lib/components/account/AccountMenu.svelte`, `BottomUi.svelte` +**TRACES**: UR-054 | DR-075, DR-076, DR-077 + +Account actions used to be reachable **only from `/library/*`** — the header +that hosted them belonged to the library layout, the bottom nav offered Home / +Search / Library, and the desktop username was inert text. From `/`, `/search` +or `/downloads` there was no route to Settings or Sign out at all. The header is +now shared and rendered from the root layout. + +### Visibility rules + +All four rules are pure functions in `layoutShell.ts`, so the contract is +unit-testable rather than a scattering of `$derived` booleans that drift per +route and platform (which is what they were): + +| Function | Rule | +|----------|------| +| `showBottomNav` | Every authenticated route except `/player/*` and `/login` | +| `showGlobalMiniPlayer` | Everything except `/player/*`, `/login`, `/settings`. **Not** gated on platform or `/library` — the root owns the mini player everywhere, so the library route must never render a second one | +| `routeOwnsLayout` | `/library`, `/player/`, `/login` render their own full-height flex column; everything else renders into the root scroller | +| `showGlobalHeader` | Authenticated, not a layout-owning route, not `/settings` (the user is already there) | + +### The structural fix worth not undoing + +The "last row hidden behind the nav" bug is solved **structurally, not by +measurement**: the bottom UI is an in-flow flex child *below* the scroller +(`BottomUi.svelte`), so the scroller is physically bounded above it and cannot +render behind it. There is no measurement and no reserved padding. If you +restructure the shell, preserve the scroll containment — reintroducing padding +math reintroduces the bug. + +### AccountMenu + +One component for both breakpoints, anchored to the username/avatar (a real +button with `aria-expanded`, not a bare three-dot icon). Fixed item order: +identity block (user + server) → Downloads, Settings, Display → divider → Sign +out, destructive and last. Dismissal is backdrop click, `Escape`, and focus +return to the trigger. + +The identity block falls back to the bare host of the server URL when the server +has no human-readable name, so it always shows *something* server-identifying. + +Settings' Display section and the library page-header toggle are two views onto +the **same** persisted `viewMode` store (`jellytau-view-mode`) — no second state, +no migration, and they stay in sync for free. + +## Library Mosaic + +**Location**: `src/lib/components/library/libraryMosaic.ts` (pure), +`MosaicGrid.svelte`, `MosaicTile.svelte` +**TRACES**: UR-075, UR-067 | DR-174, DR-175 + +The library overview and the home "Your Libraries" strip are a **mosaic**, not a +grid: rows share one height and each tile is as wide as its own artwork is, so a +square music cover, a 16:9 library backdrop and a 2:3 poster sit in the same row +at their own proportions instead of all three being cropped into whichever box a +grid picked. + +`libraryMosaic.ts` is deliberately pure — it takes the libraries and returns the +tiles to draw, so ordering and de-duplication are unit-testable rather than +buried in markup. Tiles start at an *assumed* aspect (square, 16:9) and a +measured image overrides it in `MosaicGrid`. + +Note what this file does **not** decide: which favourites category a library +belongs to. That is Jellyfin vocabulary and arrives on the library itself as +`favoritesScope`, from `SearchScope::for_collection_type` in Rust (see +[01-rust-backend.md](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)). +The frontend only decides what to *call* it and where to put it. + +## Series and Episode Navigation + +**Location**: `src/lib/components/library/` — `SeasonSection.svelte`, +`EpisodeFocusView.svelte`, `episodeStrip.ts` (pure) +**TRACES**: UR-062 … UR-064 | DR-101 … DR-107 + +Opening a series lands the viewer where they actually are in it. **"Where is this +viewer in this series" is resolved in Rust** (DR-101), not by the page: the +series detail page asks the repository and anchors on the answer — the current +season expanded, the current episode highlighted and scrolled into view, and a +hero button labelled `Resume S2E4` / `Play S1E1`. + +A season is not a destination: `/library/` redirects to its series +(DR-103). Video library routes collapse to one per library (DR-105). + +`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted +from the component because it had three distinct bugs that markup made +untestable: the strip collapsing to just the current episode while real siblings +existed, number-less episodes all matching as "current" (`undefined === +undefined`), and the window dead-ending at a season boundary instead of running +past it. It matches by id first and only falls back to season+episode number when +both numbers are known on both sides. + +## Downloaded Browse + +**Location**: `src/lib/services/downloadedCatalog.ts`, +`src/lib/components/downloads/DownloadedBrowse.svelte` +**TRACES**: UR-055, UR-056 | DR-081 … DR-085 + +`/downloads` is two views: **Downloaded** (the default) — the library filtered to +what is on the device, reusing the same grids, cards and detail pages as online +browsing — and **Transfers**, the in-flight progress rows demoted to a secondary +tab. + +`downloadedCatalog` reads the **offline-only** browse path on the repository, +never the hybrid merge. That is the point: an empty result means "nothing +downloaded here", never "server unreachable", so the view is authoritative +regardless of connectivity. It also owns disk usage — a per-item/container byte +map plus the device total, aggregated by the backend from `downloads.file_size` +(DR-085). + +## Safe-area Insets + +**Location**: `src/app.css`, `WindowInsetsBridge.kt` +**TRACES**: UR-066 | DR-112, IR-031 + +The Android WebView does not reliably report system-bar insets through +`env(safe-area-inset-*)`. Native `WindowInsets` (`systemBars() | +displayCutout()`) are therefore pushed in as CSS custom properties, and every +edge takes the larger of the two sources: + +```css +--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px)); +``` + +Two rules keep this from going wrong: **one owner per edge** (two components both +padding the top edge double-pads it), and **no nested `h-screen`** — a full-height +child inside a full-height parent that has already consumed the inset overflows +by exactly the inset. + +Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it +can safely be re-sent on resume. + +## Native Video Store + +**Location**: `src/lib/stores/nativeVideo.ts` +**TRACES**: UR-003, UR-004 | DR-188 + +Two separate concerns live here, deliberately: + +- `experimentalNativeVideo` — the user-facing opt-in flag, **defaulting to on**. + Rust already decides *which backend this platform has* (`useHtml5Element` from + `player_play_item`); this flag only *suppresses* that decision. It never turns + native on where Rust says HTML5. An explicit stored choice wins in both + directions, so someone who opted out is not re-enabled by a default flip — + hence the `null` check rather than a bare `=== "true"`. +- `nativeVideoActive` — whether a native surface is on screen *right now*. + Setting it toggles `data-native-video` on ``, which is what the CSS in + `app.css` keys off to clear the app's opaque backgrounds. It is deliberately + **not** derived from the flag: the backgrounds must come back the moment the + player unmounts. + +See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android) +for what is behind the WebView. + +## Logging + +**Location**: `src/lib/utils/logger.ts` +**TRACES**: DR-204 + +The frontend's equivalent of the Rust `log` crate: four levels +(`debug < info < warn < error`), a compile-environment default (dev → `debug`, +production → `warn`), and a runtime override that is the moral equivalent of +`RUST_LOG`. Scoped loggers carry the subsystem in the message, so a filtered +console stays usable while a player, a download worker and a store are all +talking. + +Production deliberately keeps **warn and error**: this is a client talking to a +server that may or may not be there, and a silent failure is worse to support +than a noisy console. Only the chatter is suppressed. + +`no-console` is an ESLint **error**, with the sink module itself the only +exception, so a raw `console.*` cannot re-appear. diff --git a/docs/architecture/03-data-flow.md b/docs/architecture/03-data-flow.md index 4e07663c..140099cd 100644 --- a/docs/architecture/03-data-flow.md +++ b/docs/architecture/03-data-flow.md @@ -49,6 +49,61 @@ sequenceDiagram - Background cache updates (planned) - **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline. +## Search Flow (Locally Indexed) + +**TRACES**: UR-065 | DR-108 … DR-111, IR-030 + +Search does not depend on a per-keystroke round trip to Jellyfin. The instant leg +reads the **local SQLite catalog**, which is already synced and already +FTS5-indexed, so results appear as fast as SQLite can answer — online or offline. +The server query stays, demoted to a background reconciliation that merges in +late results. + +```mermaid +sequenceDiagram + participant UI as Search UI + participant Rust as repository_search + participant Cache as Local catalog (FTS5) + participant Server as Jellyfin + participant Indexer as spawn_catalog_indexer + + UI->>Rust: search(query, scope) + Rust->>Cache: FTS5 query, scope expanded by SearchScope::item_types() + Cache-->>UI: instant results + Rust->>Server: reconciliation query (background) + Server-->>UI: search-event with late/merged results + Note over Indexer,Cache: Independent of any query:
scheduled crawl keeps the index fresh,
prunes items deleted on the server +``` + +**Key points:** + +- The **scope is opaque on the wire**. The frontend sends a `SearchScope` + variant; Rust expands it to item types + ([01-rust-backend.md](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)). +- **Index freshness is a Rust policy**, not a frontend startup call — a scheduled + background pass, not "whatever was synced when the app last launched" + (DR-109). See + [Background workers](01-rust-backend.md#background-workers). +- **Index hygiene matters as much as freshness**: the catalog save path uses + `INSERT OR REPLACE` and the crawl prunes rows for content deleted on the + server, or search keeps returning items that no longer exist (DR-110). +- The index covers **exactly the types the result groups render** (DR-111) — + including Artists, which the crawl must reach or the Artists group is silently + always empty. + +**Deliberately not done, with reasons:** + +- **Incremental indexing** (Jellyfin's `MinDateLastSaved`). A *full* crawl is + what makes the deletion sweep sound — it yields the authoritative id set per + library, and an incremental pass cannot detect deletions. Worth revisiting if + full crawls prove slow on large libraries; measure first. +- **Removing the server leg.** The reconciliation query stays. + +> ⚠️ Two dead search implementations still exist: `storage_search_items` +> (`commands/storage/mod.rs`) and `offline_search` (`commands/offline.rs`). Both +> are registered in `lib.rs` and exported to `bindings.ts`; neither is called +> from the frontend. Deleting them is correct and unclaimed. + ## Playback Initiation Flow ```mermaid diff --git a/docs/architecture/05-platform-backends.md b/docs/architecture/05-platform-backends.md index 6429b1da..bcee2598 100644 --- a/docs/architecture/05-platform-backends.md +++ b/docs/architecture/05-platform-backends.md @@ -247,6 +247,184 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO } ``` +### Audio settings on ExoPlayer + +**TRACES**: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 + +`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body. For a +long time `ExoPlayerBackend` took that default, so Settings › Audio rendered +controls that silently did nothing on Android — the parity gap recorded in +[requirements.md](../requirements.md#platform-playback-backend-parity-linux-vs-android), +now closed. + +The settings cross to Kotlin as **JSON over JNI**, not as a wide signature, so new +fields do not change the method signature — the same approach `load()` uses for +subtitles: + +```rust +fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> { + let json = audio_settings_jni_payload(settings)?; + env.call_method(&self.player_ref, "setAudioSettings", "(Ljava/lang/String;)V", …)?; + // Store the sanitised form, so audio_settings() reflects what was applied. + self.shared_state.lock_safe().audio_settings = + settings.clone().with_crossfade_clamped().with_equalizer_normalised(); +} +``` + +Kotlin owns the *mechanics* — attaching `AudioEffect`s to the audio session — while +the canonical band layout and preset curves stay in Rust: + +| Feature | Android mechanism | Notes | +|---------|-------------------|-------| +| Gapless | `pauseAtEndOfMediaItems` | | +| Volume normalization | `LoudnessEnhancer` | A gain stage — approximate next to MPV's `dynaudnorm` | +| Equalizer | `android.media.audiofx.Equalizer` | The canonical 10 bands are resampled onto the device's own band centres | +| Crossfade | — | Unimplemented on **every** platform (DR-034), architecturally blocked on MPV. Building it on Android alone would invert the parity gap | + +Two things are deliberately still open: the effects are **not yet verified on a +physical device** (`AudioEffect` availability and band layouts are device-specific), +and the trait default is still a silent `Ok(())` rather than an error, so a backend +that omits the method still reports success. Flipping that default waits on the +device verification. + +### The equalizer, and where its vocabulary lives + +**TRACES**: UR-027 | DR-030, IR-020 + +The canonical band layout (`EQ_BANDS`) and the preset curves live in +`settings.rs`, **not** in either backend and not in the UI: a preset *is* a gain +curve defined by the band layout, and the layout is a property of the audio +engine rather than of the picker that renders it. Presets are Flat, Rock, Pop, +Jazz, Classical, Bass Boost, Treble Boost and Vocal, all conservative (within +±8 dB) so they stack safely with volume normalization. + +| Platform | Mechanism | +|----------|-----------| +| Linux | One ffmpeg two-pole peaking `equalizer` filter per band, composed by `build_af_filter` into MPV's `af` property alongside the normalization filter: `equalizer=f=31:width_type=o:width=1:g=5` | +| Android | `android.media.audiofx.Equalizer`, with the canonical 10 bands **resampled onto whatever band centres the device actually has** | + +Gains are normalised (`with_equalizer_normalised`) before use, and bands beyond +`EQ_BANDS` are ignored, so a malformed settings payload cannot produce a filter +chain of unbounded length. + +## Background Audio Handoff (Android) + +**TRACES**: UR-040 | IR-025, DR-051, DR-052, DR-178 … DR-180, DR-196, DR-203 + +Keeping a video's **audio** alive when the app is backgrounded or the screen +locks, while video decode stops. Two verified facts drive the whole design: + +1. An Android WebView `