Compare commits

...
9 Commits
Author SHA1 Message Date
dtourolle f3fa45f742 feat(diagnostics): persistent redacted logging and an exportable bundle
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s
The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.

Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.

Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.

Two things the tests caught that review would not have:

  - redact_headers recursed on its own output. The replacement keeps the
    header NAME, so the next call matched the same header forever; the
    test died with a stack overflow. It is a forward scan now.
  - The frontend forwarder used `void plugin.error(...)`. `void` discards
    a promise's value but not its rejection, so in any webview without
    IPC -- a unit test, SSR, a browser preview -- every log line became an
    unhandled rejection. 20 of them showed up the first time coverage
    ran. Each call now attaches a catch.

Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.

Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.

The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.

Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.

Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
2026-08-21 18:58:57 +02:00
dtourolle fb72bf3005 docs(ci): drop the runner-health references the amend missed
The commit that removed .gitea/workflows/runner-health.yml was amended
with a git add whose pathspec named the already-deleted file, so the add
aborted and only the deletion was staged -- leaving ci-operations.md
still describing a scheduled job that no longer exists.

The runner section now says what to check by hand and why there is no
job: on a single-slot runner a daily job takes the slot and pulls the
builder image to run df, and df inside a container does not reliably
describe the host disk.
2026-08-21 18:58:39 +02:00
dtourolle 6897b290ed docs: add the governance and CI-operations files the project never had
The repo had no SECURITY.md, CONTRIBUTING.md, code of conduct, or issue
and PR templates. For a client that handles Jellyfin credentials and
ships signed binaries, the missing one that actually matters is
SECURITY.md: there was no stated way to report a vulnerability
privately, so the only available channel was the public tracker.

CONTRIBUTING.md documents the gates as they now stand, including the
three ratchets and which direction each is allowed to move, and the two
rules that surprise people: bug fixes start with a failing test, and
Jellyfin's taxonomy stays in Rust.

The bug template asks the three playback questions -- streaming or
downloaded, transcoding or direct, music or video -- because those
answers decide which of several very different code paths a report is
about, and reconstructing them over several round trips is most of the
cost of a playback bug report.

docs/build/ci-operations.md is the missing operations manual: how to
change the builder image and in what order (image pushed before the
workflow that names it, or CI breaks), why tags are dated rather than
:latest or per-SHA, what each secret is for, and what losing the updater
private key would mean -- installed desktop clients only accept payloads
signed by the key matching the public key they shipped with, so losing it
means everyone reinstalls by hand.

Disk exhaustion on the runner is documented as a manual check rather than
a scheduled job. A daily job would occupy the only slot on a single-slot
runner and pull the whole builder image to run `df` -- and `df` inside a
container does not reliably describe the host's disk, so it would spend
real build capacity reporting a number that might be wrong. What the doc
records instead is the part that is actually hard to rediscover: the
symptoms (cargo dying mid-link, docker refusing to pull, actions/cache
quietly not saving) and that `docker volume prune` needs `-a` to touch
named volumes, which is how it filled up unnoticed.

Two things in these docs are stated plainly because they are true and
were not written down anywhere: without branch protection every gate in
the pipeline is advisory, and the Gitea instance -- canonical remote,
signing secrets, registry, runner -- is not backed up by anything in this
repository.
2026-08-21 18:45:40 +02:00
dtourolle 3211c96ecf feat(updater): in-app update on desktop, releases link on Android
Anyone who installed an AppImage or ran the Windows installer was frozen
on that version forever. Nothing in the app ever mentioned a new release
existed, and the release notes were the only announcement.

Desktop now checks a signed manifest, shows the version and its notes in
Settings, and installs and relaunches on request. The signature check is
the whole point: it is what stops a substituted download from being
installed by the app itself. Windows binaries stay unsigned for
SmartScreen purposes -- that is a code-signing certificate, a separate
problem -- but the update payload is verified against our own key.

Android is deliberately not wired to the updater. An app may not replace
its own APK; that is the package installer's job, and the plugin has no
Android implementation. It gets a link to the releases page instead of a
button that would throw.

The plugins are gated with a target-triple cfg rather than
cfg(desktop). Cargo only evaluates target cfgs in a [target.'cfg(..)']
table, so cfg(desktop) matches nothing, silently drops the dependency,
and fails much later with "Permission updater:default not found" -- which
is exactly what the first attempt here did.

Where the manifest lives took some finding. This Gitea serves
/releases/download/<tag>/<asset> but 404s on
/releases/latest/download/<asset> (verified against a real asset), so
there is no stable latest-release URL. The gitea-pages branch is
force-pushed wholesale by publish-docs.yml, so it cannot host the file
either. latest.json therefore gets its own orphan branch, read over the
raw-file URL, and is published from a scratch repo in RUNNER_TEMP rather
than by switching branches in the checkout -- doing that would have left
the following steps standing on a one-commit history, and the next step
but one runs release:notes against the real commit range.

Also fixed, all of it release-integrity:

  - "appimage" is in bundle.targets. The release notes have advertised an
    AppImage for months; tauri.conf.json never built one, the artifact
    step globbed for *.AppImage, found nothing, and said nothing. The
    step now fails instead.
  - The .AppImage.tar.gz/.sig pair and the NSIS .sig are collected. A
    manifest referencing a signature that was never uploaded fails only
    on the user's machine, so the manifest step also refuses to write an
    entry with an empty signature.
  - Release notes are generated by release:notes from the traceability
    graph, which is what CLAUDE.md has asked for all along, instead of a
    fixed heredoc that said "see CHANGELOG.md for detailed changes" and
    linked "GitHub Issues" on a Gitea-hosted project.
  - The notes tell users how to verify a download with SHA256SUMS.

Requirements UR-077 / DR-217, tests UT-208 (12 cases over the version
comparison and the platform decision, including that a pre-release does
not offer itself as an upgrade to the matching release).

Verified: 1070 frontend tests, cargo check for both the host and
aarch64-linux-android (confirming the plugins are absent there), clippy
-D warnings, svelte-check 0 errors.
2026-08-21 18:41:50 +02:00
dtourolle 96abc3afef docs(specs): make "a spec becomes an architecture doc" the written rule
The sixteen specs folded in last commit were folded because someone noticed
they had gone stale, not because anything said they should be. Without the rule
written down the directory drifts straight back to a mix of promises and
descriptions, and neither can be trusted: you cannot tell from a file whether it
describes the build or proposes a change to it.

So: docs/specs/ holds only unshipped work, there is no "Implemented" resting
state, and the fold-in and the deletion happen in the same commit.

The template now asks for the destination architecture doc **up front**, which
is a design check rather than bookkeeping — a feature that fits no existing doc
usually has an unclear layer assignment, and it is cheaper to find that out at
spec time. It also tells the author which half of what they are writing is
durable (invariants, rejected alternatives, the defect a decision prevents) and
which half dies with the file (phases, migration steps, acceptance criteria).

The review checklist gains a Lifecycle section, including the case that gets
lost otherwise: out-of-scope work worth doing has to be written where it will
still be found after the spec is gone.
2026-08-21 18:29:09 +02:00
dtourolle f6653e6a8b ci(security): add a supply-chain gate, checksums and an SBOM
The project shipped signed Android builds and unsigned desktop binaries
with no vulnerability scanning of any kind. Nothing checked the ~500
crate Rust graph or the JS packages against an advisory feed, and nothing
checked that what we redistribute inside an MIT bundle permits it.

The first cargo-deny run found eight vulnerabilities and one
unsoundness -- bytes, four in rustls-webpki, time, two in quick-xml and
rand -- every one of them closed by a `cargo update` nobody had a reason
to run. That update is in this commit; 740 Rust tests and clippy
-D warnings pass on the new lockfile.

Two structural fixes matter as much as the gate itself:

  - deny.toml scopes the graph to the targets we actually ship. Without
    it the Apple targets pull in plist -> quick-xml and report two DoS
    advisories against a crate that is in no binary we release. Ignoring
    those by ID would silence them everywhere, including where they
    would matter; scoping makes them correctly absent.

  - libmpv is pinned by rev instead of branch = "master". A branch means
    the revision is whatever Cargo.lock happens to hold and any
    `cargo update` silently substitutes new upstream code -- in the one
    dependency that is not from crates.io and that links a C library
    into the player. The rev is the commit already locked, so this pins
    current behaviour rather than changing it.

Licence findings are recorded rather than waved through. libmpv and
libmpv-sys are LGPL-2.1, satisfied here by dynamic linking against the
system library; deny.toml carries the two obligations that follow (keep
the linkage dynamic, ship libmpv's licence text with any bundle carrying
the .so). MPL-2.0 crates are file-level copyleft and fine unmodified.

Releases now publish SHA256SUMS (verified in-job with `sha256sum -c`
before upload) and a CycloneDX SBOM for both halves, so "does this
release contain <vulnerable crate>?" has an answer that is not "rebuild
the tag and re-resolve it".

Workflows pin jellytau-builder:2026.08 instead of :latest. While every
job said :latest, rebuilding the image changed what every build compiled
against, including rebuilds of old release tags.

Also folded in, because both were the same class of problem:

  - publish-docs.yml downloaded mdBook from GitHub releases into
    /usr/local/bin at job time -- a toolchain install in CI, which
    CLAUDE.md explicitly forbids, and a hard dependency on GitHub's CDN
    at publish time. It is in the builder image now.

  - extract-traces.ts only ever read .ts/.svelte/.rs, so every
    requirement implemented by *configuration* was invisible to the
    matrix that measures it. DR-205, DR-206, DR-207 and DR-215 all carry
    TRACES comments nothing read, and each counted as uncovered while
    being covered. Coverage was really 90%, not 88%; MIN_THRESHOLD moves
    to 89 accordingly. CI workflows stay excluded and there is a test
    saying why: traceability-check.yml quotes "a TRACES: comment" beside
    deliberately-undefined example IDs, which the extractor would read
    as real traces and then fail its own dangling-ID check.

Supply-chain requirement is DR-216.

🔴 The builder image must be rebuilt and pushed
(scripts/build-builder-image.sh 2026.08) before this reaches master --
the workflows now name a tag and tools that do not exist in the registry
yet.
2026-08-21 18:25:38 +02:00
dtourolle 32043a2152 docs: fold shipped specs into the architecture docs and delete them
A spec was a promise; sixteen of them had become descriptions of code that
already shipped, sitting beside four that describe work still outstanding, with
nothing in the file telling the two apart. Half the statuses were also wrong —
audio-equalizer read "Accepted" with the EQ live on both platforms, the native
video spec said the flag stays off after the default was flipped on.

The shipped designs move into docs/architecture, which is the maintained
description of the build, and the spec files go. Git history keeps the
originals; what a future change still needs is carried across:

- 01-rust-backend: favourites rewritten (the old section named a file that no
  longer exists and called shipped buttons "planned"), domain vocabulary owned
  by Rust (SearchScope, exclusions, the bitrate ladder), background workers
- 02-svelte-frontend: app shell and chrome, library mosaic, series/episode
  navigation, downloaded browse, safe-area insets, native-video store, logging
- 03-data-flow: locally-indexed search
- 05-platform-backends: audio settings on ExoPlayer, the equalizer's band
  vocabulary, native video compositing, the background-audio handoff
- 06-downloads-and-offline: one storage model, offline catalog visibility
- 09-security: path confinement and input binding

docs/specs/README.md now says what the directory is for and where each shipped
design went. Deferred work the specs recorded is kept beside the code it
concerns rather than lost: season-bounded autoplay, the two dead search
commands, why indexing is a full crawl.

requirements.md had fourteen stale statuses — Android audio parity still read
"Linux only", DR-150 still said the native-video default was off, DR-190 was
Proposed after DR-196 implemented it, and five tooling requirements were
Proposed after landing. Three unbuilt specs suggested requirement ids that have
since been allocated to other work; each now carries a warning.
2026-08-21 18:15:58 +02:00
dtourolle 8f5c9023d0 ci: make the frontend gates real, and fix the coverage script
The repo configured four frontend gates and enforced one of them. eslint
and prettier ran in no workflow and no hook; `bun run check` ran only in
build-release.yml, so a type error could sit on master until somebody cut
a tag; and `bun run test:coverage` had been dead for months.

CI (build-and-test.yml) now runs format:check, lint, check and coverage
alongside the existing boundary and doc-link tripwires.

The coverage script failure was a version mismatch, not a config problem:
@vitest/coverage-v8 resolved to 4.1.10, whose peer range pins vitest
exactly, while package.json asked for ">=1.0.0 <5.0.0" and got 4.0.16 --
every run died on a missing BaseCoverageProvider export. The loose range
is what allowed the pair to drift, so it is now ^4.1.10.

Two ratchets, same policy as MIN_THRESHOLD in traceability-check.yml:

  eslint  --max-warnings=159   (0 errors; 159 is today's backlog, only
                                ever lower it)
  vitest  thresholds           (statements 51 / branches 45 /
                                functions 46 / lines 52, measured at
                                54.6 / 48.7 / 49.6 / 55.1)

no-console is promoted from "off" to "error": the logger-facade
migration it was waiting on is finished -- 8 calls remained, 2 of them
real stragglers in the settings page, now on the facade the file already
imported. The sink itself, tests, and scripts/ are exempted; a CLI whose
stdout is the product is not a stray debug statement.

The threshold was verified to bite by raising it to 99 and watching the
run go red, not by assuming an unfailed gate works.

DR-205 moves to Done; the coverage gate is DR-215.
2026-08-21 18:11:26 +02:00
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00
283 changed files with 9437 additions and 7300 deletions
+103
View File
@@ -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"
+37
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
## What and why
<!-- What changes, and the reason. The diff shows the what; the why is what
the commit log is for. -->
## How it was verified
<!-- What you actually ran or clicked. "Tests pass" on its own says little;
"played a transcoded episode on Android, seeked twice, backgrounded it"
says a lot. -->
## 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)
+88 -3
View File
@@ -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"
+219 -61
View File
@@ -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 <vulnerable crate>?" 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/<tag>/<asset> but returns 404 for
# /releases/latest/download/<asset>, 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 "<os>-<arch>". 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 \
+8 -9
View File
@@ -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
+3 -3
View File
@@ -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
+40 -6
View File
@@ -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
+47
View File
@@ -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.
+123
View File
@@ -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.
+23
View File
@@ -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"]
+23
View File
@@ -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
+60
View File
@@ -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.
+27 -20
View File
@@ -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=="],
+2 -2
View File
@@ -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:
+2 -29
View File
@@ -26,47 +26,20 @@
- [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)
- [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
+171 -38
View File
@@ -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<br/>pending_sync| UserData[user_data table]
Service -->|"1. Optimistic"| LocalDB[("SQLite user_data<br/>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<br/>(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<Vec<String>> { }
/// The scope a library of this Jellyfin `CollectionType` belongs to.
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> { }
}
```
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<Vec<String>>` 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.
+185
View File
@@ -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/<seasonId>` 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 `<html>`, 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.
+55
View File
@@ -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:<br/>scheduled crawl keeps the index fresh,<br/>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
+178
View File
@@ -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 `<video>` **does not** keep playing audio once the app is
backgrounded — the system throttles the WebView and media pauses.
2. Keeping audio alive in the background requires a **native foreground media
service**, which already exists for music (`JellyTauPlaybackService` +
`JellyTauPlayer` + `MediaSessionCompat`).
So this is a **handoff**, not "keep the WebView alive": on background, tear down
the current renderer and play the same item audio-only through the native
service; on foreground, hand back. In the project's one-directional playback
model this is a change of *which player is authoritative*, and the position must
transfer cleanly across it.
```mermaid
sequenceDiagram
participant App as App backgrounded
participant FE as VideoPlayer
participant Rust as player_enter/exit_background_audio
participant Exo as Native audio service
App-->>FE: jellytau-background (DOM CustomEvent)
FE->>Rust: enter(item, position, audioStreamIndex)
Rust->>Exo: play audio-only at position
Note over Exo: lockscreen + notification, existing MediaSession
App-->>FE: jellytau-foreground
FE->>Rust: exit() -> final position
Rust-->>FE: position
FE->>FE: restart the renderer that is on screen
```
Details that were each a shipped defect:
- **Position is absolute.** Transcoded HLS tracks time as
`videoElement.currentTime + seekOffset` (the element resets to 0 after each
transcode reload). `computeHandoffPosition` sums both terms; using the element
time alone rewinds by the offset.
- **A downloaded episode takes no base URL and an ordinary seek** (DR-180); a
stream takes the base and no seek; a handoff at 0:00 takes neither.
- **The return must restart the renderer that is actually on screen** (DR-196).
The two paths resume by different means — the webview `<video>` reloads off its
stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing
watches the URL for it, so it needs an explicit re-issue. Doing only the URL
assignment restarted nothing on the native path and left a black screen with a
play button that did nothing.
- **`wasPlaying` is captured on the way out** so play/pause survives the round
trip, and the handoff does not silently rewind (DR-203).
- **Mutually exclusive with PiP.** Toggle on → `setAutoEnterEnabled(false)`;
toggle off → PiP on background, the status quo. The frontend re-asserts the
value whenever the toggle changes and on unmount, so a stale setting cannot
leak into the next player.
- The pure arithmetic and state transitions live in
`backgroundAudioHandoff.ts`, free of Svelte and the DOM, so they are testable
without mounting the player.
Native signals background/foreground to the frontend as DOM CustomEvents
(`jellytau-background` / `jellytau-foreground`); the frontend carries the toggle
state to native through the `AndroidBackgroundAudio` bridge. No-op on every
non-Android platform.
## Native Video Compositing (Android)
**TRACES**: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196
Android can render video on the **native ExoPlayer surface behind a transparent
Tauri WebView**, with the Svelte controls drawn over it. This is on by default;
the HTML5 `<video>` path remains the fallback and is not being removed. The
default has been flipped and reverted twice and each revert has a named cause —
the per-defect record is in `requirements.md` (DR-150 … DR-196).
```mermaid
flowchart TB
subgraph Window["One Android window"]
Texture["TextureView (index 0)<br/>ExoPlayer video"]
WebView["Tauri WebView (above)<br/>transparent, Svelte controls"]
end
Rust["ExoPlayerBackend"] -->|JNI| Player["JellyTauPlayer"]
Player --> Texture
MainActivity -->|"setTransparent(true)"| WebView
VideoOverlayManager -->|"attach / detach"| Texture
```
Load-bearing details, each of which was a shipped defect:
- **TextureView, not SurfaceView** (DR-192). A SurfaceView renders on its own
layer *outside* the app window and punches a transparent hole through it;
everything drawn above that hole — for us the whole UI — depends on that
composition path, which Android's own documentation says does not reliably
work. A TextureView makes "behind" ordinary view z-order within one window.
- **Attached at index 0** by `VideoOverlayManager`, and **detached when the video
goes** (DR-184) — a surface left in the hierarchy outlives its player.
- **Bridges are installed before the page that uses them** (DR-183).
`addJavascriptInterface` must run once per WebView instance and a call that
lands after the page has loaded never reaches it, so `setTransparent(true)`
could be dropped entirely.
- **The app shell stops painting over the surface** (DR-185). `app.css` clears
its opaque backgrounds off `[data-native-video]`; before that, a CSS rule
targeted an attribute nothing ever set, so the fix looked applied and was not.
- **The poster card can lift on a path with no `<video>` element** (DR-182) — the
native reveal fires on a `playing` state or a position tick carrying a position
or duration, and on nothing else.
- **Letterbox bars are painted**, not left holding whatever was last in the
framebuffer (DR-194).
- There is deliberately **no audio-focus bridge**: manual focus requests from the
WebView competed with Chromium's `AudioFocusDelegate` and with ExoPlayer, and
the resulting `AUDIOFOCUS_LOSS` paused playback.
Related Kotlin pieces in the same window: `PictureInPictureManager` (DR-160/161),
`ScreenWakeManager` (DR-202 — Android counts its display timeout from touch
events, which a playing video does not generate), `ImmersiveModeBridge` and
`WindowInsetsBridge` (IR-031/DR-112 — see
[02-svelte-frontend.md](02-svelte-frontend.md#safe-area-insets)).
## Android MediaSession & Remote Volume Control
**Location**: `JellyTauPlaybackService.kt`
+53 -1
View File
@@ -139,9 +139,56 @@ flowchart TB
CheckStorage -->|"OK"| Download["Queue Download"]
```
## One Storage Model: Cache Entries Are Downloads
**TRACES**: UR-071 | DR-126, DR-127
A cache entry **is** a download with a shorter life: the same `downloads` row and
the same file handling, distinguished by `download_source` plus an expiry. There
is one storage model rather than a cache and a download library that can
disagree about what is on disk.
| `download_source` | Life | Reclaimed by |
|-------------------|------|--------------|
| `'auto'` (temporary) | Expiry, or eviction under space pressure | Both |
| `'user'` (permanent) | No expiry | Neither |
**Eviction only reclaims the temporary tier.** `evict_lru_async` originally
selected every completed download ordered by `completed_at ASC` with no source
filter, so hitting the storage limit deleted the *oldest* download — typically a
film saved deliberately for offline — to make room for a newly precached track.
It now evicts only `COALESCE(download_source, 'user') = 'auto'` rows.
`COALESCE` rather than a bare equality is load-bearing: rows predating the
migration can be NULL, and **unknown provenance must be treated as the user's,
never as disposable**. Freeing less than requested is the correct outcome when
only user downloads remain — the caller reports "unable to free enough".
A temporary row can be **promoted** to permanent when the user chooses to keep
it. That only clears the expiry and flips the source; the bytes never move.
## Offline Catalog Visibility
**TRACES**: UR-052 | DR-078, DR-079, DR-080
Offline, a library page shows **only media on the device**. A "Show all server
media" toggle additionally reveals the cached server catalog, greyed out and
queueable for download on reconnect.
The gate is a process-global `INCLUDE_CATALOG_BROWSE` in
`repository/offline.rs`, written by the `set_show_server_catalog` command. It
gates the synced-catalog leg of `get_items`; without it the toggle rendered but
every server item still appeared, which is the defect the spec was written for.
`isConnected` derives from backend-reported reachability alone (DR-079) — see
[07-connectivity.md](07-connectivity.md).
Per-item disk usage comes from `repository_get_download_disk_usage`
(`DownloadDiskUsage`), aggregated from `downloads.file_size` — used by the
Downloaded browse cards, detail pages, the device total and the remove
confirmation (DR-085).
## Download Commands
**Location**: `src-tauri/src/commands/download.rs`
**Location**: `src-tauri/src/commands/download/``mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`
| Command | Parameters | Description |
|---------|------------|-------------|
@@ -152,6 +199,11 @@ flowchart TB
| `resume_download` | `download_id` | Resume paused download |
| `cancel_download` | `download_id` | Cancel and delete partial |
| `delete_download` | `download_id` | Delete completed download |
| `download_video` / `download_series` / `download_season` | item ids | Queue video content |
| `get_download_storage_stats` | `user_id` | Device totals for the downloads screen |
| `delete_album_downloads` / `delete_downloads_under` / `delete_all_downloads` | container id | Bulk removal |
| `pin_item` / `unpin_item` / `is_item_pinned` | `item_id` | Protect metadata from a cache clear |
| `set_max_concurrent_downloads` | `max` | Worker concurrency (3 by default) |
## Offline Commands
+35
View File
@@ -122,6 +122,41 @@ reports `NETWORK_NO_SOURCE` (which is exactly how DR-134's failure presented).
| Downloaded Media | Filesystem permissions only |
| Cached Thumbnails | Filesystem permissions only |
## Path Confinement and Input Binding
Two classes of defect, both of the same *shape*: a value that arrived from
outside decided something it should not, at a site whose neighbours a few lines
away already did it correctly.
### Filesystem path confinement
| Surface | Rule | TRACES |
|---------|------|--------|
| Thumbnail cache | The filename is built from `item_id`, `image_type` and `tag`; all three are sanitised (non-alphanumerics → `_`), and the resolved path is checked with `starts_with(cache_dir)` **at the point of use** | DR-210 |
| Downloads | `file_path` and `target_dir` are sanitised inside `download_item` itself, not only in `download_item_and_start` — the latter is what made the existing guard bypassable rather than absent | DR-211 |
Two mechanics worth remembering, because both are easy to get subtly wrong:
- `Path::join` **neither folds `..` nor keeps the base when handed an absolute
path**. Confinement therefore has to be checked *after* the join, not before.
- Sanitising is **per path component**. Whole-string sanitising would rewrite
`downloads/x.mp3` to `downloads_x.mp3` and relocate every existing download.
The database keeps both the raw key and the resolved path, so lookups still match
and pre-existing rows still resolve.
### Query and URL construction
Caller-supplied values are **bound or encoded**, never interpolated (DR-212):
- The offline `get_items` item-type filter uses parameter placeholders rather
than formatting `IN ('a','b')`.
- `build_get_items_endpoint` encodes `ParentId` / `IncludeItemTypes` / `SortBy` /
`SortOrder`. Encoding is **per element** and list separators stay unencoded,
because Jellyfin splits these parameters on the comma.
- `player_set_volume` clamps at the command boundary — it previously accepted
NaN and out-of-range floats even though every backend clamps internally.
## Security Considerations
1. **No Secrets in SQLite**: The database contains only non-sensitive metadata
+29 -17
View File
@@ -95,15 +95,15 @@ Each major subsystem is documented in its own file in this directory:
| Document | Contents |
|----------|----------|
| [01 - Rust Backend](01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands |
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI |
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control |
| [01 - Rust Backend](01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites (marking + browsing), player backend trait, player controller, playlist system, **domain vocabulary owned by Rust** (search scope, library exclusions, streaming quality ladder), **background workers** (catalog indexer, drains), Tauri commands |
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging |
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control |
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization |
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components |
| [05 - Platform Backends](05-platform-backends.md) | Player events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, **one storage model (cache entries are downloads)**, offline catalog visibility, download/offline commands, player integration, frontend store, UI components |
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
| [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
| [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, webview CSP + asset-protocol scope, **path confinement and input binding**, local data protection |
---
@@ -112,17 +112,24 @@ Each major subsystem is documented in its own file in this directory:
```
src-tauri/src/
├── lib.rs # Tauri app setup, state initialization
├── commands/ # Tauri command handlers (90+ commands)
├── commands/ # Tauri command handlers (~245 #[tauri::command] fns)
│ ├── mod.rs # Command exports
│ ├── player.rs # 16 player commands
│ ├── repository.rs # 27 repository commands
│ ├── playlist.rs # 7 playlist commands
│ ├── playback_mode.rs # 5 playback mode commands
│ ├── connectivity.rs # 7 connectivity commands
│ ├── storage.rs # Storage & database commands
│ ├── download.rs # 7 download commands
│ ├── offline.rs # 3 offline commands
── sync.rs # Sync queue commands
│ ├── player/ # Player commands: queue, remote, session, settings, timers
│ ├── repository.rs # Repository commands (items, search, favourites, disk usage)
│ ├── catalog.rs # Catalog sync + the background index pass
│ ├── favorites.rs # Offline favourite drain
│ ├── library.rs # Library listing + folder exclusions
│ ├── playlist.rs # Playlist commands
│ ├── playback_mode.rs # Local/remote transfer
│ ├── playback_reporting.rs
── connectivity.rs # Connectivity commands
│ ├── storage/ # Storage & database commands: people, series_prefs, thumbnails
│ ├── download/ # Download commands: mod, pinning, smart_cache
│ ├── offline.rs # Offline commands
│ ├── device.rs # Device id / capabilities
│ ├── sessions.rs # Remote sessions
│ ├── sync.rs # Sync queue commands
│ └── sync_drain.rs # Background sync-queue drain
├── repository/ # Repository pattern implementation
│ ├── mod.rs # MediaRepository trait, handle management
│ ├── types.rs # RepoError, Library, MediaItem, etc.
@@ -207,4 +214,9 @@ src/lib/
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.
**Total Commands:** 90+ Tauri commands across 14 command modules
**Total Commands:** ~245 `#[tauri::command]` functions across 17 command modules
(~58k lines of Rust, ~37k non-test lines of TypeScript/Svelte).
> Counts and line totals in this file are periodic snapshots, not gates — the
> authority is the tree. Regenerate with
> `grep -rc '#\[tauri::command\]' src-tauri/src` and `wc -l`.
+165
View File
@@ -0,0 +1,165 @@
# CI operations
How the pipeline is kept working: the builder image, the secrets it needs, the
gates that must stay required, and the things that only a human with access to
the Gitea instance can do.
CI is **Gitea Actions** (`.gitea/workflows/`) on `gitea.tourolle.paris`, not
GitHub.
## The workflows
| Workflow | Trigger | What it protects |
|---|---|---|
| [build-and-test.yml](../../.gitea/workflows/build-and-test.yml) | push/PR to `master` | Frontend + Rust gates, Android compile check, supply chain |
| [traceability-check.yml](../../.gitea/workflows/traceability-check.yml) | push/PR | Requirement coverage ratchet, dangling IDs |
| [build-release.yml](../../.gitea/workflows/build-release.yml) | tag `v*` | Builds, signs, publishes, and writes the update manifest |
| [publish-docs.yml](../../.gitea/workflows/publish-docs.yml) | push to `master` | Docs site on the `gitea-pages` branch |
## 🔴 CI installs no system tools
Every build, test and packaging **tool** lives in the Docker image the job runs
in. Never add `apt-get`, `rustup`, `sdkmanager`, or a `curl | tar -xz` of a
binary to a workflow step.
Fetching the project's *own declared dependencies* is not a toolchain install and
is fine: `bun install`, cargo pulling crates from the lockfile, `cargo deny`
fetching the RustSec advisory database. The distinction is tool versus data.
This rule has been broken twice, both times invisibly until something else
failed. `publish-docs.yml` downloaded mdBook from GitHub releases into
`/usr/local/bin` at job time — a hard dependency on GitHub's CDN being up
whenever docs were published. Both mdBook and the supply-chain tools are in the
image now.
## The builder image
`Dockerfile.builder``gitea.tourolle.paris/dtourolle/jellytau-builder`.
It carries: the pinned Rust toolchain plus rustfmt/clippy and the Android,
Windows-MSVC targets; bun and Node; the Android SDK/NDK and a local Gradle
distribution; Linux desktop and packaging deps (WebKitGTK, libmpv, rpm, NSIS,
cargo-xwin); and the tooling — `cargo-deny`, `cargo-cyclonedx`, `mdbook`.
Arch packages build in a separate `Dockerfile.arch`, because `makepkg` is
Arch-specific.
### Tags are pinned, and why
Workflows name an **immutable dated tag** (`:2026.08`), never `:latest`. While
every job said `:latest`, rebuilding the image silently changed what every build
compiled against — including a rebuild of an old release tag, which is the
opposite of reproducible.
`:latest` is still pushed alongside, for local `docker compose` runs and manual
pulls.
Date tags rather than per-commit SHA tags on purpose: the runner shares a 74 GB
disk with two other projects, and SHA-tagged images accumulated there until it
filled. Keep a couple of dated tags live and prune the rest.
### Changing the image
The order matters — CI breaks if the workflow lands before the image exists.
```bash
# 1. Edit Dockerfile.builder. Put new tools in the TRAILING layer: it exists so
# a tool change is a ~2 min rebuild instead of ~15.
# 2. Build and push, tagged with the new month:
./scripts/build-builder-image.sh 2026.09
# 3. Repoint every workflow at the new tag, in the same commit as whatever
# needed the new tool:
sed -i 's|jellytau-builder:2026.08|jellytau-builder:2026.09|g' .gitea/workflows/*.yml
# 4. Verify the tools are actually in it:
docker run --rm gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09 \
-c "cargo deny --version; mdbook --version"
```
🔴 The Rust version is pinned in **two** places that must agree:
`RUST_VERSION` in `Dockerfile.builder` and `channel` in
`src-tauri/rust-toolchain.toml`. If they drift, rustup downloads the pinned
toolchain inside the job — a toolchain install in CI. Bump both, rebuild, push,
then merge.
## Secrets
Managed with the `tea` CLI (`tea actions secrets list`) or the repo settings UI.
| Secret | Used by | Notes |
|---|---|---|
| `ANDROID_KEYSTORE_BASE64` | release | Base64 of the release keystore |
| `ANDROID_KEYSTORE_PASSWORD` | release | |
| `ANDROID_KEY_ALIAS` | release | |
| `ANDROID_KEY_PASSWORD` | release | |
| `TAURI_SIGNING_PRIVATE_KEY` | release | minisign key for the desktop updater |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | release | |
| `GITEA_TOKEN` | release, docs | PAT; falls back to the auto-provided token |
The updater keypair's public half is committed in `src-tauri/tauri.conf.json`
that one is meant to be public; it is what clients verify against. The private
half exists in the Gitea secret and in the maintainer's local `.env` (which is
gitignored) and at `~/.tauri/jellytau.key`.
**Losing the private key means losing the ability to ship updates to installed
desktop clients**, because they will only accept payloads signed by the key
matching the public key they were built with. Recovering means generating a new
pair, shipping a build carrying the new public key, and telling everyone on an
older build to reinstall by hand. Back it up.
## Required status checks
Gitea → repo Settings → Branches → protect `master`, requiring:
- `Run Tests`
- `Android Compile Check`
- `Supply Chain`
- the traceability job
Without branch protection, every gate in this document is advisory: a push
straight to `master` lands whether or not CI is red. That is the state the repo
was in for its whole history before this was set up.
## The runner
One self-hosted runner, one ~74 GB disk shared with two other projects. It fills,
and when it does the symptoms are misleading: cargo dying mid-link, docker
refusing to pull, `actions/cache` quietly not saving — anything except an obvious
out-of-space error. Check the disk first.
There is deliberately no scheduled job watching this. On a single-slot runner a
daily job occupies the slot and pulls the builder image to run `df`, and `df`
inside a container does not reliably describe the host's disk anyway — it would
cost real build capacity to report a number that might be wrong. Check it by hand
on the runner:
```bash
df -h /
docker system df -v
```
When it does fill:
```bash
docker image prune -a
docker volume prune -a # the -a matters: without it, NAMED volumes are kept,
# which is exactly how this filled up unnoticed
```
Never cache `src-tauri/target` — it is ~16 GB, and caching it under several keys
is what filled the disk at ~1.15 GB/day. The workflows cache only the cargo
registry index and `.crate` tarballs; cargo re-extracts `registry/src` for free.
## Release verification
The steps that catch a broken release before users do are in
[release-checklist.md](../release-checklist.md) — in particular the update path:
`latest.json` must be live on the `updater` branch, both platform entries must
carry a non-empty signature, and the previous release should be installed and
asked to update to the new one.
## Bus factor
The Gitea instance holds the canonical remote, the signing secrets, the container
registry and the CI runner. **It is not backed up as part of this repository, and
nothing in this repository can restore it.** That is the largest single risk to
the project — larger than any gate in this document — and the backup lives
outside it.
+18
View File
@@ -126,6 +126,24 @@ git push origin v1.2.0
- [ ] All artifacts are uploaded
- [ ] Release type is correct (prerelease vs release)
- [ ] Verify integrity metadata (DR-216):
- [ ] `SHA256SUMS` is present, and `sha256sum -c SHA256SUMS` passes in the
directory you downloaded into
- [ ] SBOM files are present (`*.cdx.json`, `frontend-dependencies.txt`)
- [ ] Verify the update path (DR-217) — this is the step that catches a broken
updater *before* users hit it, because a bad manifest fails only on their
machine:
- [ ] `latest.json` is live and names this version:
`curl -s https://gitea.tourolle.paris/dtourolle/jellytau/raw/branch/updater/latest.json | jq .version`
- [ ] Both platform entries carry a non-empty `signature`
- [ ] The `.AppImage.tar.gz`, its `.sig`, and the NSIS `.sig` are among the
release assets — the manifest points at them
- [ ] Install the **previous** release, launch it, and use Settings → Updates:
it should offer this version, install it, and relaunch
- [ ] On Android, Settings → Updates offers the releases page rather than an
install button (the updater plugin is not compiled for that target)
- [ ] Announce release:
- [ ] Post to relevant channels/communities
- [ ] Update website/docs
+30 -20
View File
@@ -37,13 +37,13 @@ For a narrative overview of the system design, see
| UR-024 | View recently added content on server | Medium | Done |
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
| UR-027 | Audio equalizer for sound customization | Low | Done (Linux only) |
| UR-027 | Audio equalizer for sound customization | Low | Done (Linux; Android pending device verification) |
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
| UR-029 | Toggle between grid and list view in library | Medium | Done |
| UR-030 | Quick genre browsing and filtering | Medium | Done |
| UR-031 | Crossfade between audio tracks | Low | Not implemented (blocked — see DR-034) |
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux; Android pending device verification) |
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux; Android pending device verification) |
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
@@ -85,7 +85,9 @@ For a narrative overview of the system design, see
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Proposed |
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Done |
| UR-077 | The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throw | Medium | Done |
| UR-078 | JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the file | Medium | Done |
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
---
@@ -118,7 +120,7 @@ External system integrations and platform-specific implementations.
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | Subtitle rendering and selection in the **video** playback backends: ExoPlayer sideloads each track as a `MediaItem.SubtitleConfiguration` and selects by text-track-group position (Android), and the WebKitGTK HTML5 `<video>` element renders `<track kind="subtitles">` children carrying `data-stream-index` (Linux). **Originally scoped to libmpv, which never implemented it**: `MpvBackend` is the audio-only backend here and does not override `PlayerBackend::set_subtitle_track`, so the default `not_implemented()` still stands there. UR-020 is satisfied by the two paths above rather than by MPV | Playback | UR-020 | Done |
| IR-019 | Audio track selection in the **video** playback backends: ExoPlayer switches track by index natively (Android), while the HTML5 `<video>` path cannot switch a track in the element and instead re-opens the stream at the chosen `AudioStreamIndex` and resumes at the same position (Linux) — the two outcomes `AudioTrackSwitchResponse` distinguishes. **Originally scoped to libmpv, which never implemented it**: `MpvBackend` does not override `PlayerBackend::set_audio_track`, so the default `not_implemented()` still stands there. UR-021 is satisfied by the two paths above rather than by MPV | Playback | UR-021 | Done |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV and Android/`audiofx.Equalizer`; Android pending device verification) |
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
@@ -236,8 +238,8 @@ Internal architecture, components, and application logic.
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Not implemented (blocked on MPV: single-stream audio chain; `acrossfade` needs 2 inputs — see docs/specs/playback-backend-unification.md) |
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux via MPV; Android via `pauseAtEndOfMediaItems` — pending device verification) |
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux via MPV `dynaudnorm`; Android via `LoudnessEnhancer` — pending device verification) |
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
@@ -338,7 +340,7 @@ Internal architecture, components, and application logic.
| DR-146 | The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first `isTrackSupported` track and overrides to that, and clears `setTrackTypeDisabled(TRACK_TYPE_AUDIO)` because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the log | Playback | UR-004 | Done |
| DR-148 | The video direct-play profile advertises only what the **webview** can decode. The audio codec list comes from `MediaCodecList`, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview `<video>` element (the interim override in `VideoPlayer.svelte`, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping `/vendor/etc/media_codecs_dolby_audio.xml` reports `ac3,eac3`, so Jellyfin direct-played an E-AC-3 track with `static=true` and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by *capability*, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. `video_audio_codecs` narrows the platform list to the webview-decodable set (`aac,mp3,opus,vorbis,flac`) for the video direct-play profile *only* — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims `aac` rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audio | Playback | UR-004 | Done |
| DR-149 | The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s `Container` and `VideoCodec` — excluding either returns `SupportsDirectPlay: false` with `TranscodeReasons=ContainerNotSupported` / `VideoCodecNotSupported` — but **ignores its `AudioCodec`**, offering an E-AC-3 track for direct play against a profile listing only `aac,flac,mp3,opus,vorbis`. Neither a `VideoAudio` `CodecProfile` forbidding the codec nor a `MaxAudioChannels: 2` against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — `direct_play` and `needs_transcoding` are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already play | Playback | UR-004 | Done |
| DR-150 | Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a **suppressor, never a promoter**: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app | Playback | UR-003, UR-004 | Done (behind `experimentalNativeVideo`, default off) |
| DR-150 | Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a **suppressor, never a promoter**: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app | Playback | UR-003, UR-004 | Done (behind `experimentalNativeVideo`, **default on** since DR-194/DR-196) |
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
@@ -382,7 +384,7 @@ Internal architecture, components, and application logic.
| DR-194 | Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: **nothing painted the bars.** The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the *letterboxed* video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip | Android | UR-003, UR-066 | Done |
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
| DR-192 | Native video presents through a **TextureView**, not a SurfaceView. A SurfaceView renders on its own layer *outside* the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers `surface_type="texture_view"` and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so the old `SurfaceHolder.Callback` wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured bounds | Android | UR-003, UR-004, UR-041 | Done |
| DR-190 | The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView `<video>`: `applyPendingForegroundSeek` returns early on `!videoElement`, the HLS re-init `$effect` returns early on `!useHtml5Element`, and `pendingForegroundSeek`/`pendingForegroundPlay` — which own the post-handoff position and play/pause — are consumed only by `handleCanPlay` and `markMediaReady`, an element event and a path that reaches the same guard. On the native path there is no element, so `exitBackgroundAudioHandoff` completes, clears `handoffState`, blanks and reassigns `currentStreamUrl` to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position `player_exit_background_audio` reports, then honour `wasPlaying` — routed through the adapter rather than the element, so both paths restore through one contract | Playback | UR-040, UR-003 | Proposed |
| DR-190 | The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView `<video>`: `applyPendingForegroundSeek` returns early on `!videoElement`, the HLS re-init `$effect` returns early on `!useHtml5Element`, and `pendingForegroundSeek`/`pendingForegroundPlay` — which own the post-handoff position and play/pause — are consumed only by `handleCanPlay` and `markMediaReady`, an element event and a path that reaches the same guard. On the native path there is no element, so `exitBackgroundAudioHandoff` completes, clears `handoffState`, blanks and reassigns `currentStreamUrl` to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position `player_exit_background_audio` reports, then honour `wasPlaying` — routed through the adapter rather than the element, so both paths restore through one contract | Playback | UR-040, UR-003 | Superseded by DR-196 |
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
@@ -393,17 +395,21 @@ Internal architecture, components, and application logic.
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
| DR-204 | A leveled logging facade for the frontend, replacing raw `console.*` calls. One module owns the log sinks, so a level (error/warn/info/debug) decides at run time what is emitted rather than every call site deciding permanently at authoring time: a release build stays quiet, a developer chasing a playback bug turns the player's debug output on without editing and rebuilding, and nothing that reaches the console is written by a `console.log` nobody can find again. Scoped loggers carry the subsystem in the message, so a filtered console is usable while a player, a download worker and a store are all talking | Tooling | - | Proposed |
| DR-205 | ESLint + Prettier run as a gate over the frontend, so lint and formatting are decided once by configuration rather than per reviewer. Formatting is not a matter of opinion at review time, and the classes of bug a linter sees (unused bindings, floating promises, accidental globals) should never reach a human reviewer at all. Wired as an npm script so the same command runs locally and in CI, matching how `check:boundary` and the traceability gate already work | Tooling | - | Proposed |
| DR-206 | The Rust toolchain is pinned in-repo (`rust-toolchain.toml`) and the pin is what both a developer's machine and CI use. Without it, `cargo fmt --check` and `cargo clippy` are run by whatever version each host happens to have, so a formatting or lint result differs between a laptop and the builder image and CI fails on a diff that was clean locally — the failure mode is a red build nobody can reproduce. The builder image carries the pinned toolchain, so pinning is a *declaration*, not a CI-time install (see the no-toolchain-installs rule) | Tooling | - | Proposed |
| DR-207 | A pre-commit hook runs the "Before Committing" gates — frontend checks and tests, `cargo fmt`, clippy, the boundary tripwire and the traceability checks — so the gates are enforced at the commit rather than discovered in CI. The gates already exist and are already documented; what is missing is that nothing runs them, which makes compliance a matter of memory. The hook is the mechanism that makes the documented list actually binding | Tooling | - | Proposed |
| DR-204 | A leveled logging facade for the frontend, replacing raw `console.*` calls. One module owns the log sinks, so a level (error/warn/info/debug) decides at run time what is emitted rather than every call site deciding permanently at authoring time: a release build stays quiet, a developer chasing a playback bug turns the player's debug output on without editing and rebuilding, and nothing that reaches the console is written by a `console.log` nobody can find again. Scoped loggers carry the subsystem in the message, so a filtered console is usable while a player, a download worker and a store are all talking | Tooling | - | Done |
| DR-205 | ESLint + Prettier run as a gate over the frontend, so lint and formatting are decided once by configuration rather than per reviewer. Formatting is not a matter of opinion at review time, and the classes of bug a linter sees (unused bindings, floating promises, accidental globals) should never reach a human reviewer at all. Wired as an npm script so the same command runs locally and in CI, matching how `check:boundary` and the traceability gate already work | Tooling | - | Done |
| DR-206 | The Rust toolchain is pinned in-repo (`rust-toolchain.toml`) and the pin is what both a developer's machine and CI use. Without it, `cargo fmt --check` and `cargo clippy` are run by whatever version each host happens to have, so a formatting or lint result differs between a laptop and the builder image and CI fails on a diff that was clean locally — the failure mode is a red build nobody can reproduce. The builder image carries the pinned toolchain, so pinning is a *declaration*, not a CI-time install (see the no-toolchain-installs rule) | Tooling | - | Done |
| DR-207 | A pre-commit hook runs the "Before Committing" gates — frontend checks and tests, `cargo fmt`, clippy, the boundary tripwire and the traceability checks — so the gates are enforced at the commit rather than discovered in CI. The gates already exist and are already documented; what is missing is that nothing runs them, which makes compliance a matter of memory. The hook is the mechanism that makes the documented list actually binding | Tooling | - | Done |
| DR-208 | Documentation link integrity is checked mechanically (`scripts/check-doc-links.sh`): every relative markdown link in every tracked `.md` must resolve to a file that exists on disk. This is a real defect class, not hygiene — the generated traceability matrix shipped ~2,800 dead file links because it was written to `docs/` while its hrefs were repo-root-relative, and nothing noticed for months because no check existed and nobody clicks 2,800 links. The check validates *paths*, deliberately not anchors or external URLs: anchor resolution needs a markdown renderer's slug rules and network checks make the gate flaky, so both are out of scope and stated as such in the script | Tooling | - | Done |
| DR-209 | Library folders are excluded from music browsing **server-side, by folder id**, replacing a hardcoded frontend filter that dropped anything whose name contained "Podcasts". The name filter was wrong in three separate ways: it encoded a domain classification in the presentation layer, it matched on a title rather than on what an item *is* (so an album legitimately called "Podcasts" vanished while a podcast folder named anything else did not), and it applied only where someone had remembered to call it, so the same library was in scope on one screen and out of scope on the next. Excluded folder ids are stored as user configuration and applied by the repository layer to every music query — libraries, artists, albums, genres, search and the home rows — so scope is decided in one place and is the same everywhere | Repository | UR-076 | Proposed |
| DR-209 | Library folders are excluded from music browsing **server-side, by folder id**, replacing a hardcoded frontend filter that dropped anything whose name contained "Podcasts". The name filter was wrong in three separate ways: it encoded a domain classification in the presentation layer, it matched on a title rather than on what an item *is* (so an album legitimately called "Podcasts" vanished while a podcast folder named anything else did not), and it applied only where someone had remembered to call it, so the same library was in scope on one screen and out of scope on the next. Excluded folder ids are stored as user configuration and applied by the repository layer to every music query — libraries, artists, albums, genres, search and the home rows — so scope is decided in one place and is the same everywhere | Repository | UR-076 | Done |
| DR-210 | Thumbnail cache writes are confined to the cache directory. The filename was built from `item_id`, `image_type` and `tag`, but only `tag` was sanitised — and `Path::join` neither folds `..` nor keeps the base when handed an absolute path, so a value arriving verbatim from server JSON decided where a file landed. The tag's existing rule (non-alphanumerics become `_`) now applies to all three parts, and the resolved path is checked with `starts_with(cache_dir)` at the point of use. The database keeps the raw key and the resolved path, so lookups still match and pre-existing rows still resolve. Not exploitable as shipped — server URLs must be HTTPS and Android blocks cleartext, so the id comes from a server the user chose to trust — the value is making the write path consistent with how caller-supplied paths are handled elsewhere | Storage | UR-012 | Done |
| DR-211 | Download paths are confined to the download root. `file_path` and `target_dir` reached `PathBuf::join` unchecked from the frontend, and `mark_download_completed` persisted a caller-supplied path later passed to `remove_file`. A correct sanitiser already existed and `download_item_and_start` used it, but `download_item` is itself a command accepting `file_path` raw, so the guard was bypassable rather than absent — the fix moves it inside instead of adding a second one. Sanitising is **per path component**: whole-string sanitising would rewrite `downloads/x.mp3` to `downloads_x.mp3` and relocate every existing download. Confinement happens after the join, since a join with an absolute second half discards the root | Downloads | UR-011 | Done |
| DR-212 | Query and URL construction bind or encode their inputs. Three sites interpolated caller-supplied values directly: the offline `get_items` item-type filter built `IN ('a','b')` by string formatting, `build_get_items_endpoint` wrote `ParentId`/`IncludeItemTypes`/`SortBy`/`SortOrder` into a URL unencoded, and `player_set_volume` accepted NaN and out-of-range floats. Each is a *consistency* defect rather than a novel one — the same file already did it correctly a few lines away (parameter placeholders in `search`, `urlencoding::encode` for genres, `clamp` in every player backend). List separators stay unencoded and encoding is per element, because Jellyfin splits these parameters on the comma | Repository | UR-007, UR-065 | Done |
| DR-213 | Containerised builds hand their artifacts back to the host user. The compose services bind-mount the repo and run as root — their caches live at `/root/.cargo` and `/root/.bun`, so a non-root container user cannot write them — which leaves root-owned files accumulating in the developer's working tree: 11,124 of them when this was found, enough that `cargo clean` and `scripts/clean.sh` failed with EACCES and a plain `cargo build` died part-way, since build scripts compile for the host and land in `target/debug` even during a cross-build. Ownership is restored at the end of each containerised build, reading the intended owner from the checkout so no uid needs plumbing through. Running the containers as the host uid is the tidier fix and remains open; it needs the cache volumes relocated off `/root` first | Tooling | - | Done |
| DR-214 | The app identifies itself correctly everywhere a user or a package manager reads its name. `productName` was the scaffold's lowercase `jellytau`, which is what the Android release build showed under its icon and what the deb/rpm/NSIS bundles carried as their display name — invisible in development because `build.gradle.kts` overrides the label to "JellyTau Debug" for the debug build type, so the install a developer looks at daily was the only correctly-cased one. `mainBinaryName` pins the executable filename so nothing that resolves a path by name has to change. `strings.xml` moves into the canonical android tree, where `sync-android-sources.sh` already copies `res/values/*.xml`, so the fix survives regenerating `gen/`. Bundle metadata (publisher, copyright, category, descriptions, licence) was entirely absent, which is why the packages shipped with no maintainer or description — the hand-written Arch PKGBUILD and `.desktop` had all of it, so only the *generated* packaging was wrong | Packaging | - | Done |
| DR-215 | Frontend test coverage is a ratcheted CI gate rather than a number nobody looks at. `test:coverage` had been configured since the suite was created and was silently broken: `@vitest/coverage-v8` resolved to 4.1.10, whose peer range pins `vitest` exactly, while `package.json` asked for `>=1.0.0 <5.0.0` and got 4.0.16 — so every invocation died on a missing `BaseCoverageProvider` export and no coverage figure had been produced in months. Fixing the range is half the requirement; the other half is that a measured figure that gates nothing decays the same way an unrun script does. Thresholds sit a few points under the measured result (statements 54.6, branches 48.7, functions 49.6, lines 55.1 when this landed) and only ever move up, matching `MIN_THRESHOLD` in the traceability gate and the eslint `--max-warnings` ratchet. The absolute numbers are held down by `.svelte` components, which this project deliberately does not test directly — the pattern is to extract the logic to a plain module and test that | Tooling | - | Done |
| DR-216 | Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — `bytes`, four in `rustls-webpki`, `time`, two in `quick-xml`, `rand` — every one closed by a `cargo update` nobody had reason to run. `cargo deny` (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (`libmpv`) is pinned by revision instead of by branch, since a branch means any `cargo update` silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — `libmpv`/`libmpv-sys` are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so) | Tooling | - | Done |
| DR-217 | In-app update, desktop only, over a manifest we control. `tauri-plugin-updater` and `tauri-plugin-process` are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than `cfg(desktop)`, which Cargo does not evaluate in a `[target.'cfg(…)']` table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes `latest.json` to a dedicated `updater` branch, read over Gitea's raw-file URL: this instance serves `/releases/download/<tag>/<asset>` but returns 404 for `/releases/latest/download/<asset>`, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain `appimage`, which the release notes had been advertising for months while `tauri.conf.json` never built it — the artifact step globbed for `*.AppImage`, found nothing, and said nothing | Tooling | UR-077 | Done |
| DR-218 | Persistent, redacted logging and a diagnostics export. `tauri-plugin-log` replaces the `env_logger` stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where `env_logger`'s stdout went nowhere. **Redaction runs in the log formatter, not at export**: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. `api_key`/`X-Emby-Token`/`Authorization`/`"AccessToken"`/`Token="…"` all reduce to `[REDACTED]` while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded `user:pass@`. The panic hook chains to the previous hook rather than replacing it, because `utils/lock.rs` installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched `console.*` pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the fact | Tooling | UR-078 | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---
@@ -489,6 +495,8 @@ Internal architecture, components, and application logic.
| UR-074 | - | DR-162, DR-177, DR-181 |
| UR-075 | - | DR-174, DR-175 |
| UR-076 | - | DR-209 |
| UR-077 | - | DR-217 |
| UR-078 | - | DR-218 |
---
@@ -699,6 +707,8 @@ Internal architecture, components, and application logic.
| UT-206 | The offline item-type filter is bound rather than interpolated (a value containing a quote and `OR 1=1` matches nothing instead of disabling the `WHERE`), `build_get_items_endpoint` percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite value | DR-212 | Done |
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
| UT-208 | The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading `v` is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefined | DR-217 | Done |
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
### Integration Tests
@@ -812,18 +822,18 @@ Linux-specific `secret-tool` save/get/delete paths.
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
**Symptoms**:
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
**Symptoms** (as first recorded; the audio half is now closed — see Status):
- Audio settings (gapless playback, volume normalization, equalizer) worked on Linux but not on Android
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
**Root Cause**:
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. `MpvBackend` overrode these with MPV property commands; `ExoPlayerBackend` took the silent defaults, so the Settings Audio panel rendered controls that did nothing on Android. `ExoPlayerBackend` now overrides them too, but the trait default is still a silent `Ok(())` — a backend that omits the method still reports success rather than failing loudly.
**Affected Files**:
- [src-tauri/src/player/backend.rs](../src-tauri/src/player/backend.rs) - Trait with default empty implementations
- [src-tauri/src/player/backend.rs](../src-tauri/src/player/backend.rs) - Trait; defaults still return `Ok(())` silently
- [src-tauri/src/player/mpv_backend.rs](../src-tauri/src/player/mpv_backend.rs) - Full audio settings support
- [src-tauri/src/player/android/mod.rs](../src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
- [src-tauri/src/player/android/mod.rs](../src-tauri/src/player/android/mod.rs) - Audio settings carried to Kotlin as JSON over JNI
**Feature Parity Matrix**:
@@ -838,7 +848,7 @@ The `PlayerBackend` trait defines optional audio settings methods with default e
| Equalizer (10-band) | ✅ | ⚠️ | Implemented (resampled onto device bands), pending on-device verification |
| Position updates | 250ms | On-demand | Inconsistent |
**Status** (see docs/specs/android-audio-settings-parity.md):
**Status** (see docs/architecture/05-platform-backends.md, "Audio settings on ExoPlayer"):
1. ✅ `set_audio_settings()` implemented in `ExoPlayerBackend` (JSON over JNI)
2. ✅ Gapless via ExoPlayer's `pauseAtEndOfMediaItems`
3. ✅ Volume normalization via `LoudnessEnhancer`
+79
View File
@@ -0,0 +1,79 @@
# Specs index
Feature specs for JellyTau. Start a new one from
[SPEC-TEMPLATE.md](SPEC-TEMPLATE.md) and run it past
[SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) before accepting it.
## What lives here
**Only work that has not shipped.** Once a spec is fully implemented its design
is folded into the architecture docs — which are the maintained description of
the build — and the spec file is deleted. Git history keeps the original,
including its rejected alternatives and acceptance criteria; the architecture
docs keep the reasoning that a future change still needs.
So: a file in this directory is a **promise, not a description**. If you want to
know how something *works*, read
[docs/architecture/](../architecture/README.md). If you want to know what is
*planned*, read here.
**Status vocabulary**
| Status | Meaning |
|---|---|
| Proposed | Written, not accepted. Nothing built. |
| Accepted | Agreed as the design; implementation not started or not finished. |
| Partially implemented | Some parts shipped; the spec names what is left. |
| Design authority | No code of its own — it records a decision later specs act on. |
**Next free requirement ids** (always re-check
[requirements.md](../requirements.md) before allocating): **UR-077**,
**IR-033**, **DR-215**. Three specs below suggested ids that have since been
taken by other work; each carries a ⚠️ note at the top.
## Partially implemented
| Spec | What landed | What is left |
|---|---|---|
| [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag``imageId` (~30 sites); player/session/reporting tick math; `stream.type` |
| [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv``libmpv2` crate swap |
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-121/122/124/125 — the player quality selector and the read-through capture |
| [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) |
## Not started
| Spec | Blocked on / note |
|---|---|
| [build-provenance.md](build-provenance.md) | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken. |
| [player-facade-enforcement.md](player-facade-enforcement.md) | ~60 `commands.player*` sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken. |
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
## Design authority
| Spec | Role |
|---|---|
| [playback-backend-unification.md](playback-backend-unification.md) | Why video cannot unify onto one native engine and audio can. The audio half has since shipped on Android; Windows has not. |
| [scoped-search-boundary.md](scoped-search-boundary.md) | The boundary design the `check:boundary` rule came from. Stage 1 built. |
| [scoped-search.md](scoped-search.md) | Superseded in part — its "frontend only, no Rust changes" decision is the leak the boundary spec reversed. UX still current. |
## Where the shipped specs went
Sixteen specs were folded into the architecture docs and deleted (2026-08-21).
Where to look for each:
| Shipped work | Now documented in |
|---|---|
| Account menu & global chrome | [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — App Shell and Chrome |
| Library mosaic | [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — Library Mosaic |
| Series current-episode navigation | [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — Series and Episode Navigation |
| Downloads as an offline library | [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — Downloaded Browse |
| Favourites browsing | [01-rust-backend.md](../architecture/01-rust-backend.md) — Favorites System |
| Streaming bitrate cap | [01-rust-backend.md](../architecture/01-rust-backend.md) — Streaming quality ladder |
| Locally-indexed search | [03-data-flow.md](../architecture/03-data-flow.md) — Search Flow; [01-rust-backend.md](../architecture/01-rust-backend.md) — Background workers |
| Offline downloaded-only filter | [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — Offline Catalog Visibility |
| Audio equalizer · Android audio settings parity | [05-platform-backends.md](../architecture/05-platform-backends.md) — Audio settings on ExoPlayer |
| Android native video spike | [05-platform-backends.md](../architecture/05-platform-backends.md) — Native Video Compositing |
| Video background audio | [05-platform-backends.md](../architecture/05-platform-backends.md) — Background Audio Handoff |
| Traceability gate repair | [traceability-ci.md](../traceability-ci.md) |
| Boundary tripwire hardening | `scripts/check-frontend-boundary.sh` (its header is the spec) |
| Playback docs corrections · req-coverage script removal | Nothing to document — both were corrections that have been applied |
+12
View File
@@ -53,6 +53,18 @@ Copy the boxes into the review comment (or the PR) and tick them.
- [ ] Traceability coverage stays ≥ 88% (the CI gate — a ratchet, so check
`bun run traces:coverage` rather than trusting this number).
## Lifecycle
- [ ] **"Destination on completion" names a real architecture doc and section.**
This spec file is deleted when it ships; something has to absorb the
design. If nothing fits, the layer assignment is probably unclear — go back
to that table.
- [ ] The spec separates the **durable half** (invariants, rejected alternatives,
the defect a decision exists to prevent) from the **disposable half**
(phases, migration steps, acceptance criteria). Only the first is folded in.
- [ ] Anything listed as out of scope but still worth doing is written where it
will be found after this file is gone — beside the code it concerns.
## Conflicts & hygiene
- [ ] If this spec revises/supersedes another, the older spec gets a banner
+16 -1
View File
@@ -6,12 +6,27 @@
"Layer assignment" — read its comment before writing it.
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
LIFECYCLE: this file is temporary. docs/specs/ holds only unshipped work — when
the last acceptance criterion is met, the design is folded into
docs/architecture/ and this file is deleted in the same commit. Write it
knowing that: the durable half is the reasoning (invariants, rejected
alternatives, the defect a decision prevents), and the disposable half is the
plan (phases, migration steps, acceptance criteria).
-->
**Status:** Proposed <!-- Proposed | Accepted | Implemented | Superseded -->
**Status:** Proposed <!-- Proposed | Accepted | Partially implemented | Superseded.
NOT "Implemented" — a fully shipped spec is folded into docs/architecture/
and deleted. See "Destination on completion" below. -->
**Requirements:** <!-- UR-xxx → DR-yyy; allocate new DRs in requirements.md. -->
**UX spec:** <!-- link to the relevant ux-flows.md section, or "n/a". -->
**Supersedes / revises:** <!-- link any spec this changes, or delete this line. -->
**Destination on completion:** <!--
Which architecture doc absorbs this design when it ships, and roughly which
section. e.g. "05-platform-backends.md — a new section beside
ExoPlayerBackend". Name it NOW: a feature that fits no existing doc usually
has an unclear layer assignment, which is worth finding out at spec time.
This spec file is deleted in the same commit that folds it in. -->
## Summary
-164
View File
@@ -1,164 +0,0 @@
# Spec: Account menu and global chrome availability
**Status:** Implemented
**Scope:** Frontend only. No Rust changes required.
**Requirements:** UR-054 → DR-075, DR-076, DR-077 (see
[requirements.md](../requirements.md)).
**UX spec:** [ux-flows.md §1.21.4](../ux-flows.md).
## Summary
Account actions — Settings, Downloads, Display preferences, Sign out — are
currently reachable **only from `/library/*`**. Move them into a single shared
account menu anchored to the user's name, and make that menu available on every
authenticated non-immersive screen.
## Motivation
A user sitting on the home screen cannot open Settings or sign out. The bottom
nav offers Home / Search / Library only, and the header that hosts those actions
belongs to the library layout. The user has to guess that account actions live
*inside* Library — an unrelated section — and navigate there first.
Desktop and mobile also disagree today: desktop shows an unlabeled logout icon
with no grouped menu, mobile shows a three-dot overflow with labelled items. The
same two actions are found two different ways.
## Background: verified current state
1. **The header is not global.** It is defined in
[library/+layout.svelte](../../src/routes/library/+layout.svelte). The root
layout [+layout.svelte](../../src/routes/+layout.svelte) renders no header at
all.
2. **`routeOwnsLayout`** in
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) returns true for
`/library`, `/player/`, `/login` — those routes own their own full-height
flex column. Everything else renders into the root scroller with the root's
`BottomUi` below it.
3. **Bottom nav is Home / Search / Library only**
([BottomNav.svelte](../../src/lib/components/BottomNav.svelte)) — no Settings
or account entry.
4. **Net effect:** on `/`, `/search`, and `/downloads` there is no route to
Settings or Sign out.
5. **Desktop username is inert text** — a `<span>` next to the icons, not a
trigger.
6. **The mobile overflow menu already has the right contents** (Downloads,
Settings, divider, Sign out) and the right dismissal behaviour (backdrop
click, keyboard handler). **Extract and reuse it rather than rewriting it.**
7. **`viewMode` is already a persisted store** in
[library.ts](../../src/lib/stores/library.ts) (`jellytau-view-mode`,
`localStorage`). The Display setting is a second view onto it — **no new
state, no migration.**
## Design
### `AccountMenu` component (DR-075)
One component used by both breakpoints. Contents in fixed order:
```
Signed in as <name> ← identity block, not interactive
<server host>
────────────────────────
Downloads
Settings
Display ← grid/list preference
────────────────────────
Sign out ← destructive, last, after a divider
```
- **Trigger is the username/avatar**, not a bare three-dot icon. On mobile where
horizontal space is tight, the avatar (or initial) alone is acceptable; the
name shows inside the open menu regardless.
- **Same items, same order, both platforms.**
- Preserve the existing dismissal behaviour: click-outside backdrop, `Escape`,
and focus return to the trigger on close.
- Menu items are real links/buttons — keyboard reachable, correct roles,
`aria-expanded` on the trigger.
"Display" may either navigate to the Settings Display section or expose the
grid/list choice inline. Prefer navigating — it keeps one source of truth for
preferences and avoids a nested control inside a dropdown.
### Global chrome (DR-076)
Make the header — and therefore the account menu — available on `/`, `/search`,
and `/downloads`.
The cleanest route is to lift the header out of the library layout into a shared
component rendered by the root layout, with the library layout consuming the
same component rather than defining its own. **Do not duplicate the markup into
each route.**
Constraints that must survive the change:
- `/player/*` and `/login` stay chrome-free.
- `/settings` already owns its layout; it needs no account menu (the user is
already there), but must not double up on chrome.
- The root layout's flex/scroller structure is deliberate — the comments in
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) and
[+layout.svelte](../../src/routes/+layout.svelte) explain why routes own their
own column. Preserve the scroll containment; a regression here reintroduces
the "last row hidden behind the nav" bug called out in those comments.
- Mini-player and bottom-nav visibility rules (`showGlobalMiniPlayer`,
`showBottomNav`) must be unchanged.
### Display section in Settings (DR-077)
Add a Display section to [settings/+page.svelte](../../src/routes/settings/+page.svelte)
with the grid/list control bound to the existing `viewMode` store via
`library.setViewMode(...)`. The page-header toggle in `LibraryGrid` stays — both
controls drive the same store, so they stay in sync for free.
## Out of scope
- Redesigning the Settings page or reorganising its existing sections.
- Multi-server / account switching (UR-047) — the identity block displays the
active server but offers no switcher.
- Changing the bottom nav's three destinations.
## Acceptance criteria
- [ ] Settings and Sign out are reachable from `/`, `/search`, and `/downloads`
without first navigating into Library.
- [ ] Desktop and mobile show the same account menu items in the same order.
- [ ] The username/avatar opens the menu; it is a real button with
`aria-expanded`.
- [ ] Sign out is last, after a divider, and still logs out + resets library
state + redirects as it does today.
- [ ] `/player/*` and `/login` remain chrome-free.
- [ ] Settings has a Display section that changes grid/list, and the change is
immediately reflected by the library page-header toggle (same store).
- [ ] No regression in scroll containment, mini-player visibility, or bottom-nav
visibility on any route.
- [ ] `bun run check` and `bun run test` pass.
## Testing
- Extend the existing `layoutShell` tests: chrome-visibility for `/`, `/search`,
`/downloads` (now true) and `/player/*`, `/login` (still false).
- `AccountMenu`: renders the documented items in order; trigger toggles
`aria-expanded`; `Escape` and backdrop click close it; Sign out invokes the
logout handler.
- Display setting: writes through to the `viewMode` store and persists.
New requirement-implementing code needs `TRACES:` comments — see
[CLAUDE.md](../../CLAUDE.md). Suggested: `AccountMenu``UR-054 | DR-075`,
shell/header changes → `UR-054 | DR-076`, Settings Display section →
`UR-054, UR-029 | DR-077`.
## Notes for the implementer
- Read [ux-flows.md §1.21.4](../ux-flows.md) first — behavioural spec; this is
the implementation plan.
- The layout shell is subtle and the existing comments record real bugs that
were fixed there. Read them before restructuring.
- Another session may be active in this repo, including in
`src/routes/settings/+page.svelte`. Check `git diff` before "repairing"
unexpected changes, and expect to coordinate on that file.
-209
View File
@@ -1,209 +0,0 @@
# Spec: Android audio settings parity (EQ, normalization, gapless)
**Status:** Proposed
**Requirements:** UR-031, UR-032, UR-033, UR-027 → DR-034, DR-035, DR-036, DR-030; IR-004
**UX spec:** n/a — no UI change; Settings Audio already renders these controls
**Supersedes / revises:** closes the audio half of the parity gap recorded in [playback-backend-unification.md](playback-backend-unification.md)
## Summary
Implement `set_audio_settings` / `audio_settings` on `ExoPlayerBackend` so the
equalizer, volume normalization, and gapless playback settings actually take
effect on Android. Today the Settings Audio panel renders these controls on
Android and they silently do nothing — `ExoPlayerBackend` is the only backend
that does not override the trait's no-op defaults.
Crossfade is explicitly **not** included; see Out of scope.
## Motivation
`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body.
`MpvBackend`, `NullBackend`, and `WebviewAudioBackend` all override it;
`ExoPlayerBackend` does not. The settings are persisted, pushed to the backend on
every track load, and displayed in the UI — and then dropped on the floor.
This is the single most user-visible platform divergence in the app: a user who
sets a "Rock" EQ preset on Android sees the sliders move and hears no change.
The backend-unification investigation ruled out fixing this by swapping engines
(video cannot be unified; see the sibling spec), so the fix is to implement the
trait methods where they are missing.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Band count, centre frequencies, gain range, preset→curve map | Rust (existing) | Already domain-owned in `settings.rs` per [audio-equalizer.md](audio-equalizer.md). Android must consume the same `AudioSettings`, not define its own bands. Duplicating the band layout in Kotlin would be a taxonomy leak of exactly the kind `check:boundary` guards against. |
| Mapping `AudioSettings` → Android audio-effect parameters | Rust → JNI boundary | Platform playback detail, the direct analogue of `build_af_filter` in `mpv_backend.rs`. Belongs with the other `set_audio_settings` code. |
| Attaching/detaching `Equalizer` and `LoudnessEnhancer` to the ExoPlayer audio session | Kotlin (`JellyTauPlayer.kt`) | Android platform API mechanics; needs the live `audioSessionId`, which only the Kotlin layer holds. |
| Normalization preset (Loud/Normal/Quiet) → target gain | Rust (existing) | `VolumeLevel` is domain vocabulary; the same preset must mean the same loudness on every platform. |
| Rendering sliders / preset chips | Frontend (existing) | Pure presentation; unchanged by this spec. |
Borderline row: attaching the effects could arguably be driven entirely from
Rust via JNI property calls. It goes to Kotlin because `AudioEffect` construction
requires the audio session id and must be re-attached when ExoPlayer rebuilds its
audio sink — lifecycle state that lives in `JellyTauPlayer.kt`. Rust still owns
*what* the values are; Kotlin owns *when* the effect objects exist.
## Design
### Rust — `ExoPlayerBackend` (`src-tauri/src/player/android/mod.rs`)
Override the two defaulted methods, mirroring the shape of the existing
`set_audio_track` JNI call:
```rust
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
let s = settings.clone().with_crossfade_clamped().with_equalizer_normalised();
// Serialize as JSON — the same pattern load() already uses for subtitles,
// avoiding a 6-arg JNI signature that has to change every time a field lands.
let json = serde_json::to_string(&s).map_err(|e| PlayerError { message: e.to_string() })?;
// Kotlin: fun setAudioSettings(json: String)
self.call_player_method_string("setAudioSettings", &json)?;
self.shared_state.lock_safe().audio_settings = s;
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
self.shared_state.lock_safe().audio_settings.clone()
}
```
`ExoPlayerState` gains an `audio_settings: AudioSettings` field. Note
`ExoPlayerBackend` currently holds no such state — `position`/`state`/`volume` are
all pushed in by JNI callbacks — so this is the first *pull*-side field. That is
correct: audio settings are commanded downward, never reported upward.
### Kotlin — `JellyTauPlayer.kt`
```kotlin
fun setAudioSettings(json: String) {
val s = JSONObject(json)
applyEqualizer(s.getBoolean("equalizerEnabled"), s.getJSONArray("equalizerBands"))
applyNormalization(s.getBoolean("normalizeVolume"), s.getString("volumeLevel"))
exoPlayer.pauseAtEndOfMediaItems = !s.getBoolean("gaplessPlayback")
}
```
Three independent mechanisms:
- **Gapless** — nearly free. ExoPlayer is gapless by default for compatible
formats; honouring the setting means *disabling* it when the user turns it off,
via `pauseAtEndOfMediaItems`. Note this only applies within a loaded playlist;
our queue loads one item at a time, so verify behaviour before claiming DR-035
on Android (see Testing).
- **Equalizer**`android.media.audiofx.Equalizer` bound to
`exoPlayer.audioSessionId`. Android's EQ exposes a device-dependent band count
(commonly 5) at fixed centre frequencies, which will **not** match our 10-band
ISO layout. Rust owns the canonical 10 bands; Kotlin resamples them onto the
device's bands by nearest-centre-frequency interpolation. Gains are in
millibels (`setBandLevel` takes mB, we store dB → ×100), clamped to the
device's reported `getBandLevelRange()`.
- **Normalization**`android.media.audiofx.LoudnessEnhancer`, also bound to the
audio session, `setTargetGain(mB)` derived from `VolumeLevel`. This is a gain
booster, not a true EBU R128 normalizer like MPV's `dynaudnorm`; parity is
approximate and should be documented as such rather than overclaimed.
Lifecycle: build the effects lazily on first use, release them in `release()`,
and re-attach on `onAudioSessionIdChanged` — ExoPlayer can rebuild its audio sink
(e.g. on a format change), which invalidates effects bound to the old session.
### Make the silent-failure mode impossible
The trait's default is the root cause of this whole class of bug:
```rust
// backend.rs:85 — reports success while doing nothing
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
Ok(())
}
```
Android inherits this, so every EQ/normalization change on Android returns `Ok`
and silently does nothing — the UI ships and has no effect, with no error anywhere.
Once `ExoPlayerBackend` implements the methods, **change the trait default to
`Err(PlayerError::not_implemented())`**, matching how `set_audio_track` /
`set_subtitle_track` already behave. Any future backend that forgets to implement
audio settings then fails loudly instead of lying.
Check the call sites before flipping it: `NullBackend` overrides both methods, so
the graceful-degradation path is unaffected, but confirm nothing treats a
`set_audio_settings` error as fatal to playback.
### Re-application on track load
`PlayerController` already re-pushes `AudioSettings` per track on the platforms
that implement it; the Android path inherits that for free once the trait methods
exist. No controller change.
### 🔴 Threading note
`setAudioSettings` is invoked from Rust on whatever thread the command lands on.
`AudioEffect` construction must not happen on the ExoPlayer application thread
from inside a player callback — that is the re-entrancy hazard CLAUDE.md warns
about, and the same shape as the `AutoplayDecision` deadlock. Post the work to
the player's handler rather than doing it inline in a listener.
## Out of scope
- **Crossfade (UR-031 / DR-034).** Not implemented on *any* platform today, and
architecturally blocked on MPV (single-stream audio chain; `acrossfade` needs
two inputs). Implementing it on Android alone would invert the parity gap. It
needs its own spec and probably two player instances.
- True EBU R128 normalization. `LoudnessEnhancer` is a gain stage; matching
`dynaudnorm` exactly is out of reach without a custom `AudioProcessor`.
- Windows audio settings — see [windows-native-audio-backend.md](windows-native-audio-backend.md).
## Acceptance criteria
- [ ] `ExoPlayerBackend` overrides `set_audio_settings` and `audio_settings`.
- [ ] EQ preset change on Android audibly changes playback; setting persists across track changes and app restart.
- [ ] Normalization toggle audibly changes level; the three presets are ordered Loud > Normal > Quiet.
- [ ] Disabling gapless produces a gap between consecutive tracks; enabling it does not.
- [ ] Effects are released on `release()` and survive an audio-session rebuild.
- [ ] `requirements.md` parity matrix updated: EQ and normalization ✅ Android.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes.
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated if Rust types changed.
## Testing
**Rust** (`cargo test`): `set_audio_settings` stores the sanitized settings and
`audio_settings()` returns them — assert clamping/normalisation is applied
(crossfade clamped to 12s, band vector normalised to `EQ_BANDS.len()`). The JNI
call itself is not unit-testable; extract the JSON serialization into a pure
function and test that its shape matches what the Kotlin parser expects. That
serialization contract is the part most likely to silently break.
**Kotlin**: the band-resampling function (10 canonical bands → N device bands) is
pure arithmetic — extract it and unit-test it, including the degenerate cases of
a 5-band device and a device reporting 10 bands.
**Manual, on device** (these are the ones that actually prove it):
1. Set Bass Boost, play a track, confirm audible change.
2. Toggle normalization mid-track; confirm level change without a playback stall.
3. Queue two gapless-encoded tracks, toggle the setting, confirm the gap appears/disappears.
4. Force a format change (44.1kHz → 48kHz track) and confirm the EQ still applies afterwards — this exercises the session-rebuild re-attach.
## TRACES
- `ExoPlayerBackend::set_audio_settings``// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
- Kotlin `setAudioSettings` / `applyEqualizer` / `applyNormalization` → same IDs
- Band-resampling helper + its tests → `DR-030 | UT-xxx`
## Notes for the implementer
- Read [audio-equalizer.md](audio-equalizer.md) first — it defines the canonical
band layout and the preset→curve rule this spec consumes. Do not redefine bands
in Kotlin.
- Android source edits go in `src-tauri/android/src` (canonical tree), then run
`scripts/sync-android-sources.sh`. Never edit the `gen/` tree.
- There is a **stale duplicate** `JellyTauPlayer.kt` (285 lines) at
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/` alongside
the real 1103-line file at `src-tauri/android/src/main/java/...`. Edit the
latter. Consider deleting the former as a separate change.
- A parallel Claude session may be active — `git diff` before "repairing"
unexpected changes.
-306
View File
@@ -1,306 +0,0 @@
# Spec: Android native video — transparent-webview spike
**Status:** Spike succeeded (2026-08-11); shipped behind `experimentalNativeVideo`,
default off. Flipping that default shipped **audio with no picture** and was
reverted (DR-172). Three defects behind that have since been fixed — DR-182
(nothing on the native path could lift the poster overlay), DR-183 (the JS
bridges raced the page load), DR-184 (the SurfaceView was never detached).
Branch `fix/android-native-video-visible`. **The default stays off until the
device criteria below are green.**
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
composited behind a transparent Tauri WebView on Android. Nothing upstream
blocked it and nothing upstream demonstrated it — this is, as far as the issue
trackers show, the first working instance. The remaining flag is about test
coverage and the unverified cases below, not about viability.
**Requirements:** IR-004, UR-003, UR-004, UR-041 → DR-001, DR-004, DR-150, DR-151, DR-152
**Note:** the original draft cited DR-023/DR-024 here. Those are the *subtitle*
and *audio-track selection UI* requirements — unrelated to this work. The IDs
actually implemented are DR-150 (native rendering behind the flag), DR-151 (the
severed SurfaceView attach chain) and DR-152 (capabilities reported by Rust).
**UX spec:** n/a — no intended visual change; the video surface must land exactly where the `<video>` element is today
**Supersedes / revises:** acts on finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
## Summary
Test whether ExoPlayer's existing `SurfaceView` video path can be composited
behind a transparent Tauri WebView on Android. If it works, Android regains
hardware video decoding (MediaCodec) and libass-quality ASS/SSA subtitles, both
of which the current webview path lacks. If it does not, we document why and
delete the dead code.
This is a **spike**, not a feature commitment. The deliverable is a yes/no answer
with evidence, plus either a working path behind a flag or a removal.
## Motivation
`createAdapter()` hardcodes `const effectiveKind = "html5"` and does
`void backendKind`, discarding the `use_html5_element` value Rust computes in
`get_player_status`. As a result:
- `NativePlayerAdapter` is dead code.
- `JellyTauPlayer.kt`'s `getOrCreateSurfaceView()` — which already calls
`setZOrderMediaOverlay(false)` and wires `setVideoSurfaceHolder` — is
unreachable.
- Android video decodes in the WebView instead of via MediaCodec, despite
`CodecDetector.kt` going to the trouble of reporting hardware codec
capabilities back to Rust for DeviceProfile generation.
The code comment in `nativeAdapter.ts:11-14` justifies this by citing
tauri#10152 as an upstream blocker. **That justification is stale.**
### Why the blocker no longer holds
- tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature
request* ("Support transparent webviews on mobile"), not a bug report about
compositing.
- The capability shipped in tauri commit `27d01834` (2024-09-02) — a clippy
cleanup that moved `transparent()` out of the desktop-gated impl block, fencing
only the tao call behind `#[cfg(desktop)]`. Because it landed as unrelated
cleanup, nobody closed the issue.
- The black/white-screen reports (tauri#8381, tauri#9408) were a real but
*different* bug: a broken JNI signature for `setBackgroundColor`, fixed in
**wry 0.39.4** (PR #1237). We ship wry 0.55.x.
- Current wry calls `setBackgroundColor(0)` unconditionally on Android when
transparency is requested.
### The honest caveat
**Nobody has demonstrated SurfaceView-behind-WebView on Tauri Android.** A search
of both `tauri-apps/tauri` and `tauri-apps/wry` issues for `surfaceview` returns
zero results, and the one native-video Tauri plugin
(`YeonV/tauri-plugin-videoplayer`) sidesteps compositing by launching a separate
fullscreen Activity. Nothing upstream blocks this; nothing upstream proves it.
Hence: spike, not feature.
Note this is the *Android* question only. The equivalent Linux compositing
problem is maintainer-declared unfixable and is **not** in scope — see the
unification spec.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which video backend this platform uses | Rust (existing) | `get_player_status` already computes `use_html5_element`. The frontend must *consume* it, not decide it. Restoring that is the point of the spike. |
| Surface creation, z-ordering, `setVideoSurfaceHolder` lifecycle | Kotlin | Android platform mechanics; already written in `JellyTauPlayer.kt`. |
| Seek/audio-track *strategy* | Rust (existing) | Already returned by `player_seek_video` / `player_switch_audio_track`; `NativePlayerAdapter` executes the chosen primitive. Unchanged — this is exactly what the `PlayerAdapter` contract was built for. |
| Positioning the surface under the video viewport | Frontend | Pure presentation/layout. **This is the risk area** — see Design. |
## Design
### Phase 1 — prove compositing (no app changes)
Before touching the adapter factory, verify the primitive works at all:
1. Set `"transparent": true` in `tauri.conf.json` for the Android build, plus
`html, body { background: transparent; }`.
2. Confirm the WebView is genuinely transparent (a native view behind it is
visible) and that the app does not regress to a black/white screen.
If this fails, stop — everything downstream is moot, and the finding is that
Tauri Android transparency is still broken in practice despite the shipped fix.
### Phase 2 — un-hardcode the factory
```ts
// src/lib/player/adapters/index.ts
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
return backendKind === "native"
? new NativePlayerAdapter(host)
: new Html5PlayerAdapter(host, bridge);
}
```
`backendKind` comes from `get_player_status` (`VideoBackend::Native` on Android).
Gate behind a setting — `experimentalNativeVideo`, default **off** — so a broken
spike cannot ship as a regression. Rust already owns this decision; the flag only
suppresses it.
**Also in scope: remove the user-agent sniffing in
`src/lib/services/webviewAudio.ts:30-41`.** It re-derives which audio backend the
platform has from `navigator.userAgent` ("matching the Rust cfg gate", per its own
comment) — the frontend deciding a backend fact it should be told. Same root cause
as the hardcode above, same fix: consume the value Rust already computes. Fold it
in here rather than leaving a second, subtler copy of the bug behind. If
`get_player_status` does not currently expose enough to cover the audio case, add
the field — that is backend work, and correct.
### Implementation findings (2026-08-11)
Two blockers existed that this spec did not anticipate. Both were in code the
spec assumed was merely *unreachable*; it was also *broken*.
**1. The Kotlin attach chain was severed.** `JellyTauPlayer.setActivity()` had
**zero callers** anywhere in the tree. `currentActivity` was therefore always
null, so `autoAttachSurface()` logged "Cannot attach surface - no Activity
reference" and returned. The `SurfaceView` was created and wired to ExoPlayer but
never added to the view hierarchy — video would have decoded to a surface that
was never on screen, *regardless* of webview transparency. Fixed by calling
`JellyTauPlayer.setActivity(this)` from `MainActivity.onCreate`.
Note the knock-on: `PictureInPictureManager.canEnterPip()` gates on
`VideoOverlayManager.isVideoSurfaceAttached()`, which was permanently false. PiP
on the video path was dead for the same reason.
**2. `createAdapter()` was not the real gate.** It is never called by production
code — `VideoPlayer.svelte` constructs `Html5PlayerAdapter` directly. The actual
override was `VideoPlayer.svelte`'s INTERIM block, which read Rust's
`useHtml5Element`, forced it to `true`, and called `playerStop()` to kill the
native backend `player_play_item` had just started. Both sites are now fixed;
`VideoPlayer.svelte` routes through `createAdapter()` so there is one gate.
**Transparency needs two independent layers cleared,** not one. The spec's
Phase 1 named only `html, body`. Clearing just the page leaves the WebView
widget's own background opaque, which is a black screen with audio — the exact
symptom the INTERIM comment described as "native surface not visible". Both are
now toggled together by `$lib/utils/videoSurface.ts`:
| Layer | Cleared by | Reachable from |
|-------|-----------|----------------|
| WebView widget background + window drawable | `AndroidVideoSurface.setTransparent()` (MainActivity) | Kotlin only |
| `html`/`body` + app-shell `--color-background` | `data-native-video` attribute → app.css | CSS only |
Transparency is scoped to `tauri.android.conf.json` rather than the base config:
a transparent window on Linux is a regression, since nothing renders behind it.
It is also toggled per-session rather than set once — a permanently transparent
window shows the launcher through the rest of the app.
### Phase 3 — surface positioning
The hard part, and where this most likely fails. The webview's `<video>` element
occupies a laid-out box; the `SurfaceView` must be positioned to match it, and
kept matched through scroll, rotation, and mini-player transitions.
Approach: the video view reports its `getBoundingClientRect()` to Rust, which
forwards the rect to Kotlin to position the `SurfaceView`. This is the same
"faking it" technique the ecosystem uses on desktop — acceptable here *only if*
the video is effectively fullscreen on Android, which it is in the player route.
**Explicit failure criterion**: if the surface cannot be kept aligned during
rotation or the mini-player transition without visible artefacts, the spike fails
and we keep HTML5. Do not ship a janky native path for a codec win.
**Update: no rect plumbing was needed.** The premise — that the surface must be
positioned to match a laid-out `<video>` box — does not hold on the player route,
where video is fullscreen. `VideoOverlayManager` adds the SurfaceView at index 0
of `android.R.id.content` with `MATCH_PARENT`, and `fitSurfaceToScreen()`
(`JellyTauPlayer.kt`) already letterboxes/pillarboxes to the real video aspect
ratio and re-centres via a `Gravity.CENTER` `FrameLayout.LayoutParams`. Rotation
is handled by an `OnLayoutChangeListener` that re-fits on any bounds change. The
frontend's native branch is a bare `flex-1` box, so there is no rect to report
and nothing to keep in sync.
Fullscreen playback is confirmed working on device. But this reasoning rests
entirely on the fullscreen assumption, so **the mini-player transition is the
known gap** — it is the one case where the surface is *not* fullscreen, and
therefore the one case where the "no rect plumbing needed" conclusion could
still turn out to be wrong. If artefacts appear there, the fix is the rect
reporting this section originally proposed, scoped to that transition alone.
### A trap for the next implementer
There is a **stale duplicate player** at
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt`
(only commit: `cfddc1e` "First working POC"). No `sourceSets` entry points at it,
so it is not compiled — but edits made there silently do nothing. The canonical
tree is `src-tauri/android/src`, synced into `gen/` by
`scripts/sync-android-sources.sh`.
### What we gain if it works
- **Hardware decode via MediaCodec**`CodecDetector.kt` already reports
capabilities; the DeviceProfile would finally match what actually plays.
- **ASS/SSA subtitles** are *not* automatic. ExoPlayer cannot render them; that
would require libmpv, which is a separate and much larger decision (see the
unification spec's engine comparison). Scope this spike to hardware decode
only, and do not claim subtitle improvements from it.
## Out of scope
- Linux native video. Maintainer-declared unfixable on WebKitGTK/Wayland.
- Replacing ExoPlayer with libmpv on Android.
- Windows native video.
- Removing the HTML5 path. It stays as the default and the fallback.
## Acceptance criteria
The spike is **complete** when one of these is true:
**Success path**
- [x] Transparent WebView confirmed working on a physical device (reported by the maintainer; the config that enables it is now committed in `tauri.android.conf.json`).
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities``usesWebviewAudio`).
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
- [x] The poster/title card comes down on the native path. It never could: every `markMediaReady()` call site is a `<video>` element event and the native branch renders no element, so an opaque `bg-black` overlay covered the ExoPlayer surface for the whole session. See DR-182; guarded by `mediaReady.test.ts` (UT-184) and `VideoPlayer.nativeReveal.test.ts` (UT-185), the latter written failing first.
- [x] The `AndroidVideoSurface` bridge is installed before the page that calls it loads, via `WryActivity.onWebViewCreate` instead of a 500 ms tree walk, and a missing bridge now logs an error instead of no-oping. See DR-183.
- [x] The SurfaceView is detached when video stops, instead of accumulating one leaked view per native video. See DR-184.
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
- [ ] Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
**Failure path**
- [ ] The blocking behaviour is documented in this spec with evidence.
- [ ] `NativePlayerAdapter` and the unreachable `SurfaceView` code are deleted, or explicitly retained with a *correct* comment.
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
Either way:
- [x] `bun run check` (0 errors), `bun run test` (997 passed), `bun run check:boundary` pass.
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
### Why the 2026-08-11 verification and DR-172 do not contradict each other
The spike was reported working on device; the same path then shipped as audio
with no picture. Both are consistent with DR-182: the poster overlay is drawn
only while `isMediaReady` is false, and the native path has no way to set it, so
what the surface shows depends entirely on **whether that overlay is on screen**
— not on whether compositing works. Any run that reached the player through a
path leaving `isMediaReady` already true (a handoff return, a re-render, a
session that had previously played on the HTML5 path) shows video; a cold start
into the native path never does. That is also why DR-172 read the symptom as a
compositing failure: on screen the two are identical, and the one piece of
evidence separating them — `WebView transparent = true` never being logged —
points at DR-183 rather than at the compositing itself.
**This reasoning is not yet device-confirmed.** It explains the reports and is
backed by the code, but the criteria above are what settle it.
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
> no `bun`, so all of the above were run inside the CI builder image
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
> mount needs `:z` for SELinux, and `scripts/build-android.sh` hardcodes
> `ANDROID_HOME="$HOME/Android/Sdk"`, so the image's SDK at `/opt/android-sdk`
> must be symlinked there rather than passed by env var.
## Testing
Adapter-selection logic is pure and testable without a device: assert
`createAdapter` returns `NativePlayerAdapter` for `backendKind: "native"` with
the flag on, and `Html5PlayerAdapter` in every other combination — including that
the flag off forces HTML5 even when Rust says native. That last case is the
regression guard.
Everything else is manual on-device; there is no meaningful way to unit-test
surface compositing. Test on at least two devices — compositing behaviour varies
by OEM and Android version.
Per CLAUDE.md, if the spike turns into a bug fix (e.g. seek breaks under the
native adapter), write the failing test first.
## TRACES
- `createAdapter``// TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149`
- Adapter-selection tests → `UT-xxx`
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
## Notes for the implementer
- **Do not skip Phase 1.** If transparency does not work, phases 2 and 3 are
wasted effort.
- `VideoPlayer.svelte` has a documented hazard: no lifecycle calls after an
`await` in `onMount` — it flips to HTML5 mode and breaks Android seek. The
adapter swap touches exactly this code path.
- tauri-specta tagged responses keep Rust field names (`new_url`, not `newUrl`).
- Android source edits go in `src-tauri/android/src`, then run
`scripts/sync-android-sources.sh`.
- A parallel Claude session may be active — `git diff` first.
-176
View File
@@ -1,176 +0,0 @@
# Spec: Audio equalizer
**Status:** Accepted
**Requirements:** UR-027 → DR-030 (EQ UI), IR-020 (MPV EQ integration).
**UX spec:** n/a (extends the Settings Audio section, ux-flows §8.1 instant-apply).
**Supersedes / revises:** —
**Revised by:** [android-audio-settings-parity.md](android-audio-settings-parity.md) — lifts the "Android is a no-op" limitation below.
## Summary
Add a graphic audio equalizer to playback. Users pick a preset (Flat, Rock,
Pop, Jazz, Classical, Bass Boost, Treble Boost, Vocal) or set custom per-band
gains, from a new block in Settings Audio. On Linux the gains apply live via
MPV's audio-filter chain; the settings persist and re-apply on the next track
and at startup, exactly like crossfade/gapless/normalize do today. Android is a
no-op for now (documented parity gap, same as those three features).
## Motivation
UR-027 is one of the few still-unbuilt audio features. The audio-settings
pipeline it needs already exists — `AudioSettings` + `set_audio_settings` on the
`PlayerBackend` trait, the `player_set_audio_settings` command, and the Settings
Audio UI with instant-apply. Crossfade, gapless, and volume normalization all
ride that pipeline. The equalizer is the same shape: N more fields on
`AudioSettings`, an `af` filter on the MPV backend, one more block in the
settings panel. No new command, no new state machine.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| EQ band count, centre frequencies, gain range/clamping | Rust | Domain of the audio engine; the bands must match what the MPV filter expects. Changing the DSP must not require a frontend change. |
| Preset name → per-band gain curve | Rust | A preset *is* a domain gain curve, not a label. It changes with the audio engine's band layout, never with the UI. Placing it in the frontend would be the scoped-search taxonomy mistake again (values that look like config but are domain data). |
| Translating gains → MPV `af` filter string | Rust | Platform playback detail; lives with the other `set_audio_settings` filter code in `mpv_backend.rs`. |
| Persisting the chosen settings, re-pushing on load | Rust/existing | Same path crossfade/etc. already use; the controller re-applies `AudioSettings` per track. |
| Rendering band sliders, the preset chips, live readouts | Frontend | Pure presentation; changes only if the settings UI is redesigned. |
| Which preset chip is highlighted; instant-apply on change | Frontend | Presentation/input handling (UR-057), the same as the normalize preset picker. |
Tie-breaker note: the preset→curve map is the one tempting boundary leak. It goes
in Rust because a preset is a set of band gains defined *by the band layout*,
which is an engine property. The frontend only ever names a preset and renders
the resulting gains; it never defines them.
## Design
### `AudioSettings` (Rust, `settings.rs`)
Add two fields (both `#[serde(rename_all = "camelCase")]` via the existing
struct attribute):
```rust
/// Equalizer enabled. When false, no `af` EQ filter is applied.
pub equalizer_enabled: bool,
/// Per-band gains in dB, one per FIXED band (see EQ_BANDS). Length is
/// validated/normalised to EQ_BANDS.len(); clamped to [-12, +12] dB.
pub equalizer_bands: Vec<f32>,
```
Fixed 10-band ISO layout (domain constant in `settings.rs`):
```rust
pub const EQ_BANDS: [f32; 10] =
[31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
pub const EQ_GAIN_MIN: f32 = -12.0;
pub const EQ_GAIN_MAX: f32 = 12.0;
```
- `Default`: `equalizer_enabled: false`, `equalizer_bands: vec![0.0; 10]` (flat).
- New `with_equalizer_normalised(self)` clamps each gain to `[EQ_GAIN_MIN,
EQ_GAIN_MAX]` and pads/truncates the vec to 10 bands. Applied in the command
alongside `with_crossfade_clamped` (add that call too — it's currently missing).
- Backward compat: both fields `#[serde(default)]` so old persisted JSON loads.
### Presets (Rust, `settings.rs`)
```rust
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum EqPreset { Flat, Rock, Pop, Jazz, Classical, BassBoost, TrebleBoost, Vocal }
impl EqPreset {
/// The 10-band gain curve (dB) for this preset.
pub fn gains(&self) -> [f32; 10] { /* table */ }
}
```
Preset selection is a *frontend* convenience: tapping a chip sets
`equalizer_bands = preset.gains()` and pushes settings. The curve tables live in
Rust; the frontend reads them via a tiny `player_get_eq_presets` command
returning `Vec<(EqPreset, Vec<f32>)>` (or a map), so the frontend never encodes
the numbers. (If exposing the whole table is awkward through specta, expose
`player_eq_preset_gains(preset) -> Vec<f32>` instead — pick at implement time.)
### MPV application (Rust, `mpv_backend.rs::set_audio_settings`)
Build an `equalizer` / `anequalizer` filter from the bands and set the `af`
property. When `equalizer_enabled` is false or all gains are 0, clear the EQ
filter (leave any other `af` entries intact). Use `af add`/`af remove` or a
rebuilt `af` string; keep it isolated so it doesn't stomp a future crossfade
filter. Errors map to `PlayerError` like the gapless code.
### No new persistence table
`AudioSettings` is already round-tripped by the frontend settings store and
re-pushed via `player_set_audio_settings` on change and on load. The two new
fields ride along. `NullBackend`/Android inherit the trait default (no-op).
### Wire summary
- Command names unchanged: `player_set_audio_settings`,
`player_get_audio_settings` (now carry the EQ fields).
- New (optional) read-only command for preset curves — kebab n/a (it's a
command): `player_get_eq_presets` (or `player_eq_preset_gains`).
- Regenerate `bindings.ts` from the Rust types; never hand-edit.
## Out of scope
- Android/ExoPlayer EQ (parity gap tracked with crossfade/gapless/normalize).
**Now specified in [android-audio-settings-parity.md](android-audio-settings-parity.md)**,
which implements `set_audio_settings` on `ExoPlayerBackend`. The canonical band
layout and preset→curve map defined here remain authoritative; the Android side
resamples those bands onto the device equalizer rather than defining its own.
- Per-track or per-library EQ profiles — one global profile only.
- Automatic loudness/room correction; only manual bands + presets.
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
the missing `with_crossfade_clamped` call.
## Acceptance criteria
- [ ] Settings Audio has an Equalizer block: enable toggle, preset chips, 10
band sliders with live dB readouts, instant-apply (no Save button).
- [ ] Choosing a preset sets the bands from the Rust-defined curve; editing a
band switches the highlighted preset to "Custom" (frontend-only label).
- [ ] Gains clamp to [-12, +12] dB; the band vector always normalises to 10.
- [ ] On Linux, enabling EQ audibly changes output and persists across tracks
and app restart; disabling clears the filter without affecting other audio.
- [ ] Old persisted settings (no EQ fields) load without error, defaulting flat.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes (no preset curve numbers in the frontend).
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated.
## Testing
- Rust (`settings.rs`): default is flat + disabled; `with_equalizer_normalised`
clamps out-of-range gains and pads/truncates band length; serialization
round-trips the camelCase fields; backward-compat load of pre-EQ JSON; each
preset returns a 10-length curve; Flat is all zeros.
- Rust IPC param naming for any new command (camelCase rule per CLAUDE.md).
- Frontend (`settings` page or an extracted helper): selecting a preset sets the
expected band array; editing a band flips the label to Custom; enable toggle
gates the sliders. Keep DSP untested on the frontend (it's Rust's).
## TRACES
- `AudioSettings` EQ fields + normalise + presets: `UR-027 | DR-030` (+ unit tests)
- MPV EQ filter application: `UR-027 | IR-020`
- Settings EQ UI block: `UR-027 | DR-030`
- Preset-curve command: `UR-027 | DR-030`
## Notes for the implementer
- A parallel Claude session is active in this repo (it has touched
`tauri.conf.json`, `Dockerfile`, `package.json`, home components, and added
build scripts, and the Rust build is currently broken by its
`tauri.conf.json` bundle-target change). `git diff` before "repairing"
anything you didn't write; keep EQ changes isolated to `settings.rs`,
`mpv_backend.rs`, `backend.rs` (trait default already covers it),
`commands/player/settings.rs`, and the settings page.
- Mirror the volume-normalization block in the settings page for the toggle +
preset-picker pattern; mirror the gapless code in `set_audio_settings` for the
MPV property handling.
- Confirm the exact MPV filter name available in the linked libmpv
(`equalizer` vs `anequalizer`/`superequalizer`) before committing the filter
string; gate cleanly if unavailable.
-215
View File
@@ -1,215 +0,0 @@
# Spec: Harden the frontend boundary tripwire
**Status:** Implemented
**Requirements:** DR-094
**UX spec:** n/a — developer tooling.
**Supersedes / revises:** revises the detection rule in
[scripts/check-frontend-boundary.sh](../../scripts/check-frontend-boundary.sh);
the boundary *policy* in [scoped-search-boundary.md](scoped-search-boundary.md)
is unchanged.
## Summary
`bun run check:boundary` passes on a tree that contains the exact leak it was
built to catch. It matches a multi-type array only when written **inline at the
query site**, so assigning the same array to a named const evades it entirely —
which is how [searchScope.ts](../../src/lib/utils/searchScope.ts) has kept a
category→item-type mapping through every green CI run. This spec broadens the
match to item-type array literals anywhere in `src/`, and resolves the handful
of legitimate hits that broadening surfaces.
## Motivation
The current pattern is anchored to the `includeItemTypes:` key:
```sh
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
```
The live leak is not written that way:
```ts
// src/lib/utils/searchScope.ts:29 — invisible to the tripwire
const SCOPE_ITEM_TYPES = { music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"], … };
```
The taxonomy and the query are one indirection apart, and the grep only sees the
query. The script's own header is admirably honest that it is "a TRIPWIRE, NOT A
PROOF" — but the gap here is not a subtle judgment call it was designed to
defer to human review. It is the *crudest form* of the violation, one `const`
away from the shape it does match, in the very file the founding incident was
written about.
Broadening the pattern to any item-type array literal finds it, with a
manageable number of other hits (measured, not estimated):
| Site | Verdict |
|------|---------|
| `searchScope.ts:30,32` | 🔴 The leak. Removed by [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md). |
| `PersonDetailView.svelte:30` | Already allowlisted, with a recorded reason. |
| `DownloadedBrowse.svelte:95` | Borderline — `["MusicAlbum","Series","Season","BoxSet"].includes(item.type)` as an "is this a container?" predicate. |
| `GenericMediaListPage.svelte:298` | Borderline — `["MusicAlbum","MusicArtist","Audio","Playlist"].includes(config.itemType)` as a music-styling predicate. |
| 6 hits in `*.test.ts` | Excluded; tests legitimately name types. |
Four non-test sites total. This is a tractable change, not a boil-the-ocean one.
## Layer assignment
Tooling only — no application logic, nothing crosses IPC. The two borderline
*application* sites do get a layer decision, below.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Detecting item-type array literals in `src/` | Build tooling (`scripts/`) | Static analysis of repo source; belongs beside the existing check. |
| "Is this item a container?" (`DownloadedBrowse`) | **Rust** (recommended) | Containers-vs-leaves is Jellyfin structure, and the set grows when Jellyfin adds a container type — the litmus test's "yes". Prefer a `MediaItem.isContainer` boolean from the backend over a type-set predicate in a component. |
| "Is this music content?" (`GenericMediaListPage`) | **Frontend, allowlisted** | Selects a grid *style*. It reads `config.itemType`, a value the page already declares about itself, and changes only if the UI is redesigned — the litmus test's "no". Single-type presentation is explicitly not the target of the rule. |
`DownloadedBrowse` defaults to Rust per the checklist's borderline rule; see
Out of scope for why the migration itself is deferred rather than bundled.
## Design
### 1. Broaden the pattern
Replace the key-anchored pattern with one matching an array literal of two or
more known Jellyfin item types, wherever it appears:
```sh
# Two or more adjacent item-type string literals inside a bracket.
TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
PATTERN="\[[[:space:]]*\"($TYPES)\"[[:space:]]*,[[:space:]]*\"($TYPES)\""
```
Properties worth stating, because each is a deliberate trade:
- **Not anchored to any key**, so a named const, a function return, a `Record`
value, or an inline query all match equally.
- **Requires two adjacent type literals**, preserving the existing and correct
carve-out that single-type presentation (`itemType: "Movie"`) is legitimate.
- **Requires string literals**, so `item.type === "Audio"` (display inspection)
still does not match.
- **Explicit type list**, not `[A-Z][a-z]+`, so arbitrary string arrays
(`["High","Low"]`, `["Songs","Albums"]`) do not produce noise.
Keep `grep -rInE`, the `*.test.*` exclusion, and the allowlist mechanism as they
are — all three work.
### 2. Resolve the surfaced sites
- `PersonDetailView.svelte` — already allowlisted; entry unchanged.
- `GenericMediaListPage.svelte`**add to the allowlist** with the reason from
the layer table (grid styling over a self-declared `itemType`).
- `DownloadedBrowse.svelte` — **add to the allowlist with a `TODO` naming the
preferred fix** (backend `isContainer`). An allowlist entry that records a
known-borderline decision is honest; silently broadening the pattern to miss
it would not be.
- `searchScope.ts`**not allowlisted.** It is the leak, and
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
deletes it.
### 3. 🔴 Sequencing
**This spec must land after Stage 1 of
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).**
Hardening the tripwire first turns `master` red on a violation with no fix
available, and the only ways out are reverting the hardening or allowlisting the
leak — the second of which is exactly how a boundary rule dies.
### 4. Keep the allowlist honest
The script already warns that a growing allowlist means the boundary is eroding.
This change takes it from 1 entry to 3, which is close to that line. Add a hard
cap so drift is caught mechanically rather than by whoever notices:
```sh
MAX_ALLOWLIST=4
if [ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]; then
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
echo " Push taxonomy into Rust instead of appending here."
exit 1
fi
```
The cap is deliberately just above the current count: the next exception forces
a conversation instead of a one-line append.
### 5. Restate the limits
The header's "tripwire, not a proof" caveat stays and gets sharper. The broadened
pattern still cannot see:
- a type set built at run time (`[...musicTypes, "Playlist"]`),
- types split across variables (`const A = "Audio"; [A, B]`),
- taxonomy expressed as a `switch` or chained `||` rather than an array.
The spec-review checklist remains the real gate. This raises the floor; it does
not close the class.
## Out of scope
- **Migrating `DownloadedBrowse` to a backend `isContainer` flag.** It touches
`MediaItem`, `bindings.ts`, and the offline path — its own spec. Allowlisted
with a TODO here so it is recorded, not forgotten.
- The scoped-search fix itself — [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).
- Detecting the run-time-construction cases listed above.
- Extending the check to Rust or Kotlin (the rule is about `src/`).
- Changing the boundary *policy* in CLAUDE.md.
## Acceptance criteria
- [ ] With `searchScope.ts` reverted to its leaking form, `bun run check:boundary`
**fails** and names `src/lib/utils/searchScope.ts`. This is the criterion
that proves the fix — verify it explicitly before landing.
- [ ] On the post-fix tree, `bun run check:boundary` passes.
- [ ] A newly introduced `const X = ["Movie", "Series"]` in any non-test `src/`
file fails the check (regression test for the const-indirection evasion).
- [ ] `itemType: "Movie"` and `item.type === "Audio"` do **not** trip the check.
- [ ] `["High", "Low"]` and other non-item-type arrays do **not** trip it.
- [ ] Test files are still excluded (the 6 known test hits stay silent).
- [ ] The allowlist has exactly 3 entries, each with a written reason; a 5th
entry fails the check via `MAX_ALLOWLIST`.
- [ ] The script header still states it is a tripwire, not a proof, and names the
evasions it cannot see.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `bun run test:all` passes.
## Testing
The script is bash and has no test harness. Verify by construction — each is a
temporary edit, run, revert:
1. Reintroduce the `SCOPE_ITEM_TYPES` const → **must fail**.
2. Add `const T = ["Movie","Series"]` to a scratch `.svelte` file → **must fail**.
3. Add the same to a `.test.ts` file → **must pass** (exclusion holds).
4. Add `itemType: "Movie"`**must pass**.
5. Add a 5th allowlist entry → **must fail** on the cap.
Record the five results in the PR description. A grep-based gate that has never
been observed failing is indistinguishable from one that cannot fail — which is
the precise condition this whole spec exists to correct.
## TRACES
Allocate in `requirements.md`:
- **DR-094** — "Frontend boundary tripwire detects Jellyfin item-type array
literals anywhere in `src/` (not only inline at an `includeItemTypes:` query
site), so a category→type mapping cannot evade the check via a named const;
allowlist is capped to force taxonomy into Rust rather than accumulating
exceptions." Category: Tooling. Status: Done on merge.
Shell scripts carry no `TRACES:` comment convention in this repo; reference
DR-094 in the script header comment instead.
## Notes for the implementer
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes (CLAUDE.md §Gotchas).
- **Land after Stage 1 of the scoped-search fix** — see §3. This is the one
ordering constraint that will break `master` if ignored.
- Test the regex against the current tree *before* committing:
`grep -rInE "$PATTERN" src/ | grep -v '\.test\.'` should return exactly the
four sites in the Motivation table.
- The `TYPES` list will need occasional extension as Jellyfin adds types.
That is acceptable for a tripwire — an unlisted type produces a false
negative, never a false positive, so the check degrades safely.
+8 -2
View File
@@ -1,7 +1,13 @@
# Spec: Build provenance (git describe + build profile)
**Status:** Proposed
**Requirements:** new DR-093 (build provenance surfaced in-app and in logs); no UR — this is a diagnostic capability, not a user feature
**Status:** Proposed — not started. `src-tauri/build.rs` still contains only
`tauri_build::build()`, and nothing reports a version over IPC. Note that
`scripts/set-version.sh` has since landed, which changes the "three hand-bumped
files" premise below: versions are now stamped from one place.
**Requirements:** ⚠️ the suggested id **DR-093 has since been allocated** to the
traceability coverage gate — allocate a fresh id (DR-215 or later) on
implementation. Build provenance surfaced in-app and in logs; no UR — this is a
diagnostic capability, not a user feature
**UX spec:** n/a — adds an About block to Settings; no new flow
**Supersedes / revises:** —
-334
View File
@@ -1,334 +0,0 @@
# Spec: Locally-indexed search
**Status:** Implemented
**Requirements:** UR-065 → DR-108, DR-109, DR-110, DR-111; IR-030
**UX spec:** [ux-flows.md §6.1](../ux-flows.md) (search surface is unchanged)
**Revises:** [scoped-search.md](scoped-search.md) and
[scoped-search-boundary.md](scoped-search-boundary.md) — scope semantics are
untouched; this changes only *which corpus* the cache leg searches.
## Summary
Search stops depending on a per-keystroke round trip to Jellyfin. The local
SQLite catalog — which is already synced and already FTS5-indexed — becomes the
corpus the instant leg of search reads, so results appear as fast as SQLite can
answer, online or offline. A background indexer keeps that catalog fresh on a
schedule instead of only at app start, prunes content deleted on the server, and
covers the item types search groups results by. The server query stays, demoted
to a background reconciliation that merges in late results for anything indexed
since the last pass.
## Motivation
The pieces are already built and simply not wired together:
- [`sync_full_catalog`](../../src-tauri/src/commands/catalog.rs) already walks
every library `Recursive=true` and persists items with `synced_at`.
- `items_fts` (schema.rs migration 001) already indexes `name`, `overview`,
`album_name`, `album_artist`, `artists`, `series_name` with keep-in-sync
triggers.
- `repository_search` is already two-phase — synchronous cache result, then a
spawned server query merged in via the `search-event`.
What breaks the chain is that the cache leg is hard-restricted to *downloaded*
items. `OfflineRepository::search` wraps its FTS query in a `downloaded_items`
CTE requiring `d.status = 'completed'`:
```sql
FROM items i
JOIN items_fts fts ON fts.rowid = i.rowid
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND items_fts MATCH ?
```
So for a user with no downloads, phase 1 returns nothing on every query, and
every debounced keystroke falls through to a full `Recursive=true` server
request with `Limit=10000`. The populated local index is never read.
`get_items` does not have this problem — it gates a third `synced_at IS NOT NULL`
branch on `include_catalog_browse()` (offline.rs, the "Show all server media"
toggle). The asymmetry is the bug: **offline you can already browse the whole
catalog but cannot search it.**
Three further defects found while confirming the above:
1. **The FTS index grows without bound.** `save_to_cache` uses
`INSERT OR REPLACE INTO items`, but `recursive_triggers` is never enabled
(`storage/mod.rs` sets only `foreign_keys` and `journal_mode`). SQLite fires
`AFTER DELETE` triggers on a REPLACE *only* with recursive triggers on — so
`items_ad` never runs, the old FTS row is orphaned, and because `items.id` is
a `TEXT PRIMARY KEY` the replacement row takes a **new rowid** and inserts a
second FTS entry. Every sync appends a duplicate index. Results stay correct
(the `INNER JOIN … ON fts.rowid = i.rowid` hides orphans, and no rowid is ever
reused because nothing is deleted) but `MATCH` degrades permanently.
2. **Server-side deletions never propagate.** There is no `DELETE FROM items`
anywhere in the codebase. The local catalog is append-only, so media removed
from the server would stay searchable forever — tolerable when the cache was
only a browse accelerator, not acceptable when it is the search corpus.
3. **The index omits types search groups by.** `CATALOG_ITEM_TYPES` is
`MusicAlbum, Movie, Series, Season, Episode, Audio, BoxSet` — no
`MusicArtist`, no `Playlist`, and People live in a separate `people` table
with no FTS at all. UR-060 mandates Artists and People result groups, so today
those can *only* come from the server.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which corpus search reads (downloads-only vs full synced catalog) | **Rust** | Sync/availability policy over domain data. Changes if Jellyfin's API or the offline rules change, not if the UI is redesigned. Reuses the existing `include_catalog_browse()` flag so search and browse cannot diverge again. |
| Index freshness policy — TTL, when a re-index is due, skip-while-offline | **Rust** | Explicitly named as domain policy in [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) ("reachability/sync policy"). It is currently frontend-driven in `offlineCatalog.ts`; this spec moves it. |
| Which Jellyfin item types get indexed (`CATALOG_ITEM_TYPES`) | **Rust** | Textbook domain taxonomy — a category→item-type set. Must never appear in `src/`. |
| Reconciling a crawl against local rows (what to prune) | **Rust** | Operates on domain data and depends on crawl completeness semantics. |
| FTS query construction, ranking, scope→type expansion | **Rust** | Already there (`search_rank.rs`, `SearchScope::item_types()`); unchanged by this spec. |
| Rendering a "catalog last indexed N ago" hint and any re-index button | **Frontend** | Pure presentation of a backend-supplied timestamp. |
| Debounce interval, result group order, scope chips | **Frontend** | Input handling and view preference; changes only if the UI is redesigned. |
Borderline call, recorded: the **TTL value itself** (how many hours before a
re-index is due) could be argued as a user preference and therefore frontend. It
is placed in Rust because the frontend must not be able to decide *whether the
cache is authoritative* — that is the same class of decision as
`include_catalog_browse`, which already lives in Rust. If the TTL later becomes
user-configurable it stays a Rust-owned setting the frontend edits through a
command, not a frontend constant. Borderline defaults to Rust.
## Design
### 1. Search the full synced catalog (DR-108)
`OfflineRepository::search` mirrors `get_items` exactly: rename the CTE to
`available_items` and add the same third branch, gated on the same flag.
```rust
let catalog_branch = if include_catalog_browse() {
"UNION
-- Synced catalog: fast online search, or the offline 'Show all
-- server media' view. Mirrors get_items; see set_include_catalog_browse.
SELECT DISTINCT i.id
FROM items i
WHERE i.synced_at IS NOT NULL"
} else {
""
};
```
No new IPC surface and no frontend change: `set_include_catalog_browse` is
already called with `true` when online or when the offline toggle is on, and
`false` only when offline with the toggle off. Search inherits the correct
behaviour in all three states, and the "search is restricted to downloads" case
survives for users who deliberately asked for downloads-only.
Also fix, in the same function, the `type_filter` built by **string
interpolation** of `include_item_types` rather than bound parameters. It is
currently safe only because callers pass `SearchScope`-derived values, but
`SearchOptions.include_item_types` is settable directly from the frontend (as
`GenericMediaListPage` does). Bind the values.
Phase 2 (the server query) is unchanged and still merges via `search-event`, so
content added to the server since the last index still surfaces — just late
rather than first.
### 2. Scheduled background indexer (DR-109, IR-030)
A Rust-owned task replaces the frontend's startup-only trigger.
```rust
/// How long a full-catalog index stays fresh before a re-index is due.
const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
```
Behaviour:
- On app setup, spawn a tokio task that ticks every 30 min.
- Each tick: if a repository is active **and** the server is reachable **and**
`now - last_catalog_sync > CATALOG_INDEX_TTL`, run a full index pass.
- On the existing `ConnectivityMonitor` reconnect signal, evaluate the same
staleness condition immediately rather than waiting for the next tick.
- Never run two passes concurrently (the existing `syncInProgress` guard moves
into Rust as an `AtomicBool`).
`last_catalog_sync` is already written to `app_settings` by `sync_full_catalog`
and is currently read only for a UI hint; this makes it load-bearing.
`RepositoryManager` (`commands/repository.rs`) is a `HashMap<String, …>` with no
notion of an active handle, so the task has nothing to run against. Add:
```rust
pub struct RepositoryManager {
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
active: Arc<Mutex<Option<String>>>, // set in create(), cleared in destroy()
}
```
Progress is reported with a **kebab-case** event (per the project convention):
```rust
// event name: "catalog-index-event"
#[derive(specta::Type, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CatalogIndexEvent {
pub state: CatalogIndexState, // #[serde(tag = "type")] Idle | Running | Complete | Failed
pub libraries_done: usize,
pub libraries_total: usize,
pub items_indexed: usize,
}
```
`sync_full_catalog` stays a command so the UI can still force a pass; it and the
scheduler share one internal `run_index_pass()`.
### 3. Index hygiene — no orphans, and deletions propagate (DR-110)
**Orphan growth.** Replace `INSERT OR REPLACE INTO items (…)` in `save_to_cache`
with a true upsert:
```sql
INSERT INTO items (id, server_id, …) VALUES (…)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, overview = excluded.overview, …,
synced_at = excluded.synced_at
```
This preserves the rowid (which `items_fts` keys on via `content_rowid`) and
fires `items_au` instead of silently orphaning a row. Preferred over
`PRAGMA recursive_triggers = ON` because it also stops the rowid churn, and the
three FTS triggers are the only triggers in the schema so nothing else depends
on REPLACE semantics.
A new migration `021_rebuild_items_fts` clears the orphans already accumulated on
existing installs:
```sql
INSERT INTO items_fts(items_fts) VALUES('rebuild');
```
**Deletions.** After a library crawls *successfully and completely*, reconcile:
delete local rows for that library whose `id` was not seen in the crawl. Two
constraints the implementation must respect:
- Skip any item with a completed download — the user has the file; removing the
row would orphan it. Prune only synced-but-not-downloaded rows.
- Only sweep libraries whose crawl succeeded. `sync_full_catalog` is
deliberately best-effort per library, and `items.parent_id` is
`ON DELETE CASCADE` — sweeping on a partial crawl would cascade a whole series
away because one request timed out.
### 4. Index the types search groups by (DR-111)
Add `MusicArtist` and `Playlist` to `CATALOG_ITEM_TYPES`.
People need a different mechanism: they live in `people` (`id`, `server_id`,
`name`, `overview`, `primary_image_tag`, `synced_at`), populated incidentally by
item-detail fetches, with no FTS table. Migration `022_people_fts` adds one
mirroring the `items_fts` pattern:
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
name, overview, content='people', content_rowid='rowid'
);
-- plus people_ai / people_ad / people_au triggers
```
`OfflineRepository::search` UNIONs `people_fts` matches into its result set as
`Person`-typed items when the resolved scope permits them (i.e. when
`include_item_types` is `None``SearchScope::All`). `search_rank.rs` already
handles `MediaKind::Person`, so ranking needs no change.
## Out of scope
- **Incremental indexing** (e.g. Jellyfin's `MinDateLastSaved`). A full crawl is
what makes the deletion sweep in §3 sound — it yields the authoritative id set
per library. An incremental pass cannot detect deletions, so it would need a
separate reconciliation strategy. Worth revisiting if full crawls prove too
slow on large libraries; measure first.
- **Changing search UX** — scope chips, group order, the debounce, and the
`/search` route are untouched.
- **Removing the server leg.** Phase 2 stays.
- The two dead search implementations (`storage_search_items` in
`commands/storage/mod.rs`, `offline_search` in `commands/offline.rs`) — both
registered in `lib.rs` and exported to `bindings.ts`, neither called from the
frontend. Deleting them is correct but is cleanup, not this feature; file
separately so this spec's diff stays reviewable.
- `GenericMediaListPage` passing raw `includeItemTypes` and re-implementing the
store's request-id/event protocol. A real boundary smell, tracked separately.
## Acceptance criteria
- [ ] With a synced catalog and **zero downloads**, typing a query returns
results from the local index before any server request completes.
- [ ] Offline with "Show all server media" **on**, search returns the full
catalog (non-downloaded entries greyed out, matching browse).
- [ ] Offline with the toggle **off**, search returns downloaded media only —
the behaviour that exists today.
- [ ] Re-running a full index pass N times does not grow `items_fts` row count
beyond the `items` row count.
- [ ] An item deleted server-side disappears from local search after one index
pass; a **downloaded** item deleted server-side does not.
- [ ] A library that fails mid-crawl prunes nothing.
- [ ] Searching an artist or actor name returns results with the server
unreachable.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes.
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated (new `CatalogIndexEvent` type).
## Testing
Per CLAUDE.md, each defect gets a **failing test first**.
Rust (`cargo test`), against an in-memory DB seeded with synced-but-not-
downloaded items:
- `search` returns synced items when `include_catalog_browse()` is true, and
only downloaded items when false. *Fails today* — the current CTE returns
empty in the first case.
- Upserting the same item twice leaves exactly one `items_fts` row. *Fails
today.*
- The sweep removes a vanished synced item, retains a vanished downloaded item,
and no-ops for a library whose crawl errored.
- `type_filter` binds parameters — a type string containing a quote does not
alter the query.
- Staleness: a `last_catalog_sync` inside the TTL does not trigger a pass; one
outside it does; offline never does.
- `people_fts` matches surface as `Person` items under `SearchScope::All` and
are excluded under `Music`/`Movies`/`Tv`.
Frontend (`vitest`): the catalog-index event maps to the staleness hint; no
change to the search store's request-id/stale-response handling, which stays
covered by its existing tests.
## TRACES
| Piece | Tag |
|---|---|
| `OfflineRepository::search` availability CTE | `// TRACES: UR-065 \| DR-108` |
| Background indexer task + scheduling | `// TRACES: UR-065 \| DR-109, IR-030` |
| `save_to_cache` upsert + FTS rebuild migration | `// TRACES: UR-065 \| DR-110` |
| Deletion reconciliation | `// TRACES: UR-065 \| DR-110` |
| `CATALOG_ITEM_TYPES` widening + `people_fts` | `// TRACES: UR-065, UR-060 \| DR-111` |
## Notes for the implementer
- **A parallel Claude session may be active in this repo.** Run `git diff`
before "repairing" changes you did not make (CLAUDE.md gotchas).
- The frontend's `offlineCatalog.ts` startup trigger should be **removed**, not
left alongside the Rust scheduler — two independent triggers with one
`syncInProgress` guard each is how double-crawls happen.
- `downloads` has a relaxed FK to `items` (migration 005). Verify the deletion
sweep's interaction with it before enabling the sweep, and check whether
`parent_id`'s `ON DELETE CASCADE` reaches further than intended.
- The existing 100 ms `cache_with_timeout` in `hybrid.rs` returns *empty* on
timeout rather than erroring. Once the cache leg is the primary path, that
budget may need raising — an FTS query over a large catalog on cold page cache
can exceed it, and the failure mode is a silently empty result.
- Keep `SearchScope` semantics as-is: `All => None` (no filter), deliberately
not a union, so People and folders are not filtered out (DR-063).
- Noted but deliberately not fixed here: `pushCatalogVisibility` in
`offlineCatalog.ts` derives the flag as `connected || showCatalog` — the
frontend computing an availability *policy*, even though the flag itself is
Rust-stored. DR-108 depends on that derivation being correct and it is, so
this spec leaves it alone. Once DR-109 has moved sync policy into Rust, the
derivation belongs there too, with the frontend pushing only the raw user
toggle. Folding it into this change would enlarge the diff for no behavioural
gain — but do not add *new* policy on the frontend side of that line.
+192
View File
@@ -0,0 +1,192 @@
# Spec: Diagnostics and persistent logging
**Status:** Proposed
**Requirements:** UR-078 → DR-218; tests UT-209
**UX spec:** n/a (one Settings section; no new flow)
**Destination on completion:** [09-security.md](../architecture/09-security.md)
for the redaction rules, and a new "Logging and diagnostics" section in
[01-rust-backend.md](../architecture/01-rust-backend.md) for the capture path.
## Summary
JellyTau records what it does, keeps it in a size-capped file on disk, survives a
crash, and can hand the whole thing to the user as one file to attach to a bug
report. Credentials never reach that file.
## Motivation
Today the app forgets everything the moment it exits.
The Rust half logs through `env_logger` to **stdout only**. A user who launched
from a desktop icon has no stdout. On Android it is worse than useless:
`env_logger` writes to stdout, which is not logcat, so **the Rust backend's
output is invisible on the platform where most of the hard bugs have been** — the
autoplay deadlock, the truncated-stream restart, the background-audio stall. The
frontend has a proper leveled facade (`logger.ts`, DR-204) but it only reaches
the webview console, which nobody can read on a phone.
The practical consequence is visible in this project's history: several bugs took
multiple rounds of "can you reproduce it under `adb logcat`" before anyone could
even see what happened. A user reporting "the episode randomly restarted" is
reporting the symptom of a race whose evidence was discarded microseconds later.
There is also no crash record at all. If the app panics, the user sees it vanish
and we learn nothing.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| What is captured, at what level, and where it is written | Rust | Retention and capture policy is backend behaviour; it must work identically whether the UI is open, backgrounded, or gone |
| Log rotation and the size cap | Rust | Storage management, same class as the download and image caches |
| **Redaction of credentials** | Rust | Security-critical, and the values (tokens, `api_key`, keyring payloads) are domain vocabulary owned by the auth layer. A frontend that redacted its own messages would still not cover anything Rust wrote |
| Panic capture and persistence | Rust | Only Rust can install a panic hook |
| Assembling the export (archive + environment summary) | Rust | Touches the filesystem and the app's own paths; also the last point at which redaction can be enforced over everything |
| Which log level is active | Rust owns the *stored* setting and applies it; the frontend renders the picker | Same split as every other setting: the value is state the backend acts on, the control is presentation |
| Showing the export path / opening the folder | Frontend | Pure presentation |
| Formatting a log line for the webview console | Frontend | `logger.ts` already owns this; unchanged |
Borderline: **the frontend forwarding its own messages into the Rust sink.**
Arguably presentation "sending data down". Placed as: the frontend calls a
plugin, and the *decision of what to persist and how to redact it* stays in Rust
— which is the tie-breaker, because a bug report containing a token would be a
security defect regardless of which half wrote the line.
## Design
### Capture
Replace the `env_logger` init in `lib.rs` with `tauri-plugin-log`, which is the
official plugin and already does the three things we would otherwise hand-roll
(CLAUDE.md: prefer official plugins before writing native code):
| Target | Purpose |
|---|---|
| `Stdout` | unchanged behaviour for `bun run tauri dev` |
| `LogDir { file_name: "jellytau" }` | the persistent, rotating file |
| `Webview` (dev only) | Rust lines visible in the webview console while developing |
On Android the plugin routes to **logcat**, which is the single largest
improvement here and needs no code of ours.
Rotation: `RotationStrategy::KeepAll` is wrong for a phone. Use a size cap
(5 MB) with one retained previous file, so a long session cannot fill a device
and yesterday's evidence still exists.
Level: default `Info`, `RUST_LOG` still honoured, and a stored user preference
that survives restart (a user reproducing a bug needs debug logging *across* the
restart that reproduces it).
### Redaction
A pure function in a new `src-tauri/src/utils/diagnostics.rs`:
```rust
pub fn redact(line: &str) -> String
```
It replaces the value in each of these with `[REDACTED]`, case-insensitively:
- `api_key=…` and `ApiKey=…` in URLs and query strings
- `X-Emby-Token: …`, `X-MediaBrowser-Token: …`, `Authorization: …` headers
- `"AccessToken":"…"` in JSON bodies
- `MediaBrowser Token="…"` in the Emby auth header form
What it deliberately does **not** remove: the server host, item ids, and
filenames. Those are what make a log useful, they are not secrets, and stripping
them would produce a diagnostic bundle nobody can diagnose anything from.
Applied at two points: on every line the export copies, and — because the export
is not the only way a file leaves a device — inside the log formatter itself, so
the token never reaches disk in the first place. The export-time pass exists to
cover files written before an upgrade.
### Export
```rust
#[tauri::command]
pub async fn diagnostics_export(app: AppHandle) -> Result<DiagnosticsBundle, String>
```
Writes a single `.zip` and returns where it went:
```rust
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticsBundle {
pub path: String,
pub size_bytes: u64,
pub file_count: usize,
}
```
Contents: the current and previous log files (redacted), plus `environment.txt`
— app version, OS and arch, whether the build is debug, the active log level, and
the *scheme and host* of the configured server. No token, no username, no path
inside the user's home beyond the app's own directories.
### Frontend
`logger.ts` keeps its `console.*` pass-through untouched — live object references
in devtools are a stated design goal of DR-204 — and *additionally* forwards a
stringified copy at `info` and above to the plugin, so one timeline contains both
halves of the app. Forwarding is fire-and-forget and never throws into a caller:
a logging failure must not become an application failure.
A `Diagnostics` section in Settings shows the log location, a level picker, and
an **Export diagnostics** button that reports the resulting path and, on desktop,
offers to reveal it.
## Out of scope
- **An Android share sheet.** Export writes to the app's files directory and
reports the path; wiring a native `ACTION_SEND` intent is a Kotlin change that
belongs with the other native work, not here.
- **Uploading anywhere.** Nothing is transmitted. The user attaches the file
themselves, which is also what keeps this from becoming telemetry.
- **Frontend `debug` forwarding.** Only `info`+ crosses the IPC boundary; per-tick
player debug would be thousands of calls a minute.
## Acceptance criteria
- [ ] Rust logs reach a rotating file on Linux and **logcat** on Android.
- [ ] A panic is recorded and is present in the next export.
- [ ] Frontend `info`/`warn`/`error` appear in the same file as Rust's lines.
- [ ] An export containing a request URL with `api_key=` shows `[REDACTED]`, and
a test greps the produced bundle for the token to prove it.
- [ ] The log file cannot exceed the cap.
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy -D warnings`,
`bun run test:rust`, `bun run check:boundary` all pass.
- [ ] `bindings.ts` regenerated (new command and struct).
- [ ] New code carries `TRACES:` comments.
## Testing
**Rust** (`cargo test`): `redact` over each credential shape, including one
already-redacted line (idempotent) and a line containing no secret (unchanged);
that the environment summary contains a host but no token; that rotation respects
the cap.
**Frontend** (`vitest`): that the forwarder is called for `info`+ and not for
`debug`; that a rejected forward does not propagate to the caller.
## TRACES
| Piece | Tag |
|---|---|
| `utils/diagnostics.rs` | `UR-078 \| DR-218` |
| `commands/diagnostics.rs` | `UR-078 \| DR-218` |
| logging init in `lib.rs` | `UR-078 \| DR-218` |
| `logger.ts` forwarding | `UR-078 \| DR-204, DR-218` |
| Settings section | `UR-078 \| DR-218` |
| tests | `\| DR-218 \| UT-209` |
## Notes for the implementer
- A parallel Claude session may be active — `git diff` before "repairing"
anything unexpected.
- `utils/lock.rs` already sets and restores a panic hook in its tests. The
diagnostics hook must chain to the previous hook rather than replace it, or
those tests start reporting panics they deliberately suppress.
- Do not call the exporter from an event callback that can re-enter the player:
it does blocking file I/O (see the deadlock note in CLAUDE.md).
-166
View File
@@ -1,166 +0,0 @@
# Spec: Downloads as a browsable offline library
**Status:** Draft — ready to implement
**Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust.
**Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085
(see [requirements.md](../requirements.md)).
**UX spec:** [ux-flows.md §7.27.7](../ux-flows.md).
## Summary
Replace the flat Active/Completed download list with two views under
`/downloads`:
1. **Downloaded** (default) — the library, filtered to what's on the device,
using the *same* browse screens as online (grids, cards, detail pages).
2. **Transfers** — the existing progress-row list, demoted to a secondary tab,
showing only in-flight transfers.
Plus per-item disk usage (UR-056) shown in familiar units on cards, detail
pages, a device total, and the remove confirmation.
## Motivation
A user who downloaded three seasons and two albums sees ~70 individual transfer
rows today, with no grouping and no reuse of the library UI. "What do I have
offline" and "what is downloading" are different questions crammed into one flat
list. Browsing offline should feel exactly like browsing online.
## Background: verified current state
1. **The offline repository is already a browsable tree.**
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns
downloaded items **plus** containers (MusicAlbum, Series, Season) that have at
least one downloaded child. `get_libraries`, `get_item`, and `search` all
filter to downloaded content via CTEs. This is the data source for
Downloaded; **do not build a new query layer.**
2. **The client cannot reach it independently.**
[repository-client.ts](../../src/lib/api/repository-client.ts) `getItems`
`repositoryGetItems` always goes through the **hybrid** repository
([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and
server. There is no "offline only" browse path exposed. This is the one real
backend gap (DR-082).
3. **Downloads page is a flat two-tab list.**
[downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active /
Completed tabs, one `DownloadItem` row per transfer, no browsing.
4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`,
the `/library/[id]` detail page (§5A/§5B) render whatever items they are
given. Downloaded browse is those components with an offline-scoped source.
5. **A related fallthrough bug is already tracked** (DR-080, another session):
`HybridRepository::get_items` treats an empty offline result as a cache miss
and falls through to the server. The offline-only browse path (DR-082) must
**not** share that behaviour — an empty result there is authoritative "nothing
downloaded here."
6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.**
Do not surface them as manual controls; do not loop `startDownload` from the
frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas).
## Design
### View split (DR-081)
`/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the
default. Transfers shows a count/badge only while transfers are active.
Initiating downloads stays on item/album/series detail pages (§7.1) — this page
does not start downloads.
### Offline-scoped browse source (DR-082, DR-083)
Add an explicit offline-only browse path so Downloaded never merges server
results and never depends on reachability. Two viable shapes — pick per the
codebase, do not do both:
- **(a)** A dedicated command (e.g. `repository_get_downloaded_items` /
`_libraries`) that calls the offline repository directly, with a matching
client method; or
- **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path
that bypasses the hybrid merge and the empty→fallthrough behaviour.
Either way: an empty result is authoritative (do **not** reuse the DR-080
fallthrough), and the path is available while the server is reachable (a user
online still wants to browse their downloads).
Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this
source. Omit libraries and containers with no downloaded content. Badge
partially- vs fully-downloaded containers. Play uses the local file; remove is
available at item / album / season / series level and removes a container from
the browse when its last downloaded child goes.
### Transfers view (DR-084)
The existing list, filtered to in-flight rows only: downloading (with progress),
queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other
session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave
this view — they appear in Downloaded. Empty state points at the library.
### Disk usage (DR-085, UR-056)
- **Source the bytes from the download manager** — it writes the files and can
stat them. Aggregate to album/season/series subtotals and a device total.
This is display + aggregation, **not** new tracking.
- **Format once, consistently.** One shared formatter, human units, 23
significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use
it everywhere.
- **Surface it in familiar places:** a secondary size label on the card and
detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`)
that reconciles with the listed sum; a reclaim figure in the remove
confirmation ("frees 1.2 GB"). No separate "storage report" screen.
- Sort/filter by size is a nice-to-have, not required for v1.
## Out of scope
- Changing download initiation, the 3-concurrent cap, or the auto-pump.
- The catalog-browse / show-server-catalog toggle (UR-052, another session) —
that governs the *online offline-fallback* library; this is the dedicated
Downloads surface. They should be consistent but are separate work.
- Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend
on that behaviour here.
## Acceptance criteria
- [ ] `/downloads` opens on Downloaded and can switch to Transfers.
- [ ] Downloaded lists only libraries/containers with downloaded content, using
the same grids/cards/detail pages as online browsing.
- [ ] Browsing Downloaded never shows non-downloaded server items, online or off.
- [ ] An empty Downloaded result reads as "nothing downloaded," never falls
through to the server.
- [ ] Play from Downloaded plays the local file.
- [ ] Remove works at item/album/season/series level and updates the browse.
- [ ] Transfers shows only in-flight rows with working controls; finished
transfers move to Downloaded.
- [ ] Each downloaded item/container shows its on-disk size; a device total is
shown and reconciles with the sum; remove states the reclaim amount.
- [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` +
`cargo clippy` pass.
## Testing
- Repository client: the offline-only browse path returns downloaded content and
its containers, and an empty result does **not** trigger server fallthrough.
- Downloaded view: libraries/containers with no downloads are omitted;
partial/full container badging.
- Transfers: only in-flight statuses render; a completed transfer disappears.
- Size formatter: rounding and unit thresholds; subtotal aggregation; device
total reconciles with listed items.
- If a Rust command is added, add the tauri IPC param-naming coverage per
[CLAUDE.md](../../CLAUDE.md) (camelCase rule).
New requirement-implementing code needs `TRACES:` comments. Suggested tags:
view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`;
Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`.
## Notes for the implementer
- Read [ux-flows.md §7.27.7](../ux-flows.md) first — behavioural spec; this is
the implementation plan.
- The offline repository already does the hard part. The main work is a clean
offline-only client path and reusing the library components — resist
rebuilding browse UI.
- Another session is active in downloads/offline/connectivity code (DR-074,
DR-078080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
and the repository layer; check `git diff` before repairing unexpected changes.
-403
View File
@@ -1,403 +0,0 @@
# Spec: Favourites — marking, browsing, and sync
**Status:** Implemented
**Requirements:** UR-067, UR-068, UR-069 → DR-113 … DR-120; JA-033, JA-034
(allocated in [requirements.md](../requirements.md); tests UT-099 … UT-107).
Note: UR-066/DR-112/IR-031 were claimed by the concurrent safe-area work while
this spec was being written, so the ids here start one higher than first drafted.
Existing: UR-017 → DR-021, JA-017, JA-018 (the toggle itself, already built).
**UX spec:** [ux-flows.md](../ux-flows.md) §3.2 (full-player favourite), §5.2
(album detail favourite), §5B.3 (movie detail hero: *Play / Download / Favorite*)
— all three already specify favourite affordances that **do not exist in the
build**. This spec closes those, and adds a new §5C for the Favourites browse
surface.
**Supersedes / revises:** nothing.
## Summary
JellyTau can favourite an item but can never show you what you favourited. The
heart is mounted in exactly one place (the mini player), no query anywhere asks
Jellyfin or the local database for favourites, and favourites marked on any other
client are invisible here. This spec adds the read side (a Favourites page, home
carousels, an in-library filter), puts the heart on detail pages and media cards,
teaches the backend to ingest server-side favourite state, and drains favourite
toggles made while offline.
## Background: what exists today
Verified in code, 2026-08-04. The **write** path is real and mostly correct; the
**read** path does not exist at all.
1. **Toggling works, from one place only.**
[FavoriteButton.svelte](../../src/lib/components/FavoriteButton.svelte) is
mounted solely in
[MiniPlayer.svelte:381](../../src/lib/components/player/MiniPlayer.svelte#L381).
Nothing else in `src/` renders it — so the only favouritable item in the app
is the one currently playing.
2. **The toggle's plumbing is sound.**
[favorites.ts](../../src/lib/services/favorites.ts) writes local first
(`storage_toggle_favorite`,
[storage/mod.rs:908](../../src-tauri/src/commands/storage/mod.rs#L908) — sets
`user_data.is_favorite` + `pending_sync = 1`), then POST/DELETEs
`/Users/{uid}/FavoriteItems/{id}`
([online.rs:1652](../../src-tauri/src/repository/online.rs#L1652)) only when
connected. Leave this design intact.
3. **`MediaItem.user_data` is always `None` from the server.**
`JellyfinItem` has no `UserData` field, and `to_media_item` hardcodes
[`user_data: None`](../../src-tauri/src/repository/online.rs#L656) with the
comment *"User data not included in basic item responses"*. The only
populated `user_data` in the app comes from
[series_progress.rs](../../src-tauri/src/repository/series_progress.rs#L244)
and the local read in
[offline.rs:115](../../src-tauri/src/repository/offline.rs#L115). **Nothing
ingests server favourite state**, which is why the mini player has to fetch
`storageGetPlaybackProgress` per track to colour one heart
([MiniPlayer.svelte:75-93](../../src/lib/components/player/MiniPlayer.svelte#L75-L93)).
4. **No favourites query exists.**
[`GetItemsOptions`](../../src-tauri/src/repository/types.rs#L278) has no
favourites field; `Filters=IsFavorite` appears nowhere; no SQL selects
`is_favorite = 1`; there is no `/library/favorites` route and no favourites
carousel in [home.ts](../../src/lib/stores/home.ts).
5. **Offline favourites are silently lossy.** Offline `mark_favorite` /
`unmark_favorite` are no-ops
([offline.rs:1620](../../src-tauri/src/repository/offline.rs#L1620)), so an
offline toggle survives only as a local row with `pending_sync = 1` — and
**nothing ever drains that flag**. `syncService.queueFavorite`
([syncService.ts:91](../../src/lib/services/syncService.ts#L91)) exists with
no callers.
## Motivation
Favouriting is a promise: the app takes the input and shows a "Added to
favorites" toast, then discards it as far as the user can tell. Three of the UX
flows already specify favourite buttons that were never built, and the one that
was built (mini player) writes to a store nothing reads. Either the feature gets
its read side or the heart should be removed — this spec takes the first option.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Favourites **scope** → set of Jellyfin item types | Rust | Domain taxonomy. Changes when Jellyfin adds/renames a type, never when the UI is redesigned. Reuses the canonical `SearchScope::item_types()` ([types.rs:324](../../src-tauri/src/repository/types.rs#L324)) — the exact leak class of [scoped-search-boundary.md](scoped-search-boundary.md). |
| Cross-library favourites query (`Filters=IsFavorite`, `Recursive`, paging, sort field) | Rust | Query shaping against the Jellyfin API is domain logic; the endpoint's contract changes with the server, not the UI. |
| Offline favourites SQL (join `user_data`, downloaded/catalog gating) | Rust | Storage + domain. Must obey the existing catalog-browse gate (DR-080) which the frontend cannot see. |
| Deserialising Jellyfin `UserData` into `MediaItem.user_data` | Rust | Provider payload mapping. |
| Mirroring server favourite state into the local `user_data` table | Rust | Cache/sync policy. |
| Conflict rule: a local row with `pending_sync = 1` beats the server value | Rust | Business rule about which write wins; nothing to do with rendering. |
| Draining pending favourite toggles on reconnect | Rust | Sync policy, and it must run whether or not any view is mounted — a frontend-driven drain dies with the component. Consistent with *reachability from real traffic* (DR-055). |
| Which surfaces show favourites, tab order, row placement on home | Frontend | Pure presentation; changes only if the UI is redesigned. |
| Heart placement, animation, toast, haptics, empty-state copy | Frontend | Presentation. |
| In-session optimistic heart state shared across views | Frontend | View state, not persisted truth; the durable write already goes to Rust. |
**Borderline, and the tie-breaker used:** *which* scopes appear as tabs (All /
Movies / Shows / Music) is a presentation choice — the frontend picks which
`SearchScope` values to offer. What each scope *means* is Rust's. The frontend
sends the enum value and never names an item type in connection with favourites.
Single-type pages (`itemType: "Movie"` on the Movies list page) stay as they are;
this rule targets category taxonomy, not every mention of a type.
## Design
### 1. Server user data reaches `MediaItem` (Rust, DR-113, JA-034)
Jellyfin returns `UserData` on `/Users/{uid}/Items*` responses. Add the field to
`JellyfinItem` and map it in `to_media_item`, replacing the hardcoded `None`:
```rust
// in JellyfinItem
#[serde(alias = "UserData")]
pub user_data: Option<JellyfinUserData>,
```
`JellyfinUserData` deserialises `IsFavorite`, `Played`, `PlaybackPositionTicks`,
`PlayCount`, `LastPlayedDate` into the existing
[`UserData`](../../src-tauri/src/repository/types.rs#L44) type (which already
carries `is_favorite` and already serialises camelCase, so `bindings.ts` needs no
new type — only regeneration). Add `UserData` to the `Fields=` list in `get_items`
/ `get_item` so the shape is explicit rather than relying on the default.
Wire shape, unchanged from today's `UserData`:
```ts
item.userData?.isFavorite // boolean | null | undefined
```
Delete the now-false `// User data not included in basic item responses` comment.
### 2. Local mirror of server favourites (Rust, DR-114)
Choke point: `save_to_cache(parent_id, &items)` in
[offline.rs](../../src-tauri/src/repository/offline.rs) — every server result
that gets cached (including via
[`cache_items_from_server`](../../src-tauri/src/repository/hybrid.rs#L124) and
the background cache refresh) passes through it.
For each item carrying `user_data.is_favorite`, upsert:
```sql
INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
VALUES (?, ?, ?, ?, 0)
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_favorite = excluded.is_favorite,
synced_at = excluded.synced_at
WHERE user_data.pending_sync = 0; -- local unsynced change wins
```
The `WHERE` on the conflict clause is the whole conflict rule: a toggle made
offline is never overwritten by a stale server value before it has been pushed.
### 3. Favourites queries (Rust, DR-115, DR-116, JA-033)
**(a) In-library filter** — one new field on `GetItemsOptions`:
```rust
#[serde(skip_serializing_if = "Option::is_none")]
pub favorites_only: Option<bool>,
```
- online `get_items`: append `&Filters=IsFavorite` when true.
- offline `get_items`: add `INNER JOIN user_data ud ON ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1`, composed with the existing `available_items` CTE so the downloads-only gate still applies.
Frontend sends `{ favoritesOnly: true }` (camelCase — nested struct field, needs
the existing `#[serde(rename_all = "camelCase")]` on `GetItemsOptions`, already
present).
**(b) Cross-library favourites** — a new trait method, because favourites span
libraries and `get_items` is `ParentId`-shaped:
```rust
/// TRACES: UR-067 | DR-115 | JA-033
async fn get_favorites(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError>;
```
```rust
#[tauri::command]
#[specta::specta]
pub async fn repository_get_favorites(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String>
```
Frontend call (command name matches the Rust fn exactly; top-level params
auto-camelCase; `SearchScope` is `#[serde(rename_all = "camelCase")]` so the wire
values are `"all" | "music" | "movies" | "tv"`):
```ts
await commands.repositoryGetFavorites(handle, "movies", { limit: 100 });
```
- **online**: `/Users/{uid}/Items?Filters=IsFavorite&Recursive=true&SortBy=SortName&SortOrder=Ascending` + `&IncludeItemTypes=…` from `scope.item_types()` (omit entirely on `None`, per that function's contract) + the standard `Fields=`.
- **offline**: `items ⨝ user_data (is_favorite = 1)`, type filter from the same `scope.item_types()`, honouring `include_catalog_browse()`.
- **hybrid**: same cache-first race as `get_items`, **including the DR-080 rule** — with the catalog-browse gate off, an empty offline result is authoritative and must not fall through to the server. Getting this wrong reproduces Defect B from [offline-downloaded-only-filter.md](offline-downloaded-only-filter.md).
**The cache-first result arrives stale, and there is no second payload.** On a
cache hit, `hybrid::get_items` returns the local rows and refreshes the cache in
a background task whose result the frontend never sees — fine for a library
listing that changes daily, wrong for favourites, where the *point* is that
another client just changed something. Favourites is the second read path (after
search) that needs the deferred update, so the background refresh in
`get_favorites` must emit the same `favorites-changed` event as §4 when the
server's favourite set differs from what was returned:
```
favorites-changed → { itemIds: string[] } // union of ids whose is_favorite flipped
```
Both producers (background refresh, reconnect drain) emit the identical payload,
and the frontend has one handler that refreshes the `favorites` store. Without
this, a favourite marked on another client appears in JellyTau only on the
*second* visit to the page.
### 4. Draining offline toggles (Rust, DR-120)
On the offline→online transition already detected by `ConnectivityMonitor`,
select `user_data WHERE pending_sync = 1 AND is_favorite IS NOT NULL`, POST or
DELETE `/Users/{uid}/FavoriteItems/{id}` per row, then set `pending_sync = 0` and
`synced_at`. Failures leave the row pending for the next transition.
Emit a kebab-case event when anything changed, so open views refresh without
polling:
```
favorites-changed → { itemIds: string[] }
```
`syncService.queueFavorite` is dead code once this lands — delete it or point it
at the backend drain; do not leave two competing queues.
### 5. Frontend surfaces (DR-117, DR-118, DR-119)
**Favourites page** — new route `/library/favorites`:
- Scope tabs *All / Movies / Shows / Music* via the existing `LibraryViewTabs`; each tab sends a `SearchScope` value, nothing more.
- Renders through `LibraryGrid` + `MediaCard` (tracklist for Music→tracks if the tab is later split; not in this pass).
- Entry points: a card on the library overview ([library/+page.svelte](../../src/routes/library/+page.svelte)) and "See all" on the home rows.
- Empty state per tab: "Nothing favourited yet — tap the heart on anything you like."
**Home carousels** — `favoriteMovies`, `favoriteShows`, `favoriteMusic` added to
[home.ts](../../src/lib/stores/home.ts), each `repositoryGetFavorites(scope, { limit: 20 })`,
rendered after *Recently Added* and **only when non-empty** (no empty rows on a
fresh install).
**In-library filter** — a favourites toggle in the header of
[GenericMediaListPage](../../src/lib/components/library/GenericMediaListPage.svelte)
and the Movies/TV landing pages, passing `favoritesOnly: true` into the existing
`repo.getItems(...)` options. Session-scoped state; not persisted (a persisted
filter that hides most of a library is a support call waiting to happen).
**Hearts** — mount `FavoriteButton`:
- Movie / series detail hero button row, beside the download buttons ([library/[id]/+page.svelte:528-553](../../src/routes/library/%5Bid%5D/+page.svelte#L528-L553)) — closes ux-flows §5B.3.
- `EpisodeFocusView`, `ArtistDetailView`, `PlaylistDetailView`, album detail — closes ux-flows §5.2.
- `MediaCard` artwork overlay (top-right). Suppressed on `isServerOnly` cards, and must not fight the existing long-press/scroll-guard handlers ([MediaCard.svelte:60-70](../../src/lib/components/library/MediaCard.svelte#L60-L70)) — the heart is its own button and stops propagation.
**Shared optimistic state** — a small `favorites` store (`Map<string, boolean>`
overlay + `favorites.set(id, value)`), so un-hearting an item on the Favourites
page removes it from the grid and from any home row without a refetch, and a
heart tapped on a card is reflected on the detail page. Resolution order:
```
favorites store override ?? item.userData?.isFavorite ?? false
```
`toggleFavorite()` updates the store alongside its existing local + server
writes; the `favorites-changed` event refreshes it. This removes the mini
player's per-track `storageGetPlaybackProgress` fetch once items carry
`userData`.
### 6. Offline behaviour
Toggling offline keeps working exactly as now (local write + `pending_sync`), and
now actually reaches the server on reconnect (§4). The Favourites page offline
shows favourites among downloaded/cached items, subject to the existing
catalog-browse gate. The offline repo's no-op `mark_favorite`/`unmark_favorite`
stay no-ops — the local write plus the drain is the offline path.
## Out of scope
- Favouriting people, genres, or collections; favourite **playlists** are included only insofar as they fall under the Music scope.
- Sorting by "date favourited" — Jellyfin does not expose it. Favourites sort by name.
- A dedicated bottom-nav tab for favourites (reachable from library overview + home).
- Building a playlist or download batch from favourites.
- Reconciling favourites for items that no longer exist on the server.
- Splitting the Music tab into albums/artists/tracks sub-tabs.
## Acceptance criteria
- [ ] Favouriting is possible from movie, series, episode, album, artist and playlist detail pages, and from media cards in any grid.
- [ ] A favourite marked in another Jellyfin client shows a filled heart in JellyTau without toggling it here.
- [ ] `/library/favorites` lists favourites across libraries, filtered by the All/Movies/Shows/Music tabs.
- [ ] Home shows favourite rows for movies, shows and music, and shows no row when a category has none.
- [ ] Movies/TV/Music list pages can be filtered to favourites only.
- [ ] Un-hearting an item on one surface updates the others without a manual refresh.
- [ ] A favourite toggled while offline reaches the server after reconnect (verified against a real server or a fake repository).
- [ ] Offline, the Favourites page respects the "Show all server media" gate — with it off, an empty result stays empty and does not fall through to the server.
- [ ] No item-type set appears in `src/` in connection with favourites; the frontend sends `SearchScope` only.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes (necessary, not sufficient — see CLAUDE.md).
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
## Testing
**🔴 §4 (the pending-sync drain) is a bug fix — failing test first.** Write a
test that toggles a favourite with the repository offline, transitions to online,
and asserts the server call happened; watch it fail before writing the drain.
Rust (`cd src-tauri && cargo test`):
| Test | Covers |
|------|--------|
| UT-099 | A Jellyfin item JSON fixture with `UserData.IsFavorite: true` maps to `MediaItem.user_data.is_favorite == Some(true)` |
| UT-100 | `online::get_favorites` builds an endpoint with `Filters=IsFavorite`, `Recursive=true`, and the scope's `IncludeItemTypes`; `SearchScope::All` omits the type filter entirely |
| UT-101 | `offline::get_favorites` returns only `is_favorite = 1` rows, respects the scope type filter, and returns nothing extra when the catalog-browse gate is off |
| UT-102 | `save_to_cache` mirror does **not** overwrite a row with `pending_sync = 1` |
| UT-103 | Drain pushes pending rows, clears `pending_sync`, sets `synced_at`, and leaves failed rows pending |
| UT-104 | `get_items` with `favorites_only: true` filters both online (endpoint) and offline (SQL) |
| UT-107 | The background refresh in `hybrid::get_favorites` emits `favorites-changed` with the flipped ids, and emits nothing when the server set matches the cache |
Frontend (`bun run test`):
| Test | Covers |
|------|--------|
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
| UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) |
| IT-0xx | `repositoryGetFavorites` param naming — add to the IPC param-naming suite under `src/lib/utils/` (`tauriIntegration.test.ts` no longer exists — see the current camelCase guards in `src/lib/stores/`): camelCase top-level params, scope serialised as `"movies"` etc. |
Any component logic worth testing gets extracted into a plain `.ts` module first
(`favoritesView.ts`), rather than tested through the component.
## TRACES
| Piece | Tag |
|-------|-----|
| `JellyfinUserData` + `to_media_item` mapping | `// TRACES: UR-069 \| DR-113, JA-034 \| UT-099` |
| `save_to_cache` user_data mirror | `// TRACES: UR-069 \| DR-114 \| UT-102` |
| `get_favorites` (trait, online, offline, hybrid) + command | `// TRACES: UR-067 \| DR-115, JA-033 \| UT-100, UT-101` |
| `GetItemsOptions.favorites_only` handling | `// TRACES: UR-067 \| DR-116 \| UT-104` |
| `/library/favorites` route + tabs | `// TRACES: UR-067 \| DR-117` |
| Home favourite carousels | `// TRACES: UR-067 \| DR-118` |
| `FavoriteButton` mounts + `favorites` store | `// TRACES: UR-068 \| DR-119 \| UT-105, UT-106` |
| Pending-favourite drain + `favorites-changed` | `// TRACES: UR-069 \| DR-120 \| UT-103` |
New requirement rows to add to [requirements.md](../requirements.md):
- **UR-067** — Browse favourited media across libraries (page, home rows, in-library filter).
- **UR-068** — Mark/unmark favourites from browse and detail surfaces, not only the player.
- **UR-069** — Favourite state stays consistent with the server in both directions.
- **DR-113 … DR-120** — as tabled above.
- **JA-033** — Query favourite items (`Filters=IsFavorite`).
- **JA-034** — Read `UserData` from item responses.
## Implementation notes (as built)
Two things landed differently from the design above, both forced by where the
`AppHandle` lives:
1. **The `favorites-changed` event is emitted from the command layer, not the
repository.** `HybridRepository` has no `AppHandle` — the same reason
`search-event` is emitted from `repository_search`. `repository_get_favorites`
therefore does the two-phase read itself (cache leg returned, server leg
spawned) and diffs the two id sets via `changed_favorite_ids`, which is
extracted and unit-tested (UT-107) rather than buried in the spawn.
2. **The drain hooks the existing `connectivity:reconnected` event** via
`app.listen` in `commands/favorites.rs`, rather than reaching into
`ConnectivityMonitor` (which knows nothing about repositories). It drains
through a narrow `FavoriteSink` trait so it can be tested against a recording
double instead of a forty-method `MediaRepository` mock.
3. **The command falls back to `HybridRepository::get_favorites` when nothing is
cached.** The two-phase read alone paints "Nothing favourited yet" on a fresh
install and corrects it a server round trip later, which is a wrong answer
shown to the user. An empty cache leg therefore defers to the repository's
own cache-first-then-server read. That read was also fixed to *save through*
on a server hit — without it the page re-queried the server on every visit
and the DR-114 mirror was never filled by this path.
Also as built: `DatabaseService` is not object-safe (generic methods), so the
drain takes `Arc<RusqliteService>` like the rest of the storage code, and
`get_items`' endpoint construction was extracted to `build_get_items_endpoint`
so the `favorites_only` filter could be asserted without an HTTP server.
**Not built:** the full-player heart. ux-flows §3.2 lists one among the full
player's secondary controls and it remains unbuilt — recorded as a known
deviation in ux-flows §5C.5 rather than silently dropped.
## Notes for the implementer
- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes.
- Do **not** try to reuse `get_items` with an empty `ParentId` for cross-library favourites; that endpoint is built as `?ParentId={}` ([online.rs:731](../../src-tauri/src/repository/online.rs#L731)) and an empty value is not a reliable "all libraries" request. Use `get_favorites`.
- `SearchScope` is reused rather than a new `FavoritesScope` so there is one taxonomy expansion in the codebase, not two that can drift. If the name grates once favourites ship, rename the type across search + favourites in one commit — don't fork it.
- `SearchScope::All` returns `None` from `item_types()` **on purpose**; callers must omit `IncludeItemTypes` entirely rather than sending a union (see the doc comment at [types.rs:316](../../src-tauri/src/repository/types.rs#L316)).
- Ship order that keeps each step demonstrable: §1+§2 (state becomes visible) → §5 hearts (marking becomes possible) → §3+§5 browse surfaces (finding becomes possible) → §4 drain.
- Regenerate `bindings.ts` after the Rust types change; never hand-edit it.
+3 -1
View File
@@ -1,6 +1,8 @@
# Spec: Migrate to libmpv2 and declare the project licence
**Status:** Proposed
**Status:** Partially implemented — the `LICENSE` file has landed (part 2). The
`libmpv``libmpv2` swap (part 1) is **not** done: `src-tauri/Cargo.toml` still
pins the abandoned crate to a git branch.
**Requirements:** UR-003 → IR-003 (revises the MPV integration); no new user-facing behaviour
**UX spec:** n/a
**Supersedes / revises:** dependency and licensing housekeeping identified in [playback-backend-unification.md](playback-backend-unification.md)
-125
View File
@@ -1,125 +0,0 @@
# Spec: Library mosaic (library overview + home shortcuts)
**Status:** Implemented
**Requirements:** UR-075 → DR-174, DR-175 (with UR-067 → DR-117 extended)
**UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites)
## Summary
The library overview and the home "Your Libraries" strip stop being fixed-shape
grids and become a **mosaic**: rows share one height, and each tile is as wide as
its own artwork is. 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 the grid picked. Favourites gain a tile per category,
placed beside the library that category belongs to, alongside the existing
cross-library entry.
## Motivation
Every surface here shows artwork of more than one shape. The grid resolved that
by choosing one shape and cropping to it — and the home strip said so out loud:
> Uniform 16:9 artwork so music (square) and video libraries line up at the same
> height in this mixed row.
Lining them up is right; cropping the covers to do it is not. Holding the
**height** fixed and letting the **width** vary achieves the same alignment with
no crop at all, which is the whole idea of a justified layout.
Favourites had one entry for everything. With per-category tiles, "my favourite
albums" is one tap from the library page rather than a tap plus a tab.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Collection type → favourites category (`movies` → Movies, `livetv` → none) | **Rust** | Jellyfin vocabulary. It changes when Jellyfin renames a collection type, never when this page is redesigned — the same test that put `SearchScope::item_types` in Rust. Shipping it in Svelte would have re-created the leak [scoped-search-boundary.md](scoped-search-boundary.md) exists to document. |
| Which scopes exist at all (`SearchScope`) | **Rust** | Already there; unchanged. |
| Row packing: heights, widths, justification, clamping | Frontend | Geometry of a rendered page. It changes when the layout is redesigned and never when the API does. |
| Assumed artwork shape before the image loads (music = square, else wide) | Frontend | The shape of a *picture*, not a taxonomy — and it is only a starting guess, overruled by the decoded image. |
| Tile labels, order, and showing a category's tile once | Frontend | Pure presentation: wording and placement. |
Borderline row: the "assumed artwork shape" is a per-collection-type default, and
any per-collection-type table deserves suspicion. The tie-breaker: it does not
decide *what a category means* or what is fetched — it seeds a pixel dimension
that the loaded bitmap immediately corrects. Getting it wrong costs one re-pack,
not a wrong result. The scope mapping, which does decide what is fetched, went to
Rust.
## Design
### Wire
`Library` gains one optional field, derived at construction:
```rust
pub struct Library {
pub id: String,
pub name: String,
pub collection_type: String,
pub image_tag: Option<String>,
pub favorites_scope: Option<SearchScope>, // ← new
}
impl SearchScope {
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope>;
}
```
```ts
type Library = { …; favoritesScope?: SearchScope | null }
```
`Library::new` derives it, so the four construction sites (online views, two
offline cache reads, tests) cannot forget it. `None` is *omitted* from the JSON,
not sent as null. No new command, no new event.
### Layout
`src/lib/components/library/mosaic.ts` — pure, no DOM:
- `layoutMosaic(items, { containerWidth, targetHeight, gap })` → rows of tiles
with pixel boxes. Tiles join a row until the height needed to fill the width
drops to the target; the row closes there and is justified to the container
width, the rounding remainder absorbed by its widest tile. The **last row is
not justified** (one leftover tile would inflate into a banner) — it sits at
the target height, left-aligned.
- `layoutMosaicStrip(items, height)` → the same rule as one fixed-height row, for
a horizontally scrolling shelf.
- `mosaicTargetHeight(containerWidth)` → the row height chosen when the caller
doesn't pick one. Bounded so a phone still fits two tiles across and a desktop
doesn't turn each library into a billboard.
- Ratios are clamped to a band (0.52.5) so one panorama can't own a row.
`MosaicGrid.svelte` supplies the two things only the DOM knows — the measured
container width (`bind:clientWidth`) and the artwork's decoded ratio — and
renders the caller's `tile` snippet. `CachedImage` gained an `onNaturalSize`
callback for the second. Measured ratios are committed in one debounced batch
(120 ms): artwork arrives over several hundred milliseconds and re-packing per
image would shuffle the grid under the pointer.
`MosaicTile.svelte` draws one tile at an exact pixel box, with its label written
**over** the bottom of the artwork. A caption below the box would add height the
layout didn't compute, and a caption that wrapped to two lines would break the
row alignment the mosaic exists to provide.
### Composition
`libraryMosaic.ts` (pure, tested) builds the tile list: the cross-library
favourites entry first, then each library followed by its own category tile. A
category appears **once** — two movie libraries share one favourites list, so a
tile each would be two tiles to the same place. A library whose `favoritesScope`
is absent (Live TV, channels, books) gets no tile rather than one opening an
unfiltered list.
Home uses the same tiles in `layout="strip"` but **without** the favourites tiles:
home already carries Favourite Movies / Shows / Music rows of its own, and a
second entry point in the strip above them would be redundant.
## Out of scope
- The item grids inside a library (`/library/movies`, `/library/music/albums`, …).
Those show one item type each, so a uniform grid crops nothing; the mosaic buys
them nothing but reflow.
- Backdrop/collage artwork for libraries with no image of their own.
- Reordering or pinning libraries.
@@ -1,235 +0,0 @@
# Spec: Offline "downloaded only" filtering (issue #10)
**Status:** Implemented
**Scope:** Frontend (connectivity store) + Rust (hybrid repository). No new
commands, no schema changes, no UI additions.
**Requirements:** UR-052 → DR-078, DR-079, DR-080
(see [requirements.md](../requirements.md)).
**Tracking:** issue #10 — *"when offline the filter to show only downloaded
media does not work."*
## Summary
Offline, a library page is supposed to show **only media on the device**, with a
"Show all server media" toggle that additionally reveals the cached server
catalog greyed out (queueable for download on reconnect). In practice the toggle
does not gate the listing — every server item still appears. This spec fixes
that with two independent changes; either one alone leaves the bug visible.
## Background: what already exists
Verified in code. **The feature is built and mostly correct — this is a
two-point repair, not new infrastructure.** Do not rebuild the toggle, the
command, or the SQL gate.
1. **The SQL gate works and is unit-tested.**
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` appends
the synced-catalog `UNION` branch only when `include_catalog_browse()` is
true; with it false, only downloaded/local rows return. Guarded by
`test_get_items_toggle_gates_synced_catalog` (UT-067). **Do not touch the
query.**
2. **The toggle → backend path is wired.** The `showServerCatalog` store and the
`set_show_server_catalog` command
([catalog.rs](../../src-tauri/src/commands/catalog.rs)) drive the process-wide
`INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in
[offlineCatalog.ts](../../src/lib/services/offlineCatalog.ts) computes
`include = connected || showCatalog` and pushes it on every change.
3. **Home-screen queries are already downloads-only.** `get_latest_items`,
`get_resume_items`, `get_recently_played_audio`, `get_resume_movies` all
`INNER JOIN downloads ... status = 'completed'`. They are unaffected — leave
them.
4. **`MediaCard` already greys and queues.**
[MediaCard.svelte](../../src/lib/components/library/MediaCard.svelte) —
`isServerOnly` renders the greyed, inert card with a queue button; the queued
row heals its `stream_url` on reconnect via the offlineCatalog service. Leave
it.
## The two defects
### Defect A — offline is never actually entered (DR-079)
`pushCatalogVisibility` keys off `isConnected`, but
[connectivity.ts](../../src/lib/stores/connectivity.ts) derives:
```ts
isConnected = isOnline && isServerReachable // isOnline = navigator.onLine
```
`navigator.onLine` is documented in that same file as **advisory only** — the
Rust `ConnectivityMonitor` is the source of truth (principle: *reachability from
real traffic*, DR-055). When the server is unreachable but the device link is
up (server down, wrong LAN, VPN dropped), `isOnline` stays true, so `isConnected`
stays true, so `include` stays true, so the backend keeps returning the full
catalog. The user is "offline" in every meaningful sense but the toggle never
gets a chance to gate anything.
This is the primary cause: it explains why the filter looks dead rather than
merely inverted — the gate never closes.
### Defect B — an intentionally empty result falls through to the server (DR-080)
With the gate off and nothing downloaded in a library, offline `get_items`
correctly returns few or zero rows. But
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) treats a cache result as a
hit only `if data.has_content()`. An empty offline result is indistinguishable
from a cache miss, so `HybridRepository::get_items` (and `parallel_race`, used by
~10 other reads) falls through to the server and returns the full server list —
re-defeating the filter even after Defect A is fixed.
## Design
### Fix A: `isConnected` follows backend reachability alone (DR-079)
In [connectivity.ts](../../src/lib/stores/connectivity.ts), redefine the derived
store:
```ts
export const isConnected = derived(
connectivity,
($c) => $c.isServerReachable
);
```
`navigator.onLine` stays wired to what it is good for — a *trigger* for an
immediate recheck (`online`/`offline` listeners already call
`checkServerReachable()`); it must no longer be a *term* in the offline decision.
Leave `isOnline` on the state object and the listeners intact.
Consider whether the optimistic `isServerReachable: true` startup default
([connectivity.ts](../../src/lib/stores/connectivity.ts)) should hold until the
first real check resolves. Keep it — flipping the app to "offline" on launch is a
worse regression than a brief full-catalog flash before the first probe. Note the
choice in a comment.
**Blast radius — this is the reason this is a spec, not a patch.** `isConnected`
is consumed beyond this feature (banners, `MediaCard`, mini-player gating,
anything importing it). Enumerate consumers first:
```
grep -rn "isConnected" src/ | grep -v node_modules
```
For each, confirm "server unreachable" (not "device link down") is the correct
trigger. It almost always is — that is the whole point of the reachability model
— but verify rather than assume, and call out anything that genuinely wanted the
device link in the PR description.
### Fix B: an empty offline result is authoritative when the gate is off (DR-080)
The backend must distinguish "cache is cold, go ask the server" from "user asked
for downloads only and there are none here." The gate flag already encodes intent
— reuse it.
Add a getter beside the existing setter in
[offline.rs](../../src-tauri/src/repository/offline.rs):
```rust
pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }
```
In [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) `get_items`: when
`!include_catalog_browse()`, treat the offline result as authoritative and return
it **as-is even when empty** — do not spawn/await the server fallback for this
call. When the flag is on (online fast-path, or offline with the toggle on),
behaviour is unchanged: empty cache still falls through to the server.
Keep it surgical:
- Scope the change to `get_items`. The gate is a `get_items` concept; do not
thread it into `parallel_race` or the other readers, which have no catalog
gate and legitimately want the server on an empty cache.
- Preserve the online path exactly: with the flag on (its default, and always so
while reachable) the method behaves as it does today, including the background
cache refresh on a hit.
- The flag is process-global `Relaxed`; it is set from the frontend before the
query. That ordering already holds for the SQL gate — no new synchronization.
### Why both
Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with
downloads present the list still gets padded by the server fallback whenever a
library's cache is thin. B alone: the gate never closes because `isConnected`
never goes false on a live link. Ship them together.
## Out of scope
- The SQL gate, the toggle, the command, `INCLUDE_CATALOG_BROWSE` — all correct.
- `MediaCard` greying / queue-on-reconnect — correct.
- Home-screen and resume queries — already downloads-only.
- The Rust `ConnectivityMonitor` reachability logic itself — unchanged; this
spec only stops the *frontend* from diluting its verdict with `navigator.onLine`.
- Any new IPC command, DB column, or settings entry.
- Making the "Show all server media" toggle reachable from Settings (that is a
UX-placement question, tracked separately under UR-051's toggle note).
## Acceptance criteria
- [~] With the server unreachable on a live device link, a library page lists
only downloaded media when the toggle is off (IT-016 — pending e2e; unit
coverage via UT-069 + gate tests).
- [x] Turning the toggle on reveals the greyed-out cached catalog; turning it off
hides it again — without leaving/re-entering the page (SQL gate + toggle
wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
- [x] A library with downloads and a thin cache does not get padded with
non-downloaded server items when offline with the toggle off (Defect B —
UT-070: gate off + empty offline result returned as-is, server not queried).
- [x] `isConnected` is false whenever the server is unreachable, regardless of
`navigator.onLine`; true for a reachable server even if the browser reports
offline (UT-069).
- [x] Every existing `isConnected` consumer still behaves correctly (banner in
`+layout.svelte`, `MediaCard`, `favorites.ts` server-write skip — all want
"server unreachable", which is the new semantics; `CastButton`'s local
`isConnected` is unrelated). Full frontend suite (616 tests) green.
- [x] Online behaviour is unchanged: with the flag on (its default, always so
while reachable) `get_items` keeps the offline fast-path and background
refresh (UT-067 + gate-on fall-through test).
- [~] A download queued from a greyed offline card resolves and starts on
reconnect (IT-017 — regression check, no code change; offlineCatalog
resume path untouched).
- [x] `bun run check`, `bun run test`, and `bun run test:rust` pass;
`cd src-tauri && cargo fmt && cargo clippy` clean (no new warnings in the
touched files).
## Testing
Rust ([offline.rs](../../src-tauri/src/repository/offline.rs) /
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) test modules):
- **UT-070** — hybrid `get_items` with the gate off returns an empty offline
result as-is and does **not** query the server. Assert via a mock online repo
whose `get_items` bumps a call counter that must stay at zero.
- Gate on + empty cache still falls through to the server (guard the online path).
- UT-067 (`test_get_items_toggle_gates_synced_catalog`) must still pass untouched.
Frontend (vitest, `src/lib/**/*.test.ts`):
- **UT-069**`isConnected` follows `isServerReachable` alone: false when
unreachable with `navigator.onLine === true`; true when reachable with
`navigator.onLine === false`.
- **UT-068**`pushCatalogVisibility` resolves `serverReachable || showCatalog`
and pushes to the backend on a change of either input (extend the existing
offlineCatalog tests).
Integration (IT-016, IT-017) are documented as pending in
[requirements.md](../requirements.md); wire them if the e2e harness can simulate
an unreachable-server-on-live-link state, otherwise leave them pending with a note.
New/changed requirement code keeps its `TRACES:` comments — see
[CLAUDE.md](../../CLAUDE.md). The affected files already carry tags:
`connectivity.ts` (`… | DR-079`), `hybrid.rs` (`… | DR-080`), `offline.rs`
(`… | DR-078`). Update the getter's tag when you expose it.
## Notes for the implementer
- Read [docs/architecture/07-connectivity.md](../architecture/07-connectivity.md)
before Fix A — it is the canonical statement of the reachability model this fix
restores fidelity to.
- Fix B relies on the frontend having pushed the flag before the query runs; that
ordering already holds for the SQL gate today. No new locking.
- Another session is active in this repo (WiFi-only downloads, account menu
landed alongside this work). Check `git diff` before "repairing" unexpected
changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to
other new rows.
+6 -3
View File
@@ -3,7 +3,10 @@
**Status:** Accepted (analysis; no code changes)
**Requirements:** IR-004, UR-031, UR-032, UR-033 — revises the "Platform Playback Backend Parity" issue in requirements.md
**UX spec:** n/a
**Supersedes / revises:** informs [android-native-video-spike.md](android-native-video-spike.md), [android-audio-settings-parity.md](android-audio-settings-parity.md), [windows-native-audio-backend.md](windows-native-audio-backend.md)
**Supersedes / revises:** informed the Android native-video and audio-parity
work (both since shipped — see
[05-platform-backends.md](../architecture/05-platform-backends.md)) and
[windows-native-audio-backend.md](windows-native-audio-backend.md), still open
## Summary
@@ -167,9 +170,9 @@ rewrite, not the bindings.
2. **Android native video is worth a bounded spike anyway** — not for
unification, but because ExoPlayer's `SurfaceView` path already exists and
would restore hardware decode plus ASS/SSA subtitles. See
[android-native-video-spike.md](android-native-video-spike.md).
[05-platform-backends.md](../architecture/05-platform-backends.md#native-video-compositing-android).
3. **Audio parity is the real gap** and is achievable without touching any of the
above. See [android-audio-settings-parity.md](android-audio-settings-parity.md)
above. See [05-platform-backends.md](../architecture/05-platform-backends.md)
and [windows-native-audio-backend.md](windows-native-audio-backend.md).
4. **Migrate the dead libmpv pin** regardless of any of this. See
[libmpv2-migration.md](libmpv2-migration.md).
-198
View File
@@ -1,198 +0,0 @@
# Spec: Playback documentation corrections
**Status:** Proposed
**Requirements:** revises the status of DR-034; corrects the parity matrix in [requirements.md](../requirements.md)
**UX spec:** n/a
**Supersedes / revises:** implements the "Corrections to existing docs" section of [playback-backend-unification.md](playback-backend-unification.md)
## Summary
Fix four factual errors in the requirements doc and the player source comments,
all found while investigating backend unification. Each claims something the code
does not do. Small change, but they are actively misleading: two of them assert a
feature is implemented when it is implemented nowhere, and one cites an upstream
blocker that no longer exists.
Documentation and comments only — no behaviour change.
## Motivation
These errors compound. DR-034 reads "Done (Linux only)", so a future session
planning Android parity would reasonably assume crossfade exists on Linux and
only needs porting — when in fact it is unimplemented everywhere *and*
architecturally blocked on the engine it supposedly runs on. Likewise the
tauri#10152 comment has been discouraging work on Android native video since the
upstream capability shipped in September 2024.
## The corrections
### 1. DR-034 status is wrong
`requirements.md` line ~196:
```
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
```
The code:
```rust
// src-tauri/src/player/mpv_backend.rs, in set_audio_settings
// TODO: Implement crossfade via MPV audio filters if needed
```
That is the entire crossfade implementation. `crossfade_duration` is plumbed
through `AudioSettings` and clamped to 012s, but no backend ever acts on it.
**Change to:** `Not implemented (blocked on MPV — see playback-backend-unification.md)`
Worth stating *why* in the requirements entry, because it is not a scheduling
gap: mpv's audio chain is single-stream, and FFmpeg's `acrossfade` is an `N→A`
filter needing two inputs. Real crossfade requires two libmpv instances with
manually ramped volumes. Upstream declined the feature (mpv#4512).
### 1b. UR-031 status is wrong for the same reason
`requirements.md` line ~44:
```
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
```
Same error one level up: the *user* requirement is also marked done. Since no
backend implements crossfade, UR-031 is not satisfied on any platform.
**Change to:** `Not implemented (blocked — see DR-034)`
Note line ~517 of the same file (`UR-031 (Crossfade), UR-032 (Gapless),
UR-033 (Normalization) only work on Linux`) inherits the error — crossfade works
nowhere, so it should read UR-032/UR-033 only.
### 2. Parity matrix crossfade row is wrong
```
| Crossfade | ✅ | ❌ | Gap |
```
**Change to** `| Crossfade | ❌ | ❌ | Not implemented |` — it is not a
platform-parity gap, it is an unbuilt feature.
### 3. Parity matrix is missing the equalizer
The matrix lists crossfade, gapless, and normalization but omits the EQ, which
has the same Linux-only shape and the same root cause (`ExoPlayerBackend` not
overriding `set_audio_settings`). `build_af_filter` and `eq_filter_entries` exist
only in `mpv_backend.rs`; there is no equalizer code in the Android tree.
**Add:** `| Equalizer (10-band) | ✅ | ❌ | Gap |`
### 4. `nativeAdapter.ts` cites a stale blocker
`src/lib/player/adapters/nativeAdapter.ts:11-14` states native Android video is
blocked upstream by tauri#10152 (transparent webview / SurfaceView compositing).
tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature request*
that `WebviewWindowBuilder::transparent` was desktop-only — not a report that
compositing is broken. The capability shipped in tauri commit `27d01834`
(2024-09-02), which moved `transparent()` into the cross-platform impl block with
only the tao call `#[cfg(desktop)]`-fenced. It landed as a clippy cleanup, so the
issue was never closed. Separately, the black/white-screen bug (tauri#8381,
tauri#9408) was a broken JNI signature for `setBackgroundColor`, fixed in wry
0.39.4; we ship wry 0.55.x.
**Change to:** a comment stating the adapter is currently unreachable because
`createAdapter` hardcodes the HTML5 kind, that transparency is no longer an
upstream blocker, and that
[android-native-video-spike.md](android-native-video-spike.md) tracks whether
SurfaceView compositing actually works. Be explicit that *nobody has
demonstrated* SurfaceView-behind-WebView on Tauri Android — nothing upstream
blocks it, and nothing upstream proves it.
### 5. Platform capability is signalled three incompatible ways
Not a doc error — a real inconsistency found during the same investigation, worth
recording here even though fixing it needs its own change.
Which backend a platform uses is currently expressed three ways:
1. Rust `#[cfg]` gates in `player/mod.rs` and `create_player_backend` — the truth.
2. The `useHtml5Element` / `VideoBackend` value from `get_player_status` — which
the frontend discards (see the spike spec).
3. **Frontend user-agent sniffing** in `src/lib/services/webviewAudio.ts:30-41`:
```ts
const ua = navigator.userAgent.toLowerCase();
const isAndroid = ua.includes("android");
const isLinux = ua.includes("linux") && !isAndroid;
return !isAndroid && !isLinux;
```
The comment says it is "matching the Rust cfg gate" — i.e. the frontend
re-derives a backend decision from the user-agent string and hopes it stays in
sync. That is the frontend deciding *which backend exists*, which is domain
knowledge, not presentation. It also breaks silently the moment a new target is
added or a webview's UA changes.
**This is a boundary leak of the same family the spec-review checklist exists to
catch**, even though `check:boundary`'s tripwire (item-type arrays) does not
match it. Rust already computes the answer; the frontend should consume it.
Not fixed by this spec — it is behavioural, not documentation. It should be
folded into the spike spec's factory rework, where the same
"consume Rust's decision instead of re-deriving it" change is already in scope.
### Also worth fixing while here
`requirements.md` IR-004 reads "In Progress (basic playback works, audio settings
missing)". That stays accurate until
[android-audio-settings-parity.md](android-audio-settings-parity.md) lands, but
the "Future Fix" list in the parity issue proposes
`ConcatenatingMediaSource` for crossfade — **deprecated in current Media3**. Drop
that suggestion; the modern approach is a custom `AudioProcessor`.
## Layer assignment
No logic. Documentation and comments only.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| — | — | No logic introduced or moved by this spec. |
## Out of scope
- Implementing crossfade. This spec only stops claiming it exists.
- Implementing Android audio settings — see the parity spec.
- Running the Android video spike — see that spec.
- Rewriting the architecture docs. `docs/architecture/05-platform-backends.md`
should be re-read for the same class of error, but that is a larger pass.
## Acceptance criteria
- [ ] DR-034 status corrected, with the blocking reason stated.
- [ ] UR-031 status corrected (line ~44), and the "only work on Linux" line (~517) no longer lists crossfade.
- [ ] Parity matrix: crossfade ❌/❌; equalizer row added.
- [ ] `ConcatenatingMediaSource` suggestion removed from the "Future Fix" list.
- [ ] `nativeAdapter.ts` comment corrected and pointing at the spike spec.
- [ ] `bun run check` and `bun run test` pass (a comment change still touches TS).
- [ ] `bun run traces:markdown` re-run if requirement text changed.
No Rust changes, so the `cargo` gates do not apply.
## Testing
None beyond the standard gates — no behaviour changes. Confirm
`bun run traces:markdown` regenerates cleanly, since DR-034's row is referenced
by the traceability matrix.
## TRACES
No code implementing requirements changes; no TRACES comments to add or update.
The DR-034 row in `docs/traceability.md` will regenerate with the corrected text.
## Notes for the implementer
- Do **not** silently delete DR-034. The requirement (UR-031 crossfade) is still
wanted; it is the *status* that is wrong. Keeping the row with an honest status
and a reason is the point.
- A parallel Claude session may be active — `git diff` before "repairing"
unexpected changes.
+7 -2
View File
@@ -1,7 +1,12 @@
# Spec: Enforce the unified player boundary
**Status:** Proposed
**Requirements:** DR-095 (new); relates to UR-005 and the unified-player-boundary
**Status:** Proposed — not started. The count below has not improved: ~60
`commands.player*` call sites still live outside `src/lib/player/`, and no lint
rule enforces the boundary. This remains the one stated design principle with no
automated check.
**Requirements:** ⚠️ the suggested id **DR-095 has since been allocated** to seek
clamping — allocate a fresh id (DR-215 or later) on implementation. Relates to
UR-005 and the unified-player-boundary
principle in CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md)
**UX spec:** n/a — refactor, no user-visible change.
**Supersedes / revises:** n/a
+15 -3
View File
@@ -1,10 +1,22 @@
# Spec: Two-path media — selectable playback bitrate, independent whole-file download
**Status:** Proposed
**Status:** Partially implemented. Landed: the cache/download unification
(DR-126, DR-127 — a cache entry *is* a `downloads` row with a shorter life, and
eviction only reclaims the temporary tier), local playback of downloaded media
(DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138).
DR-123 is in progress. Still open: the **player quality selector** and the
read-through capture itself — DR-121, DR-122, DR-124, DR-125. The separate
settings-level bitrate cap (DR-162, shipped —
[01-rust-backend.md](../architecture/01-rust-backend.md#streaming-quality-ladder))
covers a *settings-level*
ceiling (DR-162), which serves part of UR-070 but is not the per-playback
selector specified here.
**Requirements:** UR-070, UR-071 → DR-121, DR-122, DR-123, DR-124, DR-125; IR-032
**UX spec:** player quality selector — needs a `ux-flows.md` section before build
**Related:** [catalog-index-search.md](catalog-index-search.md),
[downloads-as-offline-library.md](downloads-as-offline-library.md)
**Related:** the locally-indexed search and downloaded-browse work, both
shipped — see
[03-data-flow.md](../architecture/03-data-flow.md) and
[06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md)
## Summary
-161
View File
@@ -1,161 +0,0 @@
# Spec: Remove the broken `check-req-coverage.sh`
**Status:** Implemented
**Requirements:** supports DR-093 (see [traceability-gate-repair.md](traceability-gate-repair.md))
**UX spec:** n/a — developer tooling.
**Supersedes / revises:** n/a
## Summary
`scripts/check-req-coverage.sh` is broken, orphaned, and actively misleading: it
reports `Total Requirements: 1`, zeros in every category, and then prints
**"✨ All requirements have implementations!"**. Nothing references it — not CI,
not `package.json`, not the docs. This spec deletes it, with a narrowly-scoped
alternative (repair it) documented and rejected below.
## Motivation
Running it today produces:
```
Category Breakdown:
UR: 0 requirements
IR: 0 requirements
DR: 0 requirements
JA: 0 requirements
Summary:
Total Requirements: 1
✅ Fully Implemented: 0 (0%)
✨ All requirements have implementations!
```
Every number is wrong (the real totals are UR 61, IR 29, DR 89, JA 32), and the
concluding message is the *opposite* of a warning — a developer running this to
sanity-check coverage is told everything is fine.
This is worse than having no script. It is a trap, and it sits in `scripts/`
next to tools that do work, with nothing marking it as dead.
Verification that it is genuinely orphaned:
```console
$ grep -rn "check-req-coverage" . --include='*.yml' --include='*.json' \
--include='*.sh' --include='*.md' | grep -v node_modules
(no output)
```
## Layer assignment
Developer tooling only; no application logic and nothing crosses the IPC
boundary.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Requirement-coverage reporting | Build tooling — `extract-traces.ts` | One tool should own coverage analysis. A second, divergent implementation is how the two answers ("1 requirement" vs "211") came to disagree unnoticed. |
## Design
**Delete `scripts/check-req-coverage.sh`.**
Coverage reporting is owned by [scripts/extract-traces.ts](../../scripts/extract-traces.ts),
which is correct, is what CI runs, and gains a first-class local coverage mode
in [traceability-gate-repair.md](traceability-gate-repair.md):
```bash
bun run traces:coverage # the supported way to check coverage locally
```
Then check the sibling scripts for the same rot. `scripts/` also contains
`check-test-coverage.sh` and `find-req-implementations.sh`, neither of which is
referenced from `package.json`. An unreferenced script is never run and so rots
silently — that is the actual failure mode being fixed here, and fixing only the
one instance found by audit leaves the others to be rediscovered later.
### Findings (investigation, 2026-07)
All three scripts turned out to share a **single root cause**, and all three are
deleted:
| Script | Defect |
|---|---|
| `check-req-coverage.sh` | Reads `README.md`, which has held **zero** requirement rows since they moved to `docs/requirements.md``total_reqs=1`, every category 0, "✨ All requirements have implementations!" Also greps `src-tauri/` unscoped. |
| `check-test-coverage.sh` | Greps `src-tauri/` unscoped — including **40 GB** of `target/` build artifacts. Hangs indefinitely; produces no output at all. |
| `find-req-implementations.sh` | Same unscoped `src-tauri/` grep. Same hang. |
So none of them were subtly wrong — two could never terminate, and the third
inverted its own conclusion.
They were nonetheless *salvageable*: scoping the greps to `src-tauri/src` and
repointing at `docs/requirements.md` would be a few lines, and the `@req:` /
`@req-test:` tags they read are still present in the tree (**146** and **76**
occurrences).
**Decision: delete all three anyway.** The tags are an undocumented parallel
convention — `@req:` appears in no doc, and CLAUDE.md describes only `TRACES:`.
Repairing the scripts would re-establish a second traceability system to keep in
sync with the first, which is the same two-sources-of-truth condition that let
"1 requirement" and "211 requirements" coexist unnoticed. `TRACES:` plus the
repaired coverage engine ([traceability-gate-repair.md](traceability-gate-repair.md))
already cover this ground.
The existing `@req:` / `@req-test:` comments are left in place: they are
harmless as prose, several encode genuinely useful test intent, and stripping
222 comments across the tree is a large diff with no functional gain. They are
simply no longer read by any tool.
### Alternative considered: repair rather than delete
Rejected. The script's output format duplicates what `traces:markdown` already
generates, it has no tests, no caller, and no documented purpose distinct from
`extract-traces.ts`. Repairing it recreates the two-sources-of-truth condition
that produced the contradiction. If a shell-based coverage check is ever wanted,
it should shell out to `traces:json` and `jq` rather than re-parse
`requirements.md` independently.
## Out of scope
- The CI workflow denominators — [traceability-gate-repair.md](traceability-gate-repair.md).
- Any change to `extract-traces.ts`'s output (that spec owns it).
- Auditing scripts that *are* referenced from `package.json` — they run
regularly and would fail visibly.
## Acceptance criteria
- [ ] `scripts/check-req-coverage.sh` no longer exists.
- [ ] `grep -rn "check-req-coverage" .` (excluding `node_modules` and this spec)
returns nothing — no dangling reference in CI, docs, or `package.json`.
- [ ] `scripts/check-test-coverage.sh` and `find-req-implementations.sh` have each
been run and either wired into `package.json` or deleted; the decision and
reason are recorded in `scripts/README.md`. **Outcome: all three deleted —
see Findings.**
- [ ] `scripts/README.md` documents `bun run traces:coverage` as the supported
way to check requirement coverage locally.
- [ ] `bun run test:all` passes (confirms nothing invoked the deleted script).
- [ ] `bun run check` and `bun run test` pass.
- [ ] `bun run check:boundary` passes.
## Testing
No unit tests — this is a deletion. Verification is the grep in the acceptance
criteria plus a green `bun run test:all`, which exercises the script paths that
actually run.
## TRACES
No new requirement. The deletion is covered by **DR-093**
([traceability-gate-repair.md](traceability-gate-repair.md)), which establishes
`extract-traces.ts` as the single owner of coverage reporting. Note the removal
in that DR's text when both land.
## Notes for the implementer
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes (CLAUDE.md §Gotchas).
- Land this **after** or alongside [traceability-gate-repair.md](traceability-gate-repair.md),
so `bun run traces:coverage` exists before the broken script is removed and
developers are never left without a coverage command.
- Check `docs/traceability-ci.md` and `docs/traces-quick-ref.md` for prose
references to the deleted script; the grep above covers `.md`, but read the
surrounding sentence rather than deleting the line mechanically.
@@ -49,7 +49,7 @@ audit:
2. **The tripwire cannot see it.** `bun run check:boundary` passes — it greps for
a multi-type array literal *at the query site*, and this one is assigned to a
named const and dereferenced elsewhere. Broadening the tripwire is specified
separately in [boundary-tripwire-hardening.md](boundary-tripwire-hardening.md);
separately by the tripwire hardening (DR-094, shipped);
note that hardening it **without** landing this fix would turn `master` red.
3. **The spec's own acceptance criterion fails today.** "Adding a hypothetical
new type to a scope requires editing only Rust" — adding a type to the Music
@@ -96,7 +96,7 @@ the `search-event` dual-payload hazard in the middle. Split it:
`SCOPE_ITEM_TYPES` and `scopeItemTypes()`. Result grouping stays as it is.
After Stage 1 the actual boundary violation is gone and
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md) can land safely.
the hardened tripwire (DR-094) can pass.
**Stage 2 — result side.** `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
Rust bucketing, both payloads converted, `composeSearchGroups()` shrunk,
@@ -160,7 +160,7 @@ hand-edit it.
- Chip UX, scope persistence, group-order persistence — unchanged.
- The two lesser type-set sites in `DownloadedBrowse.svelte` and
`GenericMediaListPage.svelte`, handled in
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md).
the hardened tripwire (DR-094, see `scripts/check-frontend-boundary.sh`).
- Broadening the tripwire itself — same sibling spec.
## Acceptance criteria
@@ -244,7 +244,7 @@ take `@req-test: UT-089` onward (next free UT is **UT-089**).
- **Read [scoped-search-boundary.md](scoped-search-boundary.md) first.** This
spec is deliberately thin on design; that one is the authority.
- Sequence with the sibling specs: **Stage 1 here → then
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md)**. Hardening
the hardened tripwire (DR-094)**. Hardening
the tripwire first turns `master` red on a known-unfixed violation.
- `git log --oneline -- docs/specs/scoped-search-boundary.md` is worth a look
before starting — understanding why the fix stalled may surface a constraint
+8 -1
View File
@@ -1,6 +1,13 @@
# Spec: Move search scope taxonomy behind the Rust boundary
**Status:** Proposed
**Status:** Design authority — **Stage 1 implemented**, Stage 2 outstanding.
The scope→item-type mapping now lives in Rust (`SearchScope::item_types()` in
`repository/types.rs`, DR-063 … DR-067). The *result-side* grouping table
(`GROUP_ITEM_TYPES` in `src/lib/utils/searchScope.ts`) is still in the
frontend, and `check:boundary` does not match its shape. Delivery status and
the remaining work live in
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md);
this spec remains the design authority.
**Scope:** Rust + Frontend. **Revises a decision in
[scoped-search.md](scoped-search.md).**
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
@@ -1,239 +0,0 @@
# Spec: series navigation lands on the current episode
**Status:** Accepted
**Requirements:** UR-062 → DR-101, DR-102, DR-103, DR-104, DR-107; UR-063 → DR-105; UR-064 → DR-106
**UX spec:** [ux-flows.md §5B.1](../ux-flows.md), [§5B.2](../ux-flows.md), [§5B.4](../ux-flows.md), [§5B.5](../ux-flows.md)
## Summary
Opening a TV series lands you where you actually are in it: the seasons render
as collapsible sections with **only the current season expanded**, the current
episode highlighted and scrolled into view, and the hero button opens that
episode's focus view (labelled `Resume S2E4` / `Play S1E1`) instead of the first
season. A season stops being a destination of its own — every route that used to
land on `/library/<seasonId>` now lands on the series with that season in view,
so the full cross-season episode list is always reachable in one place. Watch
history can be erased per series and per season. Separately, each video library
collapses from three routes (landing, all-titles, genres) to one route with
in-page tabs.
## Motivation
Two problems, reported together.
**1. Series navigation dead-ends at season 1.** The series detail page's Play
button resolved its target as `$libraryItems[0]` — the first *season* child,
ordered by `SortName` — and navigated to `/player/<seasonId>`. The player route
classifies `season` as a container kind and bounces it back to
`/library/<seasonId>`. So Play on a series played nothing; it navigated you to
the season-1 page. Opening a series without pressing Play rendered every season
stacked but scrolled to the top, so a viewer 4 seasons deep had to scroll past
everything they had already watched.
The backend has been able to answer "where is this viewer in this show" the
whole time: `repository_get_next_up_episodes(handle, series_id, limit)` is wired
end-to-end to `/Shows/NextUp?SeriesId=`. **Both frontend call sites pass
`undefined` for `series_id`** — the per-series capability existed and was never
used.
**2. Seasons are an accidental page.** There is no season route. `/library/
<seasonId>` falls through the detail page's `kind` chain into the generic
"Contents" poster grid, which contradicts ux-flows §5A.2 (episodes in a season
must render as a row list). Worse, clicking an episode from that grid opens a
*bare* Episode page, which §5B.1 explicitly forbids. Four call sites fed it: the
episode breadcrumb, `handleItemClick case "season"`, the TV landing page, and
the broken Play button above.
**3. Too many video library routes.** Seven routes serve two media types, and the
naming does not even agree with itself: `/library/tv` + `/library/tv/shows` +
`/library/shows/genres` versus `/library/movies` + `/library/movies/all` +
`/library/movies/genres`. The genre routes do not share a prefix, which
`searchScope.ts:45` carries an apologetic comment about. The two "all" pages are
27-line config wrappers over the same `GenericMediaListPage`.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which episode is "current" for a series (resume → next-up → first unwatched → first) | **Rust** | Domain policy over Jellyfin user-data semantics. It changes if Jellyfin changes what `UserData.is_played` means, if Next Up's rules change, or if we decide a 98%-watched episode counts as finished. It does not change if the UI is redesigned. |
| Gathering a series' episodes across all seasons in broadcast order | **Rust** | Jellyfin's shape (episodes hang off season folders, except when a series is flat and they hang off the series) is provider vocabulary. The frontend already reimplemented this fan-out *and* its flat-series fallback; that is domain knowledge that leaked. |
| Ordering rule for "series order" (season index, then episode index, specials last) | **Rust** | Season 0 = specials is a Jellyfin convention, not a layout choice. |
| Scrolling the current episode into view; the highlight ring and `Up next` badge | Frontend | Pure presentation. Changes only if the page is redesigned. |
| Which seasons start expanded | Frontend | Consumes the backend's answer (`currentEpisode`) to decide layout. The *decision* about where the viewer is stays in Rust; only "and therefore this section opens" is here. |
| What "erase watch history" means (played flag + resume position, recursive over a container) | **Rust** | Jellyfin user-data semantics. Changes if the server's mark-unplayed behaviour changes; unaffected by any UI redesign. |
| Refusing to clear history while offline | **Rust** | A data-integrity rule, not a disabled button: history cleared only locally would be undone by the next sync. The UI disabling the button is a courtesy on top. |
| Play button *label* (`Resume S2E4` vs `Play S1E1`) | Frontend | Rendering a decision the backend already made (the returned episode plus its resume position). |
| Which route Play navigates to | Frontend | Navigation is presentation. |
| Redirecting `/library/<seasonId>` to the series anchor | Frontend | Route topology. |
| Episode-strip window size (3 before / 6 after) | Frontend | A layout constant; §5B.2 owns it. |
| Library page tabs and the `?view=` param | Frontend | View preference and route topology. |
Borderline row — **the strip's cross-season *ordering*** is Rust (it is series
order, above), but the *window* taken from that ordered list is frontend. The
tie-breaker: the list handed to the frontend is already correct and complete;
choosing how much of it fits on screen is layout.
## Design
### Rust: the current-episode policy
Two new pieces, split so the policy is unit-testable without a repository.
**Pure policy** — `src-tauri/src/repository/series_progress.rs`:
```rust
/// Series order: season index asc, then episode index asc. Specials (season 0)
/// sort after every numbered season rather than before season 1.
pub fn sort_series_order(episodes: &mut [MediaItem]);
/// The episode a viewer should land on, given everything already fetched.
/// Order: in-progress episode → Next Up → first unwatched → first episode.
pub fn pick_current_episode(
episodes: &[MediaItem], // series order
next_up: &[MediaItem],
resume: &[MediaItem],
) -> Option<MediaItem>;
```
Why that order:
- **In-progress wins** because a partially-watched episode is literally where
the viewer stopped; Next Up would skip past it. Ties break toward the earliest
in series order, so a viewer who dipped into a later episode still resumes the
one they are actually working through.
- **Next Up second** because it is the server's own answer, and it accounts for
history we do not cache.
- **First unwatched third** — the offline repository returns an empty vec for
Next Up (`offline.rs:1247`), so without this fallback the whole feature would
be online-only. This is the offline path, not dead code.
- **First episode last** so a never-watched series lands on S1E1 rather than
nothing.
A `resume`/`next_up` entry that is not among `episodes` is still honoured — it
comes from the same server and may carry an id the season fan-out missed — but
it must belong to this series.
**Fetch + command** — `src-tauri/src/commands/repository.rs`:
```rust
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_episodes(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Vec<MediaItem>, String>
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_current_episode(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Option<MediaItem>, String>
```
Frontend params are camelCase (`{ handle, seriesId }`) per the Tauri v2 rule.
`repository_get_series_episodes` performs the fan-out the frontend used to do:
`get_items(series_id)` → seasons → `get_items(season_id)` per season, plus the
flat-series fallback (a series whose children are episodes, not seasons), then
`sort_series_order`. `repository_get_series_current_episode` calls it, adds
`get_next_up_episodes(Some(series_id), Some(1))` and
`get_resume_items(Some(series_id), Some(10))`, and applies `pick_current_episode`.
Both tolerate a failing Next Up (offline) by treating it as empty rather than
failing the whole call.
### Frontend: series page
- `loadItem()` calls `repositoryGetSeriesEpisodes` once instead of fanning out
over seasons itself, and `repositoryGetSeriesCurrentEpisode` for the anchor.
Season *headers* still come from `get_items(seriesId)`; the page groups the
returned episodes under them by `parentIndexNumber`.
- No `?episode=` param → series view, `SeasonSection` receives
`currentEpisodeId`, `EpisodeRow` renders the highlight and scrolls itself into
view (`scrollIntoView({ block: "center" })`, the existing `focused` mechanism,
now distinguishing *focused* from *current*).
- Seasons are collapsible and **only the current season is expanded**
(`initialExpandedSeasons`). Without this a ten-season show renders every
episode of every season at once and buries the one the viewer came for. A
collapsed season still shows its episode count and watched count, so progress
is legible without expanding. Toggle state is local and not persisted — it is
a reading position, not a preference.
- Hero Play → `goto(/library/<seriesId>?episode=<currentId>)`, i.e. the Episode
Focus View, where an explicit Play/Resume starts playback. This follows
ux-flows §5B.5's "tap opens, never commits" rule: Play on a *container* is
navigation; Play on a *leaf* (the focus view, a movie) commits.
- Clicking an episode in a season section → `?episode=` swap, not
`/player/<id>`. §5B.1.
### Frontend: seasons are not a destination
`/library/<seasonId>` resolves the season's `seriesId` and redirects to
`/library/<seriesId>#season-<indexNumber>`; `SeasonSection` renders that anchor
id. A season with no `seriesId` (deep link into a stale cache) keeps the old
generic rendering as a fallback so the user is never stranded. Inbound links
updated: episode breadcrumb, `handleItemClick case "season"`, the TV landing
page's `case "Season"`, and `DownloadedBrowse`.
### Erasing watch history
```rust
#[tauri::command]
#[specta::specta]
pub async fn repository_clear_watch_history(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String>
```
`OnlineRepository` maps it to `DELETE /Users/{userId}/PlayedItems/{itemId}`
Jellyfin's mark-unplayed, which clears the played flag *and* zeroes the resume
position, and which the server applies recursively to a folder. One call
therefore handles a whole series or a single season; no per-episode fan-out.
`OfflineRepository` returns `RepoError::Offline` rather than clearing locally,
because divergent local history is undone by the next sync.
`ClearHistoryButton` is shared by the series hero (`scope="series"`) and each
`SeasonSection` header (`scope="season"`). It confirms first — there is no undo —
disables itself while the server is unreachable, and reloads the page on success
so the recomputed current episode is what the viewer sees. Clearing a whole
series therefore returns it to S1E1, which is the same path a never-watched
series takes through `pick_current_episode`.
### Frontend: one route per video library
`/library/tv` and `/library/movies` each gain `?view=browse|all|genres` tabs,
rendering the existing `GenericMediaListPage` / `GenericGenreBrowser` components
inline. `?view=` is omitted for `browse` (the default) to keep URLs clean —
the same convention `searchRouteUrl` uses for the `all` scope.
The four legacy routes become redirect-only `+page.ts` loads:
| Legacy | Redirects to |
|--------|--------------|
| `/library/tv/shows` | `/library/tv?view=all` |
| `/library/shows/genres` | `/library/tv?view=genres` |
| `/library/movies/all` | `/library/movies?view=all` |
| `/library/movies/genres` | `/library/movies?view=genres` |
They are kept (rather than deleted) because `GenreTags` builds links to them and
users may have them in history. `resolveSearchScope` keeps its `/library/shows`
branch for the same reason.
The "Browse" tile grid at the bottom of both landing pages is removed — the tabs
replace it, and the tiles were a second navigation affordance to the same two
destinations the carousels' "Show all" links already reach.
## Out of scope
- **Cross-season autoplay.** `player/mod.rs:fetch_next_episode_for_item` is
still season-bounded, so autoplay stops at a season boundary. 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) and belongs in its own change.
- **Music library routes.** `/library/music/*` has five sub-routes with the same
shape; the same consolidation applies but is not done here.
- **Marking a series' progress** (mark-watched / mark-unwatched from the series
page).
-146
View File
@@ -1,146 +0,0 @@
# Spec: streaming bitrate cap
**Status:** Implemented
**Requirements:** UR-074 → DR-162 (partially serves UR-070)
**UX spec:** n/a — the controls reuse existing patterns (Settings → Video Playback, and the player's track menus).
## Summary
The viewer picks a bandwidth ceiling for video — from `Original` (no client
limit) down to 720 kbps — and every video the app opens is fetched within it,
live TV included. The choice is made once in Settings and persists across
restarts; a single video can be moved to another ceiling from the player, which
re-opens the stream and resumes where it was without changing the saved default.
## Motivation
Every video URL the app built carried a fixed allowance —
`MaxStreamingBitrate=20000000`, `VideoBitrate=18000000` — the `PlaybackInfo`
negotiation asked for 20 Mbps, and the device profile advertised
`999999999`, which invites the server to direct-play a source of any size. On a
metered or slow connection there was no lever at all short of not watching.
The related UR-070 asks for something adjacent but different: a list of the
renditions *the server can produce for this item*. That needs per-item
`MediaSources` negotiation and is still proposed. What was missing first is
cruder and more valuable: a device-wide budget that holds regardless of what is
playing.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| What a quality step *is* — total ceiling, audio share, resolution cap | Rust | Jellyfin encoding vocabulary. It changes if Jellyfin's transcoder or parameter binding changes, not if the UI is redesigned. Exactly the shape of `EqPreset::gains()`. |
| Splitting the ceiling between video and audio | Rust | A domain rule about what the server is being asked to produce; getting it wrong overshoots the user's cap. |
| Choosing `MaxHeight` for a bitrate | Rust | An encoding judgement (how many pixels a budget can carry), not a display preference. |
| Where the cap is applied (URL builders, `PlaybackInfo`, live TV, audio handoff) | Rust | All four are backend concerns, and the frontend must not have to know that a cap has more than one enforcement point. |
| Whether a mid-playback change needs a stream reload, and performing it | Rust | Same decision the audio-track switch already delegates: the backend knows the playback mode and owns the queue. |
| Persisting the default | Rust | Application state in `app_settings`, alongside every other durable setting. |
| Rendering the picker, menu placement, which control is highlighted | Frontend | Pure presentation. |
The frontend holds one string — the serde token for the chosen variant — and
labels/details it received from Rust. It never encodes a bitrate, a resolution
or a parameter name.
## Design
`StreamingQuality` (`src-tauri/src/settings.rs`) is the ladder: `Original`,
`Mbps20`, `Mbps10`, `Mbps8`, `Mbps4`, `Mbps2`, `Mbps1`, `Kbps720`, serialised
camelCase (`"mbps10"`). Each step answers `max_bitrate()`, `audio_bitrate()`,
`video_bitrate()` (= total audio), `max_height()`, `label()`, `detail()`.
The active ceiling is a process-wide `RwLock<StreamingQuality>` in
`repository/online.rs`, read by every builder. Process-wide rather than a field
on `OnlineRepository` because it is a preference about *this device's
connection*: it must survive a repository rebuilt on re-login, and the URL
builders and the negotiation have to agree on it or the cap leaks. This mirrors
`offline::INCLUDE_CATALOG_BROWSE`.
Enforcement points — all four are required:
| Point | What the cap sets |
|-------|-------------------|
| `get_video_stream_url` (HLS transcode) | `MaxStreamingBitrate`, `VideoBitrate`, `AudioBitrate`, `MaxHeight` |
| `get_playback_info` | request `MaxStreamingBitrate`, and the device profile's `MaxStreamingBitrate`/`MaxStaticBitrate` |
| `open_live_stream` | `MaxStreamingBitrate` |
| `build_audio_only_stream_url_for_video` | `min(cap audio, 384 kbps)` |
The negotiation is the one that matters most. `MaxStaticBitrate` is what makes
the server refuse to *direct play* a source fatter than the ceiling; without it
a 30 Mbps remux is served untouched and no URL parameter downstream can reduce
it.
IPC:
```rust
player_get_streaming_qualities() -> Vec<(StreamingQuality, String, String)> // variant, label, detail
player_set_stream_quality(repository_handle, quality, use_html5,
current_position, media_source_id, audio_stream_index)
-> StreamQualityResponse // #[serde(tag = "strategy")]: native | reloadStream
```
`VideoSettings` gains `streaming_quality` (`#[serde(default)]`, so settings
persisted before the field existed load as uncapped).
`player_set_video_settings` applies it and writes it to `app_settings`;
`restore_streaming_quality` reads it back in the Tauri `setup` hook via
`tauri::async_runtime::spawn`, defaulting to uncapped if anything fails.
`StreamQualityResponse` keeps its Rust field names on the wire (`new_url`) —
tauri-specta only camelCases the `strategy` tag. The facade
(`playerController.setStreamQuality`) dispatches `reloadSource` for
`reloadStream` and does nothing for `native`, because the backend has already
reloaded itself.
Mid-playback the change applies to the current video **and** becomes the process
ceiling for what follows, but it is not persisted: the in-player menu is a "this
film, this connection" control and Settings owns the durable default.
## Out of scope
- Per-item rendition lists from the server's `MediaSources` (UR-070's other half).
- Connection-aware caps (separate WiFi/cellular ceilings). One cap, all connections.
- Adaptive/automatic selection from measured throughput.
- Download quality, which already has its own preset vocabulary (UR-071/DR-123).
## Acceptance criteria
- [x] `bun run check` passes.
- [x] `cargo fmt` clean, `cargo clippy` clean, Rust tests pass.
- [x] `bun run test` passes.
- [x] `bun run check:boundary` passes — no bitrate/resolution numbers in `src/`.
- [x] New code carries `// TRACES:` comments.
- [x] `bindings.ts` regenerated from Rust.
- [x] A capped step changes what the URL asks for; the uncapped default is byte-identical to the previous behaviour.
## Testing
Rust (`cargo test`):
- `test_video_stream_url_applies_bitrate_cap` — all four parameters at `Mbps2`.
- `test_video_stream_url_uncapped_keeps_legacy_allowance``Original` is unchanged and adds no `MaxHeight`.
- `test_audio_only_stream_url_takes_the_lower_of_cap_and_default`.
- `test_streaming_quality_budget_is_internally_consistent`, `..._ladder_descends`, `..._round_trips_through_json`.
The ceiling is process-wide, so tests that depend on it serialise on a guard
(`QualityFixture`) that restores `Original` on drop — including the two
pre-existing stream-URL tests, which would otherwise see another test's cap.
`get_playback_info` and `open_live_stream` need a live server and are not unit
tested; their behaviour is the enum's `max_bitrate()`, which is.
## TRACES
- `StreamingQuality`, `VideoSettings.streaming_quality``UR-074 | DR-162`
- URL builders / negotiation / live TV — `UR-004, UR-074 | DR-140, DR-162`
- Audio-only handoff — `UR-040, UR-074 | DR-162`
- Commands, facade, Settings UI, player menu — `UR-074 | DR-162`
- Tests — `UT-156`, `UT-157`
## Notes for the implementer
- `videoBitRate` with a capital R is the *download* endpoint's binding quirk
(DR-123). The streaming endpoint used here binds `VideoBitrate`/
`MaxStreamingBitrate` as spelled above — do not "correct" one to the other.
- A parallel Claude session may be active in this repo; `git diff` before
repairing unexpected changes. DR-160/161 were claimed by such a session while
this feature was in flight, which is why it is DR-162.
-238
View File
@@ -1,238 +0,0 @@
# Spec: Repair the traceability coverage gate
**Status:** Implemented
**Requirements:** DR-093 → supports the traceability practice described in CLAUDE.md
**UX spec:** n/a — developer tooling, no user-facing surface.
**Supersedes / revises:** n/a
## Summary
The CI traceability gate has been passing unconditionally for an unknown length
of time because it divides traced-requirement counts by **hardcoded denominators
that no longer match [requirements.md](../requirements.md)**. It currently
reports **158% overall coverage** (and `JA 24 / 3 = 800%`), so the 50% threshold
is mathematically unreachable and the job cannot fail. This spec makes the gate
derive its denominators from `requirements.md` at run time, so it reports the
real number (**85%** today) and can actually fail again.
## Motivation
`.gitea/workflows/traceability-check.yml` hardcodes `UR/39, IR/24, DR/48, JA/3`
and `TOTAL_REQS=114`. The real counts are **UR 61, IR 29, DR 89, JA 32 — 211
total**. Requirements were added over time; the divisors were never updated.
The consequence is not a cosmetic reporting bug. The gate is the *only*
automated defence for the traceability practice, and it is dead:
```
CI today: 181 / 114 = 158% → threshold 50% can never trip
Reality: 181 / 211 = 85% → healthy, but unguarded
```
Coverage could collapse to 30% and CI would still print a green
"✅ Coverage is acceptable". An audit of the design principles found that every
principle with a *working* automated check is in good shape, and the ones that
drifted are exactly the ones whose checks were broken or too narrow — this is
the clearest instance.
A second, related defect is handled in a sibling spec: `scripts/check-req-coverage.sh`
is separately broken and orphaned (see
[req-coverage-script-removal.md](req-coverage-script-removal.md)).
## Layer assignment
This spec touches only CI/build tooling — no application logic crosses the
Rust/Svelte boundary. The table is filled in for completeness.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Counting requirement IDs defined in `requirements.md` | Build tooling (`scripts/`) | Neither runtime layer; it is repo metadata analysis. Belongs beside `extract-traces.ts`, not in the workflow YAML, so it is runnable and testable locally. |
| Counting *traced* requirement IDs | Build tooling — existing `extract-traces.ts` | Already implemented and correct; this spec consumes it rather than duplicating it. |
| Threshold policy (the 50% number) | CI workflow | Deployment policy, not analysis. Keeping it in YAML lets it be tuned without touching the script. |
No frontend or Rust logic is added, so no taxonomy leak is possible.
## Design
### 1. Denominators come from `requirements.md`, not literals
`requirements.md` defines requirements in markdown tables with a stable leading
cell, e.g.:
```
| DR-001 | Player state machine (idle, loading, …) | Player | UR-005 | Done |
| UR-002 | Access media when online or offline | High | Done |
```
Extend [scripts/extract-traces.ts](../../scripts/extract-traces.ts) to also emit
the *defined* counts, so one tool owns both sides of the fraction and CI does no
arithmetic on stale literals. Add a `defined` key to the JSON report:
```jsonc
{
"byType": { "UR": [...], "IR": [...], "DR": [...], "JA": [...] }, // traced (existing)
"defined": { "UR": 61, "IR": 29, "DR": 89, "JA": 32 }, // NEW
"coverage": { "covered": 181, "total": 211, "percent": 85 }, // NEW
"requirements": { ... }, // existing
"totalTraces": 318, "totalFiles": …, "timestamp": "…" // existing
}
```
Parsing rule for a *defined* requirement: a line in `docs/requirements.md`
matching `^\|\s*(UR|IR|DR|JA)-\d{3}\s*\|` — the ID must be the table's first
cell. This deliberately does **not** count IDs mentioned in the `Traces To`
column or in prose, which is why a naive `grep -o` over the whole file
overcounts.
`defined` counts IDs that exist in the spec; `byType` counts IDs that appear in
a `TRACES:` comment somewhere in the source. Coverage is
`|byType ∩ defined| / |defined|`.
> **Intersection, not raw length.** A `TRACES:` comment naming an ID that
> `requirements.md` does not define (a typo, or a requirement later deleted)
> must **not** inflate the numerator — that is how a ratio exceeds 100% in the
> first place. Such IDs are reported separately as `orphaned` so they get fixed
> rather than silently counted or silently dropped.
```jsonc
"orphaned": ["DR-097"] // traced in code but not defined in requirements.md
```
### 2. The workflow consumes the computed number
Replace the arithmetic in `.gitea/workflows/traceability-check.yml` (lines
4676) with reads of the precomputed fields:
```sh
COVERAGE=$(jq '.coverage.percent' traces-report.json)
COVERED=$(jq '.coverage.covered' traces-report.json)
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
for T in UR IR DR JA; do
TRACED=$(jq --arg t "$T" '.byType[$t] | length' traces-report.json)
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
echo " $T: $TRACED / $DEFINED"
done
MIN_THRESHOLD=50
[ "$COVERAGE" -lt "$MIN_THRESHOLD" ] && { echo "❌ …"; exit 1; }
```
No hardcoded denominator survives anywhere in the workflow.
### 3. A self-check so this cannot silently rot again
The root cause was a number that drifted with nothing watching it. Add a
guard that fails the job on an arithmetically impossible result:
```sh
if [ "$COVERAGE" -gt 100 ]; then
echo "❌ Coverage > 100% — the gate is miscomputing; orphaned IDs: $(jq -c '.orphaned' traces-report.json)"
exit 1
fi
```
A >100% reading is now a hard failure rather than a green tick.
### 4. Local parity
Add a script so the gate is runnable outside CI:
```jsonc
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage"
```
Prints the same table CI prints and exits non-zero below threshold.
### Threshold
Keep `MIN_THRESHOLD=50` in this spec. Real coverage is 85%, so raising the bar
is tempting, but doing it in the same change that repairs the gate conflates
"restore the safety net" with "tighten the policy" — if the build then fails, it
is ambiguous which change caused it. Ratcheting is deliberately deferred to
follow-up work once the honest number has been observed on `master` for a few
builds.
## Out of scope
- Raising `MIN_THRESHOLD` above 50 (see above).
- Fixing/removing `scripts/check-req-coverage.sh` — [req-coverage-script-removal.md](req-coverage-script-removal.md).
- Adding TRACES comments to raise the actual coverage number.
- Changing the `TRACES:` comment format or the extractor's parsing of it.
- The PR "modified files missing TRACES" step (lines 78126), which is advisory
by design and stays advisory.
## Acceptance criteria
- [ ] `bun run traces:json` emits `defined`, `coverage`, and `orphaned` keys.
- [ ] `coverage.total` equals the count of requirement IDs defined in
`requirements.md` (**211** at time of writing), not a literal.
- [ ] `coverage.percent` reports **85** (±1 for rounding) on the current tree —
i.e. the honest number, not 158.
- [ ] No hardcoded requirement denominator (`39`, `24`, `48`, `3`, `114`) remains
in `.gitea/workflows/traceability-check.yml`. Verify:
`grep -nE '/ *(39|24|48|3|114)\b' .gitea/workflows/traceability-check.yml`
returns nothing.
- [ ] Adding a new requirement row to `requirements.md` **lowers** reported
coverage until it is traced (proves the denominator is live).
- [ ] A `TRACES:` comment naming an undefined ID appears in `orphaned` and does
**not** raise `coverage.percent`.
- [ ] The job fails if coverage is forced below 50% (test by temporarily raising
`MIN_THRESHOLD` to 99 locally) — proving the gate can fail again.
- [ ] The job fails if coverage computes >100%.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `bun run check:boundary` passes.
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] No Rust types changed, so no `bindings.ts` regeneration needed.
## Testing
`extract-traces.ts` currently has no test coverage. Add
`scripts/extract-traces.test.ts` (vitest) over fixture strings rather than the
live `requirements.md`, so the tests do not change meaning as requirements are
added:
- **UT:** counts a well-formed table row as a defined requirement.
- **UT:** does **not** count an ID appearing only in the `Traces To` column or
in prose — the specific overcounting bug this parse rule avoids.
- **UT:** coverage is the intersection — a traced-but-undefined ID lands in
`orphaned` and does not inflate the numerator.
- **UT:** coverage of an empty trace set is 0%, not a divide-by-zero.
- **UT:** all-traced fixture reports exactly 100%, never above.
CI behaviour is verified by the acceptance criteria above (the forced-failure
check is the important one — a gate nobody has watched fail is not known to
work).
## TRACES
Allocate in `requirements.md`:
- **DR-093** — "Traceability coverage gate derives requirement denominators from
`requirements.md` at run time (not hardcoded literals), computes coverage as
the intersection of traced and defined IDs, reports IDs traced but undefined
as orphaned, and fails on an impossible >100% result." Category: Tooling.
Status: Done on merge.
Tag:
```typescript
// scripts/extract-traces.ts
// TRACES: | DR-093
```
Tests carry `@req-test: UT-089 …` onward (next free UT is **UT-089**).
## Notes for the implementer
- A parallel Claude session may be active in this repo — run `git diff` before
"repairing" unexpected changes (CLAUDE.md §Gotchas).
- **Do not add tooling to the CI image for this.** `jq` and `bun` are already in
`jellytau-builder`; this spec needs nothing else. Installing a system package
in a workflow step violates the hard CI rule in CLAUDE.md.
- Keep `traces:json`'s existing keys intact — `release-notes.ts` and
`traces:markdown` consume the same report, and the CI workflow uploads it as
an artifact. This is an additive change.
- The `head -50 docs/traceability.md` and artifact-upload steps are unaffected.
- Expect the first green build after this change to print a *lower* number than
before (85% vs 158%). That is the fix working, not a regression.
-233
View File
@@ -1,233 +0,0 @@
# Spec: Background audio for video playback (Android)
**Status:** Draft
**Scope:** Android only (v1). Linux noted as future work.
**Branch base:** `android-picture-in-picture`
**Requirements:** UR-040 → IR-025, JA-032, DR-051, DR-052 (see
[requirements.md](../requirements.md)). Tests: UT-059, UT-060, UT-061, IT-013.
## Summary
Add a per-player toggle that lets the **audio** of a video keep playing when the
app is backgrounded or the screen is locked, while **video decoding stops**.
When the app returns to the foreground, video decoding resumes from the current
audio position.
This is the audio-first counterpart to the existing Picture-in-Picture feature
(which keeps the *whole video* decoding in a floating window). The two are
mutually exclusive: enabling background audio suppresses auto-PiP.
## Motivation
Users watching talk-heavy content (podcasts-as-video, lectures, music videos,
concert films) want to lock the phone or switch apps and keep listening without
draining battery on video decode or needing a visible floating window.
## Background: how playback actually works here
Two facts drive the entire design (verified in code, not assumed):
1. **Video renders through the HTML5 `<video>` element in the WebView on both
platforms.** The native ExoPlayer *video* surface path is disabled — see the
INTERIM override in
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)
around the `playerPlayItem` response handling (`useHtml5Element` is forced
`true`, native backend is stopped). So "video decoding" == the WebView
`<video>` element, and the WebView is what Android suspends on background.
2. **An Android WebView `<video>` element does not keep playing audio when the
app is backgrounded / locked.** The system throttles the WebView and media
pauses. Keeping audio alive in the background requires a **native foreground
media service**, which already exists for music:
[`JellyTauPlaybackService`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt)
+
[`JellyTauPlayer`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt)
(ExoPlayer) + `MediaSessionCompat`.
**Therefore the design is a handoff**, not "keep the WebView alive": on
background, stop the WebView `<video>` and start audio-only playback of the same
item through the existing native ExoPlayer audio service; on foreground, hand
back to the WebView `<video>`.
This also aligns with the project's one-directional playback rule
(`CLAUDE.md` → "Playback state is one-directional"): the currently-authoritative
player (WebView element **or** native audio service) drives position; the UI and
MediaSession consume it. The handoff is a change of *which* player is
authoritative, and must transfer position cleanly.
## User-facing behavior
### The toggle
- A toggle button in the video player controls (next to the existing PiP /
fullscreen buttons in
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)).
- Icon: headphones / "audio-only" glyph. Two visual states (on/off).
- **Visible only when** `isPipSupported()`-equivalent conditions hold — i.e.
Android with a native audio service available. Hidden on Linux in v1.
- State is a UI preference on the player. Consider persisting the last choice
per user (see Open Questions) — v1 may default OFF each session.
### When toggle is ON and the app goes to background / screen locks
1. Auto-PiP is suppressed (see "Interaction with PiP").
2. The WebView `<video>` is paused and its decode stopped (release the media
source so the decoder is freed, not merely `pause()`).
3. Native audio-only playback of the same item starts at the current position,
through `JellyTauPlaybackService` (foreground notification + lockscreen
controls via the existing `MediaSessionCompat`).
4. Lockscreen / notification shows the item with play/pause/seek, driven by the
native player (existing music behavior — reused, not rebuilt).
### When toggle is ON and the app returns to foreground
1. Native audio playback stops; its final position is captured.
2. WebView `<video>` reloads/resumes at that position and continues as normal
audiovisual playback.
3. Playback state (playing/paused) is preserved across the handoff.
### When toggle is OFF (default)
Current behavior is unchanged: backgrounding video auto-enters PiP
(`onUserLeaveHint``PictureInPictureManager.enterPip`).
## Interaction with PiP
The toggle chooses one behavior or the other:
- Toggle **ON** → call `AndroidPictureInPicture.setAutoEnterEnabled(false)` (the
bridge already exists,
[pictureInPicture.ts](../../src/lib/utils/pictureInPicture.ts) →
`setAutoEnterEnabled`). Background → audio handoff instead of PiP.
- Toggle **OFF**`setAutoEnterEnabled(true)`. Background → PiP (status quo).
The frontend must also call `setAutoEnterEnabled(false)` on unmount if it left
it enabled, and re-assert the correct value whenever the toggle changes, so a
stale setting can't leak into the next player.
> Note: `canEnterPip()` today requires `isPlayingVideo()` on the *native*
> ExoPlayer, but video plays via the WebView, so native `isPlayingVideo()` is
> false during normal playback. Confirm during implementation how auto-PiP is
> actually triggering today (it may rely on a different signal), because the
> background-audio handoff needs the same "is a local video active" signal to
> know it should fire. **This is a load-bearing unknown — resolve it first
> (Phase 0).**
## Technical design
### The audio-only stream
Jellyfin can transcode/stream a video item as audio-only. Add a repository
method (mirroring
[`get_video_stream_url`](../../src-tauri/src/repository/online.rs) and
[`get_audio_stream_url`](../../src-tauri/src/repository/mod.rs)) that returns an
**audio-only stream URL for a video item** at a given audio-stream index — so
the currently-selected audio track (`selectedAudioTrackIndex` in the player)
carries over. Prefer direct-play of the audio stream where the container/codec
allows; transcode to a broadly-supported audio codec otherwise.
Position semantics must match between the WebView `<video>` timeline and the
audio stream (account for the transcoded-HLS `seekOffset` model already in the
player — see the `seekOffset` handling in `VideoPlayer.svelte`).
### Backend command surface (Rust)
New/extended `#[tauri::command]`s in `src-tauri/src/commands/player/` (follow the
camelCase param rule and `Result<T, String>` convention):
- `player_enter_background_audio(item_id, position_seconds, audio_stream_index)`
— stop WebView authority, start native audio-only playback at position; makes
the native player authoritative. Emits state via the existing player-event
channel so MediaSession/UI stay consumers.
- `player_exit_background_audio() -> position_seconds` — stop native audio,
return final position for the WebView to resume from; restores WebView
authority.
Reuse existing `player_play_*` / `player_stop` plumbing where possible rather
than adding a parallel path.
### Android native
- Reuse `JellyTauPlaybackService` + `JellyTauPlayer` audio path
(`MediaSessionCompat`, foreground notification, audio-becoming-noisy, etc. —
all already implemented for music).
- Add a bridge method (alongside `AndroidPictureInPicture`) or reuse an existing
one so the frontend can signal "prepare for background audio handoff" tied to
the Activity lifecycle (`onPause`/`onStop`/`onUserLeaveHint`).
- On `onUserLeaveHint` / screen-off with background-audio enabled: **do not**
enter PiP; instead trigger the handoff command.
- Respect the deadlock gotchas in `CLAUDE.md` (no sync/blocking calls from
player event callbacks; bind locked `AutoplayDecision` to a `let` before
matching).
### Frontend (VideoPlayer.svelte)
- Add toggle state + button. On change, call `setAutoEnterEnabled(!on)`.
- Listen for Android lifecycle background/foreground signals (via a bridge event
or existing visibility hooks) and:
- background + ON → `player_enter_background_audio(...)`, pause + tear down the
`<video>`/HLS decode (reuse the existing HLS teardown sequence to avoid dual
audio).
- foreground + ON → `player_exit_background_audio()`, reload `<video>` at the
returned position, restore play/pause state.
- **Follow the native-mode pitfall** (memory:
`videoplayer-native-mode-pitfalls`): no lifecycle calls after an `await` in
`onMount`. Keep the handoff logic out of that window.
- Dual-audio is the key regression risk: at every handoff exactly one of
{WebView `<video>`, native ExoPlayer} produces audio. Tear the other down
*before* starting the next, mirroring the existing HLS cleanup discipline.
## Phasing
- **Phase 0 — De-risk (do first):**
- Confirm what actually triggers today's auto-PiP given video is on the
WebView (resolve the `canEnterPip`/`isPlayingVideo` question).
- Spike: obtain an audio-only stream URL for a video item and play it through
the native audio service; measure position accuracy and that WebView audio
is fully silenced (no dual audio).
- **Phase 1 — Backend:** repository audio-only-URL method + the two player
commands + events.
- **Phase 2 — Native:** lifecycle wiring, PiP suppression, handoff trigger.
- **Phase 3 — Frontend:** toggle UI, lifecycle listeners, handoff calls,
teardown discipline.
- **Phase 4 — Polish:** persist toggle preference, subtitle/audio-track
carry-over, edge cases (calls, headphone unplug, autoplay-next during
background audio).
## Testing
- Rust: unit tests for the audio-only URL builder and the two commands
(`cargo test`, `bun run test:rust`).
- IPC param-naming integration tests for any new commands
(`bun run test -- tauriIntegration.test.ts`).
- Frontend: `bun run check`, `bun run test`, plus a VideoPlayer logic test for
the handoff state machine (mirror the existing
`VideoPlayer.logic.test.ts`).
- Manual on-device matrix:
- toggle ON: home button → audio continues, video stops decoding; return →
video resumes at position; playing/paused preserved.
- toggle ON: screen lock → audio continues; lockscreen controls work; unlock →
resumes.
- toggle OFF: background → PiP (unchanged).
- No dual audio at any transition. No audio leak after leaving the player.
- Transcoded (HEVC/10-bit) item — verify position with `seekOffset`.
- Autoplay-next fires correctly if an episode ends during background audio.
## Open questions
1. **Persist the toggle per user/series, or default OFF each session?**
(Recommend: remember last choice; series-level like the audio-track
preference is a nice-to-have.)
2. **Autoplay-next during background audio** — should the next episode start as
audio-only and stay audio until foreground, or pause at episode end? (Recommend:
continue as audio-only.)
3. **Subtitles** are irrelevant in audio-only mode but must restore on
foreground — confirm they survive the `<video>` teardown/reload.
4. Exact **Android lifecycle signal** for "screen locked" vs "app backgrounded"
`onUserLeaveHint` covers Home but not lock; may need a screen-off receiver.
## Non-goals (v1)
- Linux background audio (desktop windows keep running unfocused; low value).
- Replacing or removing PiP — it stays as the toggle-OFF behavior.
- Re-enabling the native ExoPlayer *video* surface path.
+6 -2
View File
@@ -1,7 +1,11 @@
# Spec: Windows native audio backend
**Status:** Proposed
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036; new IR-030
**Status:** Proposed — not started. Windows still runs on
`WebviewAudioBackend`. Blocked on [libmpv2-migration.md](libmpv2-migration.md),
whose crate swap has not landed either.
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036;
⚠️ the suggested id **IR-030 has since been allocated** to the scheduled catalog
crawl — allocate a fresh id (IR-033 or later) on implementation
**UX spec:** n/a — Settings Audio already renders the controls
**Supersedes / revises:** acts on the "audio can unify, video cannot" conclusion in [playback-backend-unification.md](playback-backend-unification.md)
+1 -1
View File
@@ -54,7 +54,7 @@ that will drift.
> (UR/39, IR/24, DR/48, JA/3, total 114) while `requirements.md` had grown past
> 200. It reported **158%** coverage, so the 50% threshold was unreachable and
> the job could not fail regardless of how far coverage dropped. See
> [specs/traceability-gate-repair.md](specs/traceability-gate-repair.md).
> The fix derives the denominators from `requirements.md` at run time.
Coverage is the *intersection* of traced and defined IDs: an ID that appears in
a `TRACES:` comment but is not defined in `requirements.md` is reported as
+1 -1
View File
@@ -763,7 +763,7 @@ Favouriting is a two-sided promise: the heart takes the input, and the app must
be able to give it back. This section covers both sides — where you can mark a
favourite, and where marked favourites resurface.
See [specs/favorites-browsing.md](specs/favorites-browsing.md) for the layer
See [architecture/01-rust-backend.md](architecture/01-rust-backend.md#favorites-system) for the layer
assignment and wire shapes.
### 5C.1 The heart appears wherever an item does
+31 -7
View File
@@ -50,14 +50,17 @@ export default ts.config(
},
},
rules: {
// 🔴 TEMPORARILY OFF. A parallel migration is moving all ~468 `console.*`
// calls in `src/` onto a logger facade. Turning this on before that lands
// would paint the tree red and collide with that work.
// The logger-facade migration this rule was waiting on is done: the ~468
// `console.*` calls that used to live in `src/` are gone, replaced by
// `createLogger(...)` from src/lib/utils/logger.ts (DR-204), which is now
// the single sink. Nothing is allowed through — not even warn/error —
// because the facade's own `warn`/`error` levels are always emitted, so a
// raw call has no capability the facade lacks. It only loses the scope tag
// and the runtime level control.
//
// 👉 Switch this to "error" (allowing nothing, or at most
// `{ allow: ["warn", "error"] }`) once the logger-facade migration is
// merged — that is the whole point of the rule being listed here.
"no-console": "off",
// The sink itself is exempted below, as are tests (a test that asserts on
// logging has to be able to talk about `console`).
"no-console": "error",
// Unused values are a real signal, but `_`-prefixed args are the
// established way to say "this parameter exists for the signature".
@@ -133,6 +136,17 @@ export default ts.config(
},
},
{
// The logging facade is the one place allowed to touch `console` — it *is*
// the sink every other module reaches it through (see the `no-console`
// comment above). `createLogger`'s `console[method](...)` dispatch is a
// computed member access, which the rule flags like any other.
files: ["src/lib/utils/logger.ts"],
rules: {
"no-console": "off",
},
},
{
// Node-side tooling: build/test scripts and root config files run under
// Bun/Node, not in the webview.
@@ -148,6 +162,13 @@ export default ts.config(
...globals.node,
},
},
rules: {
// These are command-line tools (extract-traces, release-notes, ...) whose
// stdout IS the product — `bun run traces:markdown > docs/traceability.md`
// depends on it. The logging facade is a webview concern; a CLI printing
// its result is not a stray debug statement.
"no-console": "off",
},
},
{
@@ -160,6 +181,9 @@ export default ts.config(
},
},
rules: {
// Tests are allowed to talk about `console` — several spy on it to assert
// what the logging facade emits, and scripts/ tooling tests capture output.
"no-console": "off",
// Test doubles legitimately use `any` for partial mocks.
"@typescript-eslint/no-explicit-any": "off",
// `vi.mock` factories are hoisted above the import graph, so a lazy
+4 -1
View File
@@ -56,8 +56,11 @@
},
"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"
},
@@ -85,6 +88,6 @@
"typescript": "~5.6.2",
"typescript-eslint": "^8.67.0",
"vite": "^6.0.3",
"vitest": ">=1.0.0 <5.0.0"
"vitest": "^4.1.10"
}
}
+2 -1
View File
@@ -112,7 +112,8 @@ typos and renames that missed a call site went unreported for months.
> unscoped (hanging on ~40 GB of `target/` artifacts), and in one case reported
> "all requirements implemented" from an empty result set. `extract-traces.ts` is
> the single source of truth for requirement coverage. See
> [docs/specs/req-coverage-script-removal.md](../docs/specs/req-coverage-script-removal.md).
> it reported `Total Requirements: 1` and then "All requirements have
> implementations!". Nothing referenced it. Use `bun run traces:coverage`.
Example TRACES comment in code:
```typescript
+28 -1
View File
@@ -49,12 +49,39 @@ else
fi
# Step 4: Push to registry
#
# Two tags, on purpose:
#
# <date> what the workflows pin (e.g. :2026.08). CI must name an immutable
# tag -- while every job said :latest, rebuilding the image silently
# changed what every build, including a rebuild of an old release
# tag, compiled against. That is the opposite of reproducible.
# latest convenience for local `docker compose` runs and for anyone pulling
# the image by hand.
#
# Date tags rather than per-commit SHA tags: the Gitea runner shares a 74 GB
# disk with two other projects, and SHA-tagged images accumulated there until it
# filled. Keep at most a couple of dated tags live and prune the rest
# (`docker image prune -a` on the runner).
#
# To bump: build+push a new dated tag, then update the `image:` lines in
# .gitea/workflows/*.yml in the same commit as whatever needed the new tool.
echo "📤 Pushing image to registry..."
docker push ${FULL_IMAGE_NAME}
if [ "$IMAGE_TAG" != "latest" ]; then
echo "🏷️ Also tagging as :latest for local use..."
LATEST_IMAGE_NAME="${REGISTRY_HOST}/${REGISTRY_USER}/${IMAGE_NAME}:latest"
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${LATEST_IMAGE_NAME}
docker push ${LATEST_IMAGE_NAME}
fi
echo ""
echo "✅ Successfully built and pushed: ${FULL_IMAGE_NAME}"
echo ""
echo "Update your workflow to use:"
echo "Workflows must pin the dated tag, not :latest --"
echo " container:"
echo " image: ${FULL_IMAGE_NAME}"
echo ""
echo "Currently pinned in .gitea/workflows/:"
grep -ho "jellytau-builder:[A-Za-z0-9._-]*" "$(git rev-parse --show-toplevel)"/.gitea/workflows/*.yml 2>/dev/null | sort -u | sed "s/^/ /"
+7 -1
View File
@@ -62,7 +62,13 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
mkdir -p "$OUTPUT_DIR"
find "$BIN_DIR" -maxdepth 1 -name 'jellytau.exe' -exec cp -v {} "$OUTPUT_DIR/" \;
# NSIS setup installers land in bundle/nsis/*-setup.exe; MSI in bundle/msi/*.msi.
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' \) \
#
# The .sig files come along too: when TAURI_SIGNING_PRIVATE_KEY is set the
# bundler writes `<installer>.sig` beside each installer, and that signature is
# what the updater verifies before installing anything. Leaving it behind
# produces a release whose manifest references a signature that was never
# published, which fails only on the user's machine.
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' -o -name '*.sig' \) \
-exec cp -v {} "$OUTPUT_DIR/" \; 2>/dev/null || true
echo ""
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
+3 -2
View File
@@ -33,7 +33,8 @@
# This check was hardened in July 2026 after the audit found it passing on the
# very leak it was written for: the original pattern was anchored to
# `includeItemTypes:` at the query site, so assigning the same array to a named
# const evaded it entirely. See docs/specs/boundary-tripwire-hardening.md (DR-094).
# const evaded it entirely (DR-094). The pattern below is the hardened one: it
# matches an item-type array literal anywhere, not just at a query site.
#
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
@@ -59,7 +60,7 @@ ALLOWLIST=(
# BORDERLINE — leans domain: the container set grows when Jellyfin adds a
# container type. TODO: replace with a backend-supplied `MediaItem.isContainer`
# flag and remove this entry. Tracked in
# docs/specs/boundary-tripwire-hardening.md §Out of scope.
# a backend-supplied flag; deferred rather than bundled with the tripwire work.
"src/lib/components/downloads/DownloadedBrowse.svelte"
)
+54 -11
View File
@@ -23,6 +23,7 @@ import {
findDanglingIds,
formatMatrixFileLink,
generateMarkdown,
isTracedSourceFile,
MIN_COVERAGE_PERCENT,
type TracesData,
} from "./extract-traces";
@@ -30,6 +31,52 @@ import {
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
const HERE = path.dirname(new URL(import.meta.url).pathname);
describe("isTracedSourceFile", () => {
// The extractor used to accept only .ts/.svelte/.rs under src/, src-tauri/src/
// and scripts/. Every requirement implemented by *configuration* was therefore
// invisible to the matrix that measures it: eslint.config.js (DR-205), the
// pre-commit hook (DR-207), rust-toolchain.toml (DR-206) and deny.toml
// (DR-216) all carry TRACES comments that were never read. Each one counted
// against coverage as an uncovered requirement while being, in fact, covered.
it("accepts the source extensions it always did", () => {
expect(isTracedSourceFile("src/lib/utils/logger.ts")).toBe(true);
expect(isTracedSourceFile("src/routes/settings/+page.svelte")).toBe(true);
expect(isTracedSourceFile("src-tauri/src/lib.rs")).toBe(true);
});
it("accepts tooling files that implement a requirement", () => {
expect(isTracedSourceFile("eslint.config.js")).toBe(true);
expect(isTracedSourceFile("src-tauri/deny.toml")).toBe(true);
expect(isTracedSourceFile("src-tauri/rust-toolchain.toml")).toBe(true);
expect(isTracedSourceFile("scripts/hooks/pre-commit")).toBe(true);
});
it("does not scan CI workflows, whose comments discuss TRACES in prose", () => {
// .gitea/workflows/traceability-check.yml explains the gate, so it contains
// lines like "a `TRACES:` comment ... (DR-189 and UT-188 lived in three
// source files, defined nowhere)". The extractor's pattern would read that
// as a trace and manufacture references to IDs that do not exist, failing
// traces:validate. A file that *describes* traceability is not a file that
// implements a requirement.
expect(isTracedSourceFile(".gitea/workflows/traceability-check.yml")).toBe(false);
expect(isTracedSourceFile(".gitea/workflows/build-and-test.yml")).toBe(false);
});
it("rejects files that merely mention a requirement in prose", () => {
// requirements.md defines IDs; traceability.md is generated *from* traces.
// Scanning either would make every requirement trace to itself.
expect(isTracedSourceFile("docs/requirements.md")).toBe(false);
expect(isTracedSourceFile("docs/traceability.md")).toBe(false);
expect(isTracedSourceFile("README.md")).toBe(false);
});
it("rejects generated and vendored trees", () => {
expect(isTracedSourceFile("node_modules/foo/index.ts")).toBe(false);
expect(isTracedSourceFile("src-tauri/target/debug/build/x.rs")).toBe(false);
expect(isTracedSourceFile("src-tauri/gen/android/app/build.gradle.kts")).toBe(false);
});
});
describe("countDefinedRequirements", () => {
it("counts a well-formed table row as a defined requirement", () => {
const md = `
@@ -140,9 +187,10 @@ describe("findDanglingIds", () => {
});
it("deduplicates and sorts, so one typo is reported once", () => {
expect(
findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)
).toEqual(["DR-189", "UR-999"]);
expect(findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)).toEqual([
"DR-189",
"UR-999",
]);
});
it("ignores IDs whose prefix is not a known trace type", () => {
@@ -158,7 +206,7 @@ describe("coverage threshold", () => {
// passes, which is how the 50%-while-actually-86% slack went unnoticed.
const workflow = fs.readFileSync(
path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"),
"utf-8"
"utf-8",
);
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
expect(match).not.toBeNull();
@@ -307,9 +355,7 @@ describe("generated matrix file links", () => {
it("keeps the #Lnn line anchor on the href", () => {
const link = formatMatrixFileLink("scripts/extract-traces.ts", 427);
expect(link).toBe(
"[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)"
);
expect(link).toBe("[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)");
});
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => {
@@ -334,10 +380,7 @@ describe("live requirements.md", () => {
// row. Worse, the pins never guarded the actual defect — a stale denominator
// is caught by the sum-consistency check below, and the >100% ratio it
// produced is covered directly by the computeCoverage tests, on fixtures.
const md = fs.readFileSync(
path.resolve(HERE, "../docs/requirements.md"),
"utf-8"
);
const md = fs.readFileSync(path.resolve(HERE, "../docs/requirements.md"), "utf-8");
const defined = countDefinedRequirements(md);
// The parser found real rows of every type: a section silently failing to
+92 -44
View File
@@ -56,7 +56,7 @@ export interface TracesData {
*
* TRACES: | DR-093
*/
export const MIN_COVERAGE_PERCENT = 88;
export const MIN_COVERAGE_PERCENT = 89;
// Repo root, derived from this script's location (scripts/ -> repo root).
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
@@ -64,8 +64,7 @@ export const MIN_COVERAGE_PERCENT = 88;
// `import.meta.dir` is a Bun extension and is undefined when this module is
// imported by vitest (which runs it as an ordinary ESM module), so fall back to
// import.meta.url — this file must stay importable for extract-traces.test.ts.
const SCRIPT_DIR =
import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
const SCRIPT_DIR = import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
const BASE_DIR = path.resolve(SCRIPT_DIR, "..");
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
@@ -76,6 +75,71 @@ function extractRequirementIds(tracesString: string): string[] {
return matches.map((m) => `${m[1]}-${m[2]}`);
}
/**
* Tooling files that implement a requirement.
*
* The walker below only visits `src/`, `src-tauri/src/` and `scripts/`, and only
* picks up `.ts`/`.svelte`/`.rs`. That made every requirement implemented by
* *configuration* invisible to the matrix that measures it DR-205
* (eslint.config.js), DR-206 (rust-toolchain.toml), DR-207 (the pre-commit
* hook) and DR-216 (deny.toml) all carry TRACES comments that nothing read, so
* each was counted as uncovered while being covered.
*
* An explicit list rather than "also scan .toml/.js/.yml": most config files in
* this repo implement nothing, and one class of file is actively dangerous to
* scan see `isTracedSourceFile`.
*/
const TOOLING_FILES = new Set([
"eslint.config.js",
"vitest.config.ts",
"scripts/hooks/pre-commit",
"src-tauri/deny.toml",
"src-tauri/rust-toolchain.toml",
]);
/** Directory names that never contain hand-written traced source. */
const EXCLUDED_SEGMENTS = new Set([
"node_modules",
"target",
"build",
".git",
".svelte-kit",
"docs-site",
// Tauri regenerates src-tauri/gen/ on every android/desktop init; the
// canonical Android sources live in src-tauri/android/ and are synced into it.
"gen",
]);
/**
* Decide whether a repo-relative path should be scanned for TRACES comments.
*
* Exported for scripts/extract-traces.test.ts the file-walking half needs a
* filesystem, this half is a pure decision and is where the mistakes live.
*
* Deliberately excluded:
* - `docs/requirements.md` *defines* IDs and `docs/traceability.md` is
* generated from traces; scanning either would make requirements trace to
* themselves.
* - `.gitea/workflows/*.yml` traceability-check.yml explains the gate in
* prose, quoting "a `TRACES:` comment" on the same line as example IDs that
* are deliberately undefined. The extractor would read those as real traces
* and then fail its own dangling-ID check.
*/
export function isTracedSourceFile(relativePath: string): boolean {
const p = relativePath.split(path.sep).join("/");
if (p.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment))) {
return false;
}
if (TOOLING_FILES.has(p)) {
return true;
}
const isSourceExtension = p.endsWith(".ts") || p.endsWith(".svelte") || p.endsWith(".rs");
if (!isSourceExtension) {
return false;
}
return p.startsWith("src/") || p.startsWith("src-tauri/src/") || p.startsWith("scripts/");
}
function getAllSourceFiles(): string[] {
const baseDir = BASE_DIR;
// `scripts` is scanned too: build tooling implements requirements (e.g.
@@ -91,23 +155,16 @@ function getAllSourceFiles(): string[] {
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(baseDir, fullPath);
// Skip node_modules, target, build
if (
relativePath.includes("node_modules") ||
relativePath.includes("target") ||
relativePath.includes("build") ||
relativePath.includes(".git")
) {
// Directory pruning still happens here so the walk does not descend
// into node_modules/target at all; isTracedSourceFile repeats the rule
// for individual files (and is the version under test).
if (entry.isDirectory() && !isTracedSourceFile(path.join(relativePath, "x.ts"))) {
continue;
}
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (
entry.name.endsWith(".ts") ||
entry.name.endsWith(".svelte") ||
entry.name.endsWith(".rs")
) {
} else if (isTracedSourceFile(relativePath)) {
files.push(fullPath);
}
}
@@ -123,6 +180,15 @@ function getAllSourceFiles(): string[] {
}
}
// eslint.config.js, deny.toml and rust-toolchain.toml sit at the repo root or
// in src-tauri/ rather than under a walked root, so they are added by name.
for (const toolingFile of TOOLING_FILES) {
const fullPath = path.join(baseDir, toolingFile);
if (fs.existsSync(fullPath) && !files.includes(fullPath)) {
files.push(fullPath);
}
}
return files;
}
@@ -230,7 +296,7 @@ function extractTraces(): TracesData {
// CI gate previously divided by frozen literals (UR/39, IR/24, DR/48, JA/3,
// total 114) while the real file had grown to 211 requirements, so it reported
// 158% coverage and the 50% threshold became unreachable — the gate could not
// fail. See docs/specs/traceability-gate-repair.md.
// fail. See docs/traceability-ci.md, "Coverage Thresholds".
//
// TRACES: | DR-093
// ---------------------------------------------------------------------------
@@ -283,8 +349,7 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
else ids.add(id);
}
const countOf = (type: string) =>
[...ids].filter((id) => id.startsWith(`${type}-`)).length;
const countOf = (type: string) => [...ids].filter((id) => id.startsWith(`${type}-`)).length;
return {
UR: countOf("UR"),
@@ -311,16 +376,13 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
*
* TRACES: | DR-093
*/
export function findDanglingIds(
tracedIds: string[],
defined: DefinedRequirements
): string[] {
export function findDanglingIds(tracedIds: string[], defined: DefinedRequirements): string[] {
const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/;
const dangling = new Set(
tracedIds
.filter((id) => KNOWN_TYPE.test(id))
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id))
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id)),
);
return [...dangling].sort();
@@ -336,10 +398,7 @@ export function findDanglingIds(
*
* TRACES: | DR-093
*/
export function computeCoverage(
tracedIds: string[],
defined: DefinedRequirements
): CoverageResult {
export function computeCoverage(tracedIds: string[], defined: DefinedRequirements): CoverageResult {
// Only the four *requirement* types participate in coverage. UT/IT are test
// identifiers defined in §4 of requirements.md — a different taxonomy, and
// flagging them as orphans would bury real typos in ~60 lines of noise.
@@ -352,10 +411,7 @@ export function computeCoverage(
return {
covered: covered.length,
total: defined.total,
percent:
defined.total === 0
? 0
: Math.round((covered.length / defined.total) * 100),
percent: defined.total === 0 ? 0 : Math.round((covered.length / defined.total) * 100),
orphaned,
};
}
@@ -488,16 +544,14 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
if (cov.orphaned.length > 0) {
console.log("");
console.log(
`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`
);
console.log(`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`);
console.log(" Fix the TRACES comment or add the requirement.");
}
if (data.dangling && data.dangling.length > 0) {
console.log("");
console.log(
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`,
);
}
@@ -538,9 +592,7 @@ function reportDangling(data: TracesData): number {
console.log("❌ TRACES reference IDs that docs/requirements.md does not define:");
console.log("");
for (const id of dangling) {
const files = [
...new Set((data.requirements[id] ?? []).map((e) => e.file)),
].sort();
const files = [...new Set((data.requirements[id] ?? []).map((e) => e.file))].sort();
console.log(` ${id}`);
for (const file of files) console.log(` ${file}`);
}
@@ -554,9 +606,7 @@ function reportDangling(data: TracesData): number {
// Main — guarded so this module stays importable from extract-traces.test.ts.
if (import.meta.main) {
const args = process.argv.slice(2);
const format = args.includes("--format")
? args[args.indexOf("--format") + 1]
: "markdown";
const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "markdown";
console.error("🔍 Extracting TRACES from codebase...");
const data = extractTraces();
@@ -583,7 +633,5 @@ if (import.meta.main) {
console.log(generateMarkdown(data));
}
console.error(
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
);
console.error(`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`);
}
+6
View File
@@ -12,6 +12,8 @@
# bun run check svelte-check (types)
# bun run test vitest, single pass
# scripts/check-frontend-boundary.sh domain-taxonomy tripwire (DR-094)
# bun run format:check prettier
# bun run lint --max-warnings=N eslint, warning-count ratchet
# cargo fmt --all -- --check only when src-tauri/ is staged
#
# NOT here, on purpose: `cargo clippy` and `cargo test`. Both take minutes on a
@@ -59,6 +61,10 @@ run_gate() {
run_gate "svelte-check (bun run check)" bun run check
run_gate "frontend tests (bun run test)" bun run test
run_gate "frontend/backend boundary" bash scripts/check-frontend-boundary.sh
run_gate "formatting (bun run format:check)" bun run format:check
# Same ratchet as the CI step in build-and-test.yml — keep the two numbers equal,
# or a commit passes here and fails there.
run_gate "lint (bun run lint)" bun run lint --max-warnings=159
# rustfmt only matters when Rust actually changed, and `cargo fmt --check` is
# cheap (no compilation) whenever it does.
+1 -3
View File
@@ -55,9 +55,7 @@ function loadRequirementDescriptions(): Map<string, string> {
}
function changedFiles(range: string): string[] {
const cmd = range
? `git diff --name-only ${range}`
: "git ls-files"; // untagged repo: describe everything currently traced
const cmd = range ? `git diff --name-only ${range}` : "git ls-files"; // untagged repo: describe everything currently traced
return sh(cmd)
.split("\n")
.filter((f) => f && existsSync(f));
+24 -7
View File
@@ -34,30 +34,47 @@ function seed(dir: string) {
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2),
);
fs.writeFileSync(
path.join(dir, "src-tauri", "tauri.conf.json"),
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2),
);
// A dependency carrying its own `version =` is the trap: a greedy regex
// rewrites it too and the build then resolves the wrong crate.
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.toml"),
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
[
"[package]",
'name = "jellytau"',
'version = "0.0.1"',
"",
"[dependencies]",
'serde = { version = "1.0.100" }',
"",
].join("\n"),
);
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.lock"),
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
[
"[[package]]",
'name = "serde"',
'version = "1.0.100"',
"",
"[[package]]",
'name = "jellytau"',
'version = "0.0.1"',
"",
].join("\n"),
);
fs.writeFileSync(
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
"tauri.android.versionCode=1\n"
"tauri.android.versionCode=1\n",
);
fs.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true });
fs.writeFileSync(
path.join(dir, "packaging", "arch", "PKGBUILD"),
['pkgname=jellytau', 'pkgver=0.0.1', 'pkgrel=3', 'pkgdesc="x"', ''].join("\n")
["pkgname=jellytau", "pkgver=0.0.1", "pkgrel=3", 'pkgdesc="x"', ""].join("\n"),
);
}
@@ -74,7 +91,7 @@ function read(rel: string): string {
function versionCode(): number {
const m = read("src-tauri/gen/android/app/tauri.properties").match(
/^tauri\.android\.versionCode=(\d+)$/m
/^tauri\.android\.versionCode=(\d+)$/m,
);
return m ? Number(m[1]) : NaN;
}
+1 -1
View File
@@ -16,7 +16,7 @@ import { readFileSync } from "fs";
import { resolve } from "path";
const config = JSON.parse(
readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8")
readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8"),
);
const security = config.app.security;
+471 -25
View File
@@ -49,6 +49,17 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -85,6 +96,23 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -150,6 +178,21 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "ascii"
version = "1.1.0"
@@ -349,6 +392,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "bitvec"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -380,6 +435,30 @@ dependencies = [
"piper",
]
[[package]]
name = "borsh"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c"
dependencies = [
"once_cell",
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.112",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -407,6 +486,40 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "byte-unit"
version = "5.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0"
dependencies = [
"rust_decimal",
"schemars 1.2.0",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.24.0"
@@ -421,9 +534,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.0"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
dependencies = [
"serde",
]
@@ -782,14 +895,24 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.5.5"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.112",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1086,6 +1209,15 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1096,6 +1228,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.6"
@@ -1154,6 +1296,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1578,6 +1726,9 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
@@ -1585,7 +1736,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"ahash 0.8.12",
]
[[package]]
@@ -2035,7 +2186,7 @@ dependencies = [
"libmpv",
"log",
"ndk-context",
"rand 0.8.5",
"rand 0.8.7",
"reqwest",
"rusqlite",
"serde",
@@ -2045,8 +2196,11 @@ dependencies = [
"specta-typescript",
"tauri",
"tauri-build",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-updater",
"tauri-specta",
"tempfile",
"tiny_http",
@@ -2055,6 +2209,7 @@ dependencies = [
"tokio-util",
"urlencoding",
"uuid",
"zip 2.4.2",
]
[[package]]
@@ -2217,7 +2372,7 @@ dependencies = [
[[package]]
name = "libmpv"
version = "2.0.1"
source = "git+https://github.com/ParadoxSpiral/libmpv-rs.git?branch=master#3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7"
source = "git+https://github.com/ParadoxSpiral/libmpv-rs.git?rev=3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7#3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7"
dependencies = [
"libmpv-sys",
]
@@ -2225,7 +2380,7 @@ dependencies = [
[[package]]
name = "libmpv-sys"
version = "3.1.0"
source = "git+https://github.com/ParadoxSpiral/libmpv-rs.git?branch=master#3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7"
source = "git+https://github.com/ParadoxSpiral/libmpv-rs.git?rev=3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7#3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7"
[[package]]
name = "libredox"
@@ -2274,6 +2429,9 @@ name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]]
name = "lru-slab"
@@ -2339,6 +2497,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2438,9 +2602,9 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
@@ -2473,6 +2637,15 @@ dependencies = [
"syn 2.0.112",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "objc2"
version = "0.6.3"
@@ -2644,6 +2817,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2776,6 +2961,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.17",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2915,7 +3114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [
"phf_shared 0.10.0",
"rand 0.8.5",
"rand 0.8.7",
]
[[package]]
@@ -2925,7 +3124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.5",
"rand 0.8.7",
]
[[package]]
@@ -3176,6 +3375,26 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -3255,6 +3474,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3271,9 +3496,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.8.5"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -3451,6 +3676,15 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -3506,6 +3740,35 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@@ -3520,6 +3783,23 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust_decimal"
version = "1.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.7",
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -3574,9 +3854,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.8"
version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [
"ring",
"rustls-pki-types",
@@ -3661,6 +3941,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "selectors"
version = "0.24.0"
@@ -3892,6 +4178,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -4194,6 +4486,23 @@ dependencies = [
"syn 2.0.112",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4333,6 +4642,28 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"time",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.2"
@@ -4373,6 +4704,48 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.17",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip 4.6.1",
]
[[package]]
name = "tauri-runtime"
version = "2.9.2"
@@ -4568,30 +4941,31 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.44"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.6"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.24"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
@@ -5022,6 +5396,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5046,6 +5426,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "value-bag"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -5858,6 +6244,15 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
@@ -5879,6 +6274,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.1"
@@ -6043,12 +6448,53 @@ dependencies = [
"syn 2.0.112",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.12.1",
"memchr",
"thiserror 2.0.17",
"zopfli",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.12.1",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317f17ff091ac4515f17cc7a190d2769a8c9a96d227de5d64b500b01cda8f2cd"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zvariant"
version = "5.8.0"
+42 -2
View File
@@ -62,17 +62,57 @@ sha2 = "0.10"
getrandom = "0.2"
log = "0.4"
env_logger = "0.11"
# Persistent, rotating, redacted logging on every platform -- and on Android the
# only thing that puts Rust output into logcat at all (env_logger writes to
# stdout, which Android discards, which is why the backend was invisible on the
# platform where the hardest bugs live).
#
# TRACES: UR-078 | DR-218
tauri-plugin-log = "2"
# Zip for the diagnostics export bundle.
zip = { version = "2", default-features = false, features = ["deflate"] }
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
specta-typescript = "=0.0.9"
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
tiny_http = { version = "0.12.0", default-features = false }
# In-app update, desktop only.
#
# `cfg(desktop)` is not decoration: tauri-plugin-updater does not support
# Android at all -- an APK cannot replace itself, that is the package manager's
# job -- and building it for the Android target fails. Android is offered the
# releases page through tauri-plugin-opener instead (see the frontend's
# updateCheck module). tauri-plugin-process supplies the relaunch that has to
# follow a desktop install.
#
# The cfg is spelled out as "not android, not iOS" rather than `cfg(desktop)`:
# Cargo evaluates a [target.'cfg(...)'] table against *target-triple* cfgs only
# (target_os, target_arch, target_family, unix/windows). `desktop` is a cfg
# Tauri's build script emits for use in Rust source, so `cfg(desktop)` here
# matches nothing, silently drops the dependency, and the build then fails much
# later with "Permission updater:default not found".
#
# TRACES: UR-077 | DR-217
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
hostname = "0.4"
libc = "0.2"
# Use latest git version for better MPV version compatibility
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
# The crates.io release of libmpv predates the MPV versions we support, so this
# tracks the upstream git repo.
#
# Pinned by `rev`, not `branch = "master"`. With a branch, the revision is
# whatever Cargo.lock happens to hold and any `cargo update` silently swaps in
# new upstream code -- for the one dependency here that is not from crates.io,
# is not signed, and links a C library into the player. The rev below is the
# commit the lockfile already resolved to, so this pins current behaviour rather
# than changing it. To take upstream fixes, bump this deliberately.
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
# JNI for Android ExoPlayer integration
[target.'cfg(target_os = "android")'.dependencies]
+5 -2
View File
@@ -2,10 +2,13 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default",
"core:path:default"
"core:path:default",
"opener:allow-reveal-item-in-dir"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "updater",
"description": "In-app update: check for a new release, download and install it, then relaunch. Desktop only — tauri-plugin-updater has no Android implementation, and `platforms` keeps these permissions out of the mobile capability set entirely rather than granting something the platform cannot honour.",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"permissions": ["updater:default", "process:allow-restart"]
}
+178
View File
@@ -0,0 +1,178 @@
# cargo-deny configuration for the JellyTau backend.
#
# TRACES: | DR-216
#
# Run locally with: cd src-tauri && cargo deny check
# CI runs the same command in the `security` job of
# .gitea/workflows/build-and-test.yml. cargo-deny is baked into the builder
# image (Dockerfile.builder) -- the advisory database it fetches at run time is
# *data*, not a toolchain, so it does not conflict with the "CI installs no
# system tools" rule.
#
# Four checks run: advisories (known vulnerabilities), licenses (what we are
# allowed to ship), bans (duplicate/undesired crates) and sources (where code
# may come from).
# ---------------------------------------------------------------------------
# Graph scope
#
# Only the targets JellyTau actually ships. This is not a performance tweak --
# it changes which advisories are *real*. Without it the graph includes Apple
# targets, which drag in `plist` -> `quick-xml`, and two quick-xml DoS
# advisories (RUSTSEC-2026-0194/0195) get reported against a crate that is not
# compiled into anything we release. Ignoring them by ID would have been the
# wrong fix: it silences the finding everywhere, including on a target where it
# would matter. Scoping the graph makes the finding correctly absent instead.
#
# Add a target here the day we ship it, and expect new findings with it.
[graph]
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
"aarch64-linux-android",
"armv7-linux-androideabi",
"x86_64-linux-android",
]
all-features = true
[advisories]
# Vulnerabilities and unsoundness are hard errors -- there is deliberately no
# switch here turning them into warnings. Everything below is an explicit,
# justified exception with a named ID; a new advisory fails the build until
# somebody decides what to do about it.
#
# Yanked crates in the lockfile are an error too: a yank usually means the
# author withdrew that exact version for a reason.
yanked = "deny"
ignore = [
# ---------------------------------------------------------------------
# GTK3 bindings: unmaintained, and not ours to replace.
#
# Tauri v2's Linux backend is WebKitGTK, which is GTK3. The gtk-rs project
# has stopped maintaining its GTK3 bindings in favour of GTK4, but Tauri
# cannot move until WebKitGTK does. These arrive through
# tauri -> tauri-runtime-wry -> wry -> gtk, with no version of any of them
# that avoids it ("No safe upgrade is available", per cargo-deny).
#
# Unmaintained != vulnerable: no advisory here describes an exploitable
# defect. Revisit when Tauri ships a GTK4/WebKitGTK-6 backend.
{ id = "RUSTSEC-2024-0411", reason = "gdkwayland-sys: GTK3 binding, pulled in by Tauri's Linux backend" },
{ id = "RUSTSEC-2024-0412", reason = "gdk: GTK3 binding, pulled in by Tauri's Linux backend" },
{ id = "RUSTSEC-2024-0413", reason = "atk: GTK3 binding, pulled in by Tauri's Linux backend" },
{ id = "RUSTSEC-2024-0414", reason = "gdkx11-sys: GTK3 binding, pulled in by Tauri's Linux backend" },
{ id = "RUSTSEC-2024-0415", reason = "gtk: GTK3 binding, pulled in by Tauri's Linux backend" },
{ id = "RUSTSEC-2024-0416", reason = "atk-sys: GTK3 binding, pulled in by Tauri's Linux backend" },
{ id = "RUSTSEC-2024-0417", reason = "gdkx11: GTK3 binding, pulled in by Tauri's Linux backend" },
{ id = "RUSTSEC-2024-0418", reason = "gdk-sys: GTK3 binding, pulled in by Tauri's Linux backend" },
{ id = "RUSTSEC-2024-0419", reason = "gtk3-macros: GTK3 binding, pulled in by Tauri's Linux backend" },
{ id = "RUSTSEC-2024-0420", reason = "gtk-sys: GTK3 binding, pulled in by Tauri's Linux backend" },
# ---------------------------------------------------------------------
# Unmaintained transitive build-time crates. All are proc-macro or
# lookup-table dependencies of Tauri's own toolchain; none has a safe
# upgrade and none is a vulnerability.
{ id = "RUSTSEC-2024-0370", reason = "proc-macro-error: unmaintained proc-macro helper, transitive" },
{ id = "RUSTSEC-2024-0436", reason = "paste: unmaintained macro helper, transitive" },
{ id = "RUSTSEC-2025-0057", reason = "fxhash: unmaintained hasher, transitive" },
# unic-* reach us via urlpattern -> tauri-utils. Unicode table crates,
# superseded upstream but with no drop-in replacement at this depth.
{ id = "RUSTSEC-2025-0075", reason = "unic-char-range: unmaintained, via urlpattern -> tauri-utils" },
{ id = "RUSTSEC-2025-0080", reason = "unic-common: unmaintained, via urlpattern -> tauri-utils" },
{ id = "RUSTSEC-2025-0081", reason = "unic-char-property: unmaintained, via urlpattern -> tauri-utils" },
{ id = "RUSTSEC-2025-0098", reason = "unic-ucd-version: unmaintained, via urlpattern -> tauri-utils" },
{ id = "RUSTSEC-2025-0100", reason = "unic-ucd-ident: unmaintained, via urlpattern -> tauri-utils" },
]
# ---------------------------------------------------------------------------
# Licenses
#
# JellyTau ships as MIT (see ../LICENSE) in deb/rpm/AppImage/NSIS/APK bundles,
# so every crate compiled into those has to be redistributable under terms
# compatible with that. The list is an allow-list on purpose: a new crate with
# an unlisted licence fails the build and gets a decision, rather than being
# shipped because nobody looked.
[licenses]
# A crate offering a choice ("MIT OR Apache-2.0") is satisfied by any allowed
# arm. 0.8 means we accept a licence-file match at >=80% textual confidence.
confidence-threshold = 0.8
allow = [
# Permissive, no redistribution conditions beyond attribution.
"MIT",
"MIT-0",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"0BSD",
"CC0-1.0",
"Unlicense",
"BSL-1.0",
# Unicode data tables (the icu_* family). Permissive, attribution only.
"Unicode-3.0",
# webpki-roots: the Mozilla CA bundle, published as data under CDLA.
"CDLA-Permissive-2.0",
# MPL-2.0 -- weak, *file-level* copyleft (cssparser, selectors, dtoa-short,
# option-ext). The obligation attaches to modified MPL files, not to the
# program that links them, so shipping them unmodified inside an MIT
# application is fine. If we ever patch one of these crates, that patch must
# be published.
"MPL-2.0",
# LGPL-2.1 -- `libmpv` and `libmpv-sys` only, and this one is deliberate.
#
# These are bindings to libmpv, which is itself LGPL-2.1+; the binding
# crates inherit the licence. LGPL permits use from a differently-licensed
# application provided the user can substitute their own build of the
# library, which dynamic linking satisfies -- libmpv-sys links the *system*
# shared object (the builder image installs libmpv-dev; the deb/rpm declare
# a runtime dependency) rather than statically embedding it.
#
# 🔴 Two obligations follow, and they are ours, not cargo-deny's:
# - keep the linkage dynamic (do not switch libmpv-sys to a vendored
# static build without revisiting this),
# - ship libmpv's licence text with any bundle that carries the .so,
# which currently means the AppImage.
"LGPL-2.1",
]
# Crates whose licence field is missing or unparseable get a per-crate
# clarification here rather than a blanket relaxation. Empty today.
exceptions = []
[bans]
# Duplicate versions are noise, not danger: a Tauri-sized graph legitimately
# carries several `windows-sys` and `bitflags` majors because its dependencies
# upgrade at different rates. Warn so the count stays visible; do not fail.
multiple-versions = "warn"
# The only wildcard in the graph is the `libmpv` git dependency: a git dep
# carries no semver requirement, so cargo-deny counts it as `*` no matter how it
# is pinned. It is pinned by `rev` in Cargo.toml and by hash in Cargo.lock, and
# `[sources].allow-git` below is the check that actually constrains it -- so
# "deny" here would fail the build forever over something already controlled
# twice. Warn, so a *second* wildcard still shows up.
wildcards = "warn"
# `cargo build` order for equal-priority features; keeps the check deterministic.
highlight = "all"
deny = []
skip = []
[sources]
# Anything not from crates.io needs to be named here. This is the check that
# would notice a dependency being repointed at somebody's fork.
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# The one git dependency. Cargo.toml pins it by branch, not by revision, which
# is worth knowing: the `master` it resolves to is whatever the lockfile has
# recorded, and `cargo update` will move it. It exists because the crates.io
# release of libmpv predates the MPV versions we support.
allow-git = ["https://github.com/ParadoxSpiral/libmpv-rs.git"]
+2 -1
View File
@@ -319,7 +319,8 @@ async fn read_last_sync(
///
/// Replaces the frontend's startup-only `syncCatalog()` call: index freshness is
/// sync policy and belongs in Rust (see the layer assignment in
/// docs/specs/catalog-index-search.md). Ticks every [`CATALOG_INDEX_TICK`] and
/// docs/architecture/03-data-flow.md, "Search Flow"). Ticks every
/// [`CATALOG_INDEX_TICK`] and
/// runs a pass when a repository exists, the server is reachable, and the index
/// is older than [`CATALOG_INDEX_TTL`].
///
+330
View File
@@ -0,0 +1,330 @@
//! Diagnostics: log level control and the exportable bug-report bundle.
//!
//! TRACES: UR-078 | DR-218
//!
//! The app used to forget everything it did the moment it exited. A user
//! reporting "the episode randomly restarted" was reporting the symptom of a
//! race whose evidence had been discarded microseconds later, and the only way
//! to recover it was to talk them through `adb logcat` — which is how several
//! bugs in this project's history actually got diagnosed.
//!
//! This module is the other half of that: the log is on disk, it survives a
//! crash, and the user can hand the whole thing over as one file.
//!
//! Everything written here has been through [`crate::utils::diagnostics::redact`]
//! twice — once in the log formatter, and again on the way into the archive, so
//! files written by a build that predates the formatter pass are covered too.
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use log::{info, warn, LevelFilter};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::utils::diagnostics::{redact, redact_server_url};
/// Where an export landed, so the UI can tell the user where to find it.
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticsBundle {
/// Absolute path to the written archive.
pub path: String,
pub size_bytes: u64,
/// How many log files went in, excluding the environment summary.
pub file_count: usize,
}
/// Where logs live and how verbose they currently are.
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticsInfo {
/// Directory holding the rotating log files.
pub log_dir: String,
/// Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
pub level: String,
/// Total bytes currently held by log files.
pub total_size_bytes: u64,
}
/// Name of the file holding the user's chosen level, in the app config dir.
const LEVEL_FILE: &str = "log-level";
/// Parse a stored/user-supplied level name.
///
/// Unknown values fall back to Info rather than erroring: this is read at
/// startup, and a corrupt one-line file must not stop the app from launching.
pub fn parse_level(raw: &str) -> LevelFilter {
match raw.trim().to_ascii_lowercase().as_str() {
"error" => LevelFilter::Error,
"warn" => LevelFilter::Warn,
"debug" => LevelFilter::Debug,
"trace" => LevelFilter::Trace,
_ => LevelFilter::Info,
}
}
/// Read the persisted level, if the user has ever set one.
///
/// Persisted rather than session-only on purpose: somebody reproducing a bug
/// needs debug logging to survive *the restart that reproduces it*.
pub fn stored_level(config_dir: &Path) -> Option<LevelFilter> {
fs::read_to_string(config_dir.join(LEVEL_FILE))
.ok()
.map(|raw| parse_level(&raw))
}
fn level_name(level: LevelFilter) -> &'static str {
match level {
LevelFilter::Off => "off",
LevelFilter::Error => "error",
LevelFilter::Warn => "warn",
LevelFilter::Info => "info",
LevelFilter::Debug => "debug",
LevelFilter::Trace => "trace",
}
}
/// Current log level and where the files are.
///
/// TRACES: UR-078 | DR-218
#[tauri::command]
#[specta::specta]
pub async fn diagnostics_get_info(app: AppHandle) -> Result<DiagnosticsInfo, String> {
let log_dir = app
.path()
.app_log_dir()
.map_err(|e| format!("no log directory: {e}"))?;
let total_size_bytes = log_files(&log_dir)
.iter()
.filter_map(|p| fs::metadata(p).ok())
.map(|m| m.len())
.sum();
Ok(DiagnosticsInfo {
log_dir: log_dir.to_string_lossy().to_string(),
level: level_name(log::max_level()).to_string(),
total_size_bytes,
})
}
/// Set the log level, for this session and the next.
///
/// TRACES: UR-078 | DR-218
#[tauri::command]
#[specta::specta]
pub async fn diagnostics_set_level(app: AppHandle, level: String) -> Result<String, String> {
let parsed = parse_level(&level);
log::set_max_level(parsed);
let config_dir = app
.path()
.app_config_dir()
.map_err(|e| format!("no config directory: {e}"))?;
fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
fs::write(config_dir.join(LEVEL_FILE), level_name(parsed)).map_err(|e| e.to_string())?;
info!("[DIAG] log level set to {}", level_name(parsed));
Ok(level_name(parsed).to_string())
}
/// Collect every log file in the log directory, newest first.
fn log_files(log_dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(log_dir) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
p.extension()
.is_some_and(|ext| ext == "log" || ext == "txt")
})
.collect();
files.sort();
files.reverse();
files
}
/// A short, non-identifying description of the environment.
///
/// Deliberately excludes the token, the username, and any path outside the
/// app's own directories. The server URL is reduced to scheme and host, which is
/// diagnostic (https? LAN address? reverse proxy?) without being a credential.
fn environment_summary(app: &AppHandle, server_url: Option<&str>) -> String {
let package = app.package_info();
let mut out = String::new();
out.push_str("JellyTau diagnostics\n");
out.push_str("====================\n\n");
out.push_str(&format!("app version: {}\n", package.version));
out.push_str(&format!("tauri version: {}\n", tauri::VERSION));
out.push_str(&format!("os: {}\n", std::env::consts::OS));
out.push_str(&format!("arch: {}\n", std::env::consts::ARCH));
out.push_str(&format!("debug build: {}\n", cfg!(debug_assertions)));
out.push_str(&format!(
"log level: {}\n",
level_name(log::max_level())
));
out.push_str(&format!(
"server: {}\n",
server_url.map_or("not configured".to_string(), redact_server_url)
));
out.push_str("\nNo access token, password or username is included in this file.\n");
out
}
/// Write a redacted diagnostics archive and return where it went.
///
/// # Blocking I/O
///
/// This reads and rewrites every log file. It is an `async` command so it does
/// not block the IPC thread, but it must never be called from a player event
/// callback — see the deadlock note in CLAUDE.md.
///
/// TRACES: UR-078 | DR-218
#[tauri::command]
#[specta::specta]
pub async fn diagnostics_export(
app: AppHandle,
server_url: Option<String>,
) -> Result<DiagnosticsBundle, String> {
let log_dir = app
.path()
.app_log_dir()
.map_err(|e| format!("no log directory: {e}"))?;
// Written into the app's own data directory. Choosing an arbitrary
// user-visible location would need a file dialog on desktop and a storage
// permission on Android; the UI reports the path and can reveal it.
let out_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("no data directory: {e}"))?;
fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
let archive_path = out_dir.join("jellytau-diagnostics.zip");
let file = fs::File::create(&archive_path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipWriter::new(file);
let options: zip::write::FileOptions<'_, ()> =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let files = log_files(&log_dir);
let mut written = 0usize;
for path in &files {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let mut contents = String::new();
match fs::File::open(path).and_then(|mut f| f.read_to_string(&mut contents)) {
Ok(_) => {}
Err(e) => {
// A log we cannot read is not a reason to produce no bundle.
warn!("[DIAG] skipping unreadable log {name}: {e}");
continue;
}
}
// Second redaction pass. The formatter already cleaned anything this
// build wrote; this covers files left by an older build.
let cleaned: String = contents.lines().map(redact).collect::<Vec<_>>().join("\n");
zip.start_file(name, options).map_err(|e| e.to_string())?;
zip.write_all(cleaned.as_bytes())
.map_err(|e| e.to_string())?;
written += 1;
}
zip.start_file("environment.txt", options)
.map_err(|e| e.to_string())?;
zip.write_all(environment_summary(&app, server_url.as_deref()).as_bytes())
.map_err(|e| e.to_string())?;
zip.finish().map_err(|e| e.to_string())?;
let size_bytes = fs::metadata(&archive_path)
.map_err(|e| e.to_string())?
.len();
info!(
"[DIAG] exported {written} log file(s), {size_bytes} bytes -> {}",
archive_path.display()
);
Ok(DiagnosticsBundle {
path: archive_path.to_string_lossy().to_string(),
size_bytes,
file_count: written,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_every_level_name_case_insensitively() {
assert_eq!(parse_level("debug"), LevelFilter::Debug);
assert_eq!(parse_level("DEBUG"), LevelFilter::Debug);
assert_eq!(parse_level(" warn\n"), LevelFilter::Warn);
assert_eq!(parse_level("error"), LevelFilter::Error);
assert_eq!(parse_level("trace"), LevelFilter::Trace);
}
#[test]
fn unknown_level_falls_back_to_info_rather_than_failing() {
// This is read at startup from a file on disk. A corrupt value must not
// stop the app launching.
assert_eq!(parse_level("banana"), LevelFilter::Info);
assert_eq!(parse_level(""), LevelFilter::Info);
}
#[test]
fn level_names_round_trip() {
for name in ["error", "warn", "info", "debug", "trace"] {
assert_eq!(level_name(parse_level(name)), name);
}
}
#[test]
fn stored_level_is_none_when_never_set() {
let dir = std::env::temp_dir().join("jellytau-diag-test-empty");
let _ = fs::create_dir_all(&dir);
let _ = fs::remove_file(dir.join(LEVEL_FILE));
assert!(stored_level(&dir).is_none());
}
#[test]
fn stored_level_reads_back_what_was_written() {
let dir = std::env::temp_dir().join("jellytau-diag-test-roundtrip");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(LEVEL_FILE), "debug").unwrap();
assert_eq!(stored_level(&dir), Some(LevelFilter::Debug));
let _ = fs::remove_file(dir.join(LEVEL_FILE));
}
#[test]
fn log_files_ignores_non_log_files() {
let dir = std::env::temp_dir().join("jellytau-diag-test-listing");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("jellytau.log"), "x").unwrap();
fs::write(dir.join("notes.md"), "x").unwrap();
fs::write(dir.join("jellytau.zip"), "x").unwrap();
let found = log_files(&dir);
let names: Vec<String> = found
.iter()
.filter_map(|p| p.file_name()?.to_str().map(String::from))
.collect();
assert!(names.contains(&"jellytau.log".to_string()));
// The export archive itself lives elsewhere, but never re-zip a zip.
assert!(!names.contains(&"jellytau.zip".to_string()));
assert!(!names.contains(&"notes.md".to_string()));
let _ = fs::remove_dir_all(&dir);
}
}
+2
View File
@@ -6,6 +6,7 @@ pub mod catalog;
pub mod connectivity;
pub mod conversions;
pub mod device;
pub mod diagnostics;
pub mod download;
pub mod favorites;
pub mod library;
@@ -25,6 +26,7 @@ pub use catalog::*;
pub use connectivity::*;
pub use conversions::*;
pub use device::*;
pub use diagnostics::*;
pub use download::*;
pub use library::*;
pub use offline::*;
+100 -4
View File
@@ -58,6 +58,10 @@ use commands::{
// Device commands
device_get_id,
device_set_id,
// Diagnostics commands
diagnostics_export,
diagnostics_get_info,
diagnostics_set_level,
download_album,
download_item,
download_item_and_start,
@@ -993,6 +997,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
playlist_add_items,
playlist_remove_items,
playlist_move_item,
// Diagnostics commands
// TRACES: UR-078 | DR-218
diagnostics_get_info,
diagnostics_set_level,
diagnostics_export,
// Conversion commands
format_time_seconds,
format_time_seconds_long,
@@ -1099,12 +1108,67 @@ fn set_env_if_unset(key: &str, value: &str) {
/// through `convertFileSrc` again.
///
/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
/// Build the logging plugin.
///
/// Replaces the previous `env_logger` init, which wrote to **stdout only**. That
/// was invisible to anyone who launched from a desktop icon, and worse than
/// useless on Android: stdout is not logcat, so the Rust backend produced no
/// visible output at all on the platform carrying the hardest bugs in this
/// project's history (the autoplay deadlock, the truncated-stream restart, the
/// background-audio stall). tauri-plugin-log routes to logcat there for free.
///
/// Three decisions worth keeping:
///
/// * **Every line goes through `redact` first.** A credential must never reach
/// disk, not merely be stripped later when a bundle is exported — a file on
/// the device is already the disclosure.
/// * **The size cap is deliberate.** `RotationStrategy::KeepAll` would let a
/// long-running session fill a phone. One rotation keeps yesterday's evidence
/// without unbounded growth.
/// * **The level is read from disk.** Someone reproducing a bug needs debug
/// logging to survive the restart that reproduces it.
///
/// TRACES: UR-078 | DR-218
fn build_log_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
use tauri_plugin_log::{Target, TargetKind};
let mut targets = vec![
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir {
file_name: Some("jellytau".to_string()),
}),
];
// Rust lines in the webview console, so a developer sees both halves of the
// app in one place. Dev only -- in a release build this would ship backend
// logging into a console the user can open.
if cfg!(debug_assertions) {
targets.push(Target::new(TargetKind::Webview));
}
tauri_plugin_log::Builder::new()
.targets(targets)
.level(log::LevelFilter::Info)
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
.format(|out, message, record| {
out.finish(format_args!(
"[{}][{}] {}",
record.level(),
record.target(),
crate::utils::diagnostics::redact(&message.to_string())
))
})
.build()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize logger
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
// Crash capture before anything else, so a panic during startup is recorded
// rather than vanishing with the process.
//
// TRACES: UR-078 | DR-218
crate::utils::diagnostics::install_panic_hook();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
@@ -1121,6 +1185,7 @@ pub fn run() {
let invoke_handler = builder.invoke_handler();
tauri::Builder::default()
.plugin(build_log_plugin())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.invoke_handler(invoke_handler)
@@ -1129,6 +1194,37 @@ pub fn run() {
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// In-app update, desktop only.
//
// Registered here rather than in the builder chain above because a
// `#[cfg]` attribute cannot be attached to one link of a method
// chain -- this block is the shape Tauri's own docs use.
//
// Android is excluded on purpose: tauri-plugin-updater cannot
// replace an installed APK, and the frontend offers the releases
// page there instead.
//
// Re-apply the log level the user last chose. Without this the
// picker would only affect the running session -- and the whole
// point of a persisted level is that somebody reproducing a bug
// keeps debug logging across the restart that reproduces it.
//
// TRACES: UR-078 | DR-218
if let Ok(config_dir) = app.path().app_config_dir() {
if let Some(level) = crate::commands::diagnostics::stored_level(&config_dir) {
log::set_max_level(level);
log::info!("[DIAG] restored log level from settings: {level}");
}
}
// TRACES: UR-077 | DR-217
#[cfg(desktop)]
{
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
}
// Initialize database with proper app data directory
// Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+1 -1
View File
@@ -630,7 +630,7 @@ impl PlayerBackend for MpvBackend {
// peaking bands and (optionally) a dynamic loudness normalizer, and
// set the `af` property. An empty string clears all filters. Both
// features share one `af` graph because MPV exposes a single filter
// property. See docs/specs/audio-equalizer.md and IR-020.
// property. See docs/architecture/05-platform-backends.md and IR-020.
let af = build_af_filter(settings);
self.mpv
.set_property("af", af.as_str())
+5 -2
View File
@@ -28,7 +28,9 @@ impl VolumeLevel {
/// Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count
/// and layout are a property of the audio engine, not the UI — presets and the
/// MPV filter are defined against these bands. See docs/specs/audio-equalizer.md.
/// MPV filter are defined against these bands. See
/// docs/architecture/05-platform-backends.md ("The equalizer, and where its
/// vocabulary lives").
///
/// TRACES: UR-027 | DR-030, IR-020
pub const EQ_BANDS: [f32; 10] = [
@@ -159,7 +161,8 @@ impl AudioSettings {
/// The ladder is deliberately expressed in bandwidth rather than resolution: 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. See docs/specs/streaming-bitrate-cap.md.
/// afford. See docs/architecture/01-rust-backend.md ("Streaming quality
/// ladder").
///
/// TRACES: UR-074 | DR-162
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+399
View File
@@ -0,0 +1,399 @@
//! Credential redaction and crash capture for diagnostic logs.
//!
//! TRACES: UR-078 | DR-218
//!
//! ## Why redaction lives here and not at the export
//!
//! A diagnostic bundle is something a user attaches to a public bug report. If a
//! Jellyfin access token can reach it, this feature is a credential-disclosure
//! bug with a friendly button on it.
//!
//! So [`redact`] runs in the log *formatter* — the token never reaches disk —
//! and again over every line the exporter copies, which covers files written by
//! an older build that lacked the formatter pass. Redacting only at export would
//! leave the secret sitting in a file on the device, which is exactly the thing
//! we are trying not to do.
//!
//! ## What is deliberately NOT redacted
//!
//! Server host, item ids, filenames and paths inside the app's own directories
//! all stay. They are not secrets and they are the entire diagnostic value of a
//! log: a bundle scrubbed of them is one nobody can debug anything from.
use std::borrow::Cow;
/// Replacement for a redacted value.
pub const REDACTED: &str = "[REDACTED]";
/// Query-string parameters whose value is a credential.
///
/// Jellyfin accepts the API key under several spellings depending on the
/// endpoint and client generation, and this codebase has emitted more than one
/// of them over time.
const SECRET_QUERY_KEYS: &[&str] = &["api_key", "apikey", "x-emby-token", "accesstoken"];
/// Header names whose value is a credential.
const SECRET_HEADERS: &[&str] = &[
"x-emby-token",
"x-mediabrowser-token",
"authorization",
"x-emby-authorization",
];
/// JSON keys whose value is a credential.
const SECRET_JSON_KEYS: &[&str] = &["accesstoken", "password", "token"];
/// Strip credentials from one log line.
///
/// Idempotent: redacting an already-redacted line changes nothing, which matters
/// because the exporter may re-process a file the formatter already cleaned.
pub fn redact(line: &str) -> String {
let mut out = redact_query_params(line);
out = redact_headers(&out);
out = redact_json_values(&out);
out = redact_emby_auth(&out);
out
}
/// `?api_key=abc&x=1` -> `?api_key=[REDACTED]&x=1`
///
/// The value ends at the first character that cannot be part of one: `&`
/// separates parameters, and whitespace/quotes mean the URL itself ended.
fn redact_query_params(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let lower = line.to_ascii_lowercase();
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
let mut matched = None;
for key in SECRET_QUERY_KEYS {
// A key only counts when it is preceded by ? or & (or starts the
// line), so a *word* like "token" inside prose is left alone.
if lower[i..].starts_with(key) {
let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
let is_param_start = matches!(prev, None | Some(b'?') | Some(b'&'));
let after = i + key.len();
if is_param_start && after < bytes.len() && bytes[after] == b'=' {
matched = Some((*key, after + 1));
break;
}
}
}
match matched {
Some((key, value_start)) => {
result.push_str(&line[i..i + key.len()]);
result.push('=');
result.push_str(REDACTED);
let mut end = value_start;
while end < bytes.len()
&& !matches!(bytes[end], b'&' | b' ' | b'"' | b'\'' | b'\t' | b')')
{
end += 1;
}
i = end;
}
None => {
// Advance one whole char, not one byte: a UTF-8 boundary split
// would panic on the slice above.
let ch = line[i..].chars().next().unwrap_or('\0');
result.push(ch);
i += ch.len_utf8();
}
}
}
result
}
/// `X-Emby-Token: abc` -> `X-Emby-Token: [REDACTED]`
///
/// Scans forward from a cursor rather than recursing on the rewritten string.
/// The obvious recursive version does not terminate: the replacement keeps the
/// header *name*, so the next call finds the same header again and recurses
/// until the stack is gone. A test provoked exactly that.
fn redact_headers(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
'outer: loop {
let lower = rest.to_ascii_lowercase();
// Earliest header match in what remains, so several headers on one line
// are handled left to right.
let mut best: Option<(usize, usize)> = None;
for header in SECRET_HEADERS {
let needle = format!("{header}:");
if let Some(pos) = lower.find(&needle) {
let candidate = (pos, needle.len());
if best.is_none_or(|(best_pos, _)| pos < best_pos) {
best = Some(candidate);
}
}
}
let Some((pos, needle_len)) = best else {
break 'outer;
};
let value_start = pos + needle_len;
// The value runs to the next comma or the end of the line: reqwest's
// debug output prints several headers comma-separated on one line.
let value_end = rest[value_start..]
.find(',')
.map_or(rest.len(), |c| value_start + c);
out.push_str(&rest[..value_start]);
out.push(' ');
out.push_str(REDACTED);
// Continue strictly *after* the value just handled -- this is what makes
// the loop terminate.
rest = &rest[value_end..];
}
out.push_str(rest);
out
}
/// `"AccessToken":"abc"` -> `"AccessToken":"[REDACTED]"`
fn redact_json_values(line: &str) -> String {
let mut out = Cow::Borrowed(line);
for key in SECRET_JSON_KEYS {
loop {
let lower = out.to_ascii_lowercase();
let pattern = format!("\"{key}\"");
let Some(key_pos) = lower.find(&pattern) else {
break;
};
// Find the opening quote of the value after the colon.
let after_key = key_pos + pattern.len();
let Some(colon_rel) = out[after_key..].find(':') else {
break;
};
let value_region = after_key + colon_rel + 1;
let Some(open_rel) = out[value_region..].find('"') else {
break;
};
let open = value_region + open_rel;
let Some(close_rel) = out[open + 1..].find('"') else {
break;
};
let close = open + 1 + close_rel;
// Already redacted: stop, or this loops forever.
if &out[open + 1..close] == REDACTED {
break;
}
let mut replaced = String::with_capacity(out.len());
replaced.push_str(&out[..open + 1]);
replaced.push_str(REDACTED);
replaced.push_str(&out[close..]);
out = Cow::Owned(replaced);
}
}
out.into_owned()
}
/// `MediaBrowser Token="abc"` -> `MediaBrowser Token="[REDACTED]"`
///
/// Jellyfin's own auth header format, which is not JSON and not a query param.
fn redact_emby_auth(line: &str) -> String {
let lower = line.to_ascii_lowercase();
let Some(pos) = lower.find("token=\"") else {
return line.to_string();
};
let open = pos + "token=\"".len();
let Some(close_rel) = line[open..].find('"') else {
return line.to_string();
};
let close = open + close_rel;
if &line[open..close] == REDACTED {
return line.to_string();
}
let mut out = String::with_capacity(line.len());
out.push_str(&line[..open]);
out.push_str(REDACTED);
out.push_str(&line[close..]);
out
}
/// Reduce a server URL to scheme and host.
///
/// The host is diagnostic (is it https? a LAN address? a reverse proxy?); the
/// path and any query on it are not, and a configured URL has been seen to carry
/// a token.
pub fn redact_server_url(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return REDACTED.to_string();
};
let after_scheme = scheme_end + 3;
let host_end = url[after_scheme..]
.find('/')
.map_or(url.len(), |slash| after_scheme + slash);
// Credentials embedded as user:pass@host must not survive.
let host = &url[after_scheme..host_end];
let host = host.rsplit('@').next().unwrap_or(host);
format!("{}://{}", &url[..scheme_end], host)
}
/// Install a panic hook that records the panic through `log::error!` before the
/// default hook runs.
///
/// # Why it chains rather than replaces
///
/// `utils::lock` installs a silencing hook around its own tests, which
/// deliberately provoke poisoned locks. Replacing the current hook here would
/// make that test output scream about panics it is intentionally causing — and,
/// more importantly, replacing whatever hook is present is how you lose the
/// backtrace the runtime would otherwise print.
pub fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// The payload is very often the formatted message of a `panic!`, so it
// goes through redaction like any other line: a panic inside the HTTP
// layer can carry a URL.
let payload = panic_payload_string(info);
let location = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "unknown location".to_string());
log::error!("PANIC at {location}: {}", redact(&payload));
log::error!("backtrace:\n{}", std::backtrace::Backtrace::force_capture());
previous(info);
}));
}
/// Extract a printable message from a panic payload.
fn panic_payload_string(info: &std::panic::PanicHookInfo<'_>) -> String {
let payload = info.payload();
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"non-string panic payload".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_api_key_query_parameter() {
let line = "GET https://media.example.com/Items?api_key=abc123def&Limit=50";
let out = redact(line);
assert!(!out.contains("abc123def"), "token survived: {out}");
assert!(out.contains("api_key=[REDACTED]"));
// The rest of the URL is what makes the line worth keeping.
assert!(out.contains("media.example.com"));
assert!(out.contains("Limit=50"));
}
#[test]
fn redacts_every_spelling_of_the_key_parameter() {
for key in ["api_key", "ApiKey", "X-Emby-Token", "AccessToken"] {
let line = format!("https://h/Items?{key}=SECRETVALUE&x=1");
let out = redact(&line);
assert!(!out.contains("SECRETVALUE"), "{key} survived: {out}");
assert!(out.contains("x=1"), "{key} ate the next parameter: {out}");
}
}
#[test]
fn redacts_auth_headers() {
let out = redact("request headers: X-Emby-Token: abc123, Accept: application/json");
assert!(!out.contains("abc123"), "{out}");
// A following header must survive -- the value stops at the comma.
assert!(out.contains("Accept: application/json"), "{out}");
}
#[test]
fn redacts_authorization_header() {
let out = redact("Authorization: Bearer verysecrettoken");
assert!(!out.contains("verysecrettoken"), "{out}");
}
#[test]
fn redacts_json_access_token() {
let out = redact(r#"login response {"User":{"Name":"duncan"},"AccessToken":"abc123"}"#);
assert!(!out.contains("abc123"), "{out}");
// The username is not a credential and is diagnostic.
assert!(out.contains("duncan"), "{out}");
}
#[test]
fn redacts_the_emby_auth_header_form() {
let line = r#"MediaBrowser Client="JellyTau", Token="abc123xyz""#;
let out = redact(line);
assert!(!out.contains("abc123xyz"), "{out}");
assert!(out.contains("JellyTau"), "{out}");
}
#[test]
fn is_idempotent() {
// The exporter re-processes files the formatter already cleaned; a
// second pass must not corrupt them or loop.
let once = redact("https://h/Items?api_key=abc&z=1");
let twice = redact(&once);
assert_eq!(once, twice);
}
#[test]
fn leaves_ordinary_lines_untouched() {
let line = "player: advancing to next episode (item 4f2a, position 0)";
assert_eq!(redact(line), line);
}
#[test]
fn does_not_redact_the_word_token_in_prose() {
// "token" appears in comments and messages constantly. Only a real
// parameter or header assignment should trigger.
let line = "refreshing the access token because the session expired";
assert_eq!(redact(line), line);
}
#[test]
fn handles_multibyte_characters_without_panicking() {
// The scanner walks bytes; a naive implementation slices mid-character.
let line = "playing “Où est le café” from https://h/Items?api_key=abc";
let out = redact(line);
assert!(!out.contains("abc"), "{out}");
assert!(out.contains("café"), "{out}");
}
#[test]
fn server_url_keeps_scheme_and_host_only() {
assert_eq!(
redact_server_url("https://media.example.com/jellyfin?api_key=abc"),
"https://media.example.com"
);
assert_eq!(
redact_server_url("http://192.168.1.10:8096/"),
"http://192.168.1.10:8096"
);
}
#[test]
fn server_url_drops_embedded_credentials() {
// http://user:password@host is a valid URL and has been pasted into
// server-address fields before.
assert_eq!(
redact_server_url("https://duncan:hunter2@media.example.com/"),
"https://media.example.com"
);
}
#[test]
fn server_url_without_a_scheme_is_refused_rather_than_guessed() {
assert_eq!(redact_server_url("media.example.com"), REDACTED);
}
}
+1
View File
@@ -1,2 +1,3 @@
pub mod conversions;
pub mod diagnostics;
pub mod lock;
+12
View File
@@ -31,11 +31,23 @@
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBMEY0NDJDRDAxRUU3NkMKUldSczV4N1FMRVFQaXNXSlV6U3RXdk5qT2NCY0s2eTZ2Q3RYS25MNnNKY09HcU5LSTJjUUx3d3MK",
"endpoints": [
"https://gitea.tourolle.paris/dtourolle/jellytau/raw/branch/updater/latest.json"
],
"windows": {
"installMode": "passive"
}
}
},
"bundle": {
"active": true,
"targets": [
"deb",
"rpm",
"appimage",
"nsis"
],
"icon": [
+6 -2
View File
@@ -46,7 +46,8 @@
}
/* Global styles */
html, body {
html,
body {
@apply h-full;
background-color: var(--color-background);
}
@@ -77,5 +78,8 @@ html[data-native-video="active"] [data-app-shell] {
body {
@apply text-white antialiased;
font-family: system-ui, -apple-system, sans-serif;
font-family:
system-ui,
-apple-system,
sans-serif;
}
+1 -4
View File
@@ -9,10 +9,7 @@
and the bottom nav renders under the Android navigation bar. See
$lib/utils/safeArea.ts for the other half (native WindowInsets → CSS vars).
-->
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>JellyTau</title>
%sveltekit.head%
</head>
+2 -6
View File
@@ -112,9 +112,7 @@ describe("autoplay API", () => {
await setAutoplaySettings(settings);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_set_autoplay_settings"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_set_autoplay_settings");
expect(call).toBeDefined();
expect(call![1]).toEqual({ userId: "user-1", settings });
});
@@ -155,9 +153,7 @@ describe("autoplay API", () => {
const { invoke } = await import("@tauri-apps/api/core");
const invokeSpy = vi.mocked(invoke);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_play_next_episode"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_play_next_episode");
expect(call).toBeDefined();
expect(call![1]).toEqual({ item: mockItem });
});
+1 -3
View File
@@ -14,9 +14,7 @@ export async function getAutoplaySettings(): Promise<AutoplaySettings> {
return commands.playerGetAutoplaySettings();
}
export async function setAutoplaySettings(
settings: AutoplaySettings
): Promise<AutoplaySettings> {
export async function setAutoplaySettings(settings: AutoplaySettings): Promise<AutoplaySettings> {
return commands.playerSetAutoplaySettings(auth.getUserId() ?? "", settings);
}
+14 -22
View File
@@ -77,7 +77,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
sortBy: sortField,
}),
})
}),
);
}
});
@@ -99,7 +99,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
sortOrder: "Descending",
}),
})
}),
);
});
@@ -148,7 +148,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
includeItemTypes: ["Audio", "MusicAlbum"],
}),
})
}),
);
});
@@ -168,7 +168,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
genres: ["Rock", "Jazz"],
}),
})
}),
);
});
@@ -195,7 +195,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
"repository_search",
expect.objectContaining({
query: "query",
})
}),
);
});
@@ -217,7 +217,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
startIndex: 100,
limit: 50,
}),
})
}),
);
});
});
@@ -238,7 +238,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
"repository_search",
expect.objectContaining({
query: "query",
})
}),
);
expect(result.items.length).toBe(2);
@@ -260,7 +260,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
includeItemTypes: ["Audio"],
}),
})
}),
);
});
@@ -302,7 +302,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
expect.objectContaining({
itemId: "item123",
imageType: "Primary",
})
}),
);
});
@@ -327,23 +327,18 @@ describe("Backend Integration - Refactored Business Logic", () => {
const url = await client.getVideoStreamUrl("item123");
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_video_stream_url",
expect.any(Object)
);
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", expect.any(Object));
});
it("should get subtitle URLs from backend", async () => {
const backendUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
const backendUrl =
"https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getSubtitleUrl("item123", "source456", 0);
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_subtitle_url",
expect.any(Object)
);
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", expect.any(Object));
});
it("should get video download URLs from backend", async () => {
@@ -353,10 +348,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
const url = await client.getVideoDownloadUrl("item123", "medium");
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_video_download_url",
expect.any(Object)
);
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", expect.any(Object));
});
it("should never expose access token in frontend code", async () => {
+60 -1
View File
@@ -1763,6 +1763,36 @@ async playlistRemoveItems(handle: string, playlistId: string, entryIds: string[]
async playlistMoveItem(handle: string, playlistId: string, itemId: string, newIndex: number) : Promise<null> {
return await TAURI_INVOKE("playlist_move_item", { handle, playlistId, itemId, newIndex });
},
/**
* Current log level and where the files are.
*
* TRACES: UR-078 | DR-218
*/
async diagnosticsGetInfo() : Promise<DiagnosticsInfo> {
return await TAURI_INVOKE("diagnostics_get_info");
},
/**
* Set the log level, for this session and the next.
*
* TRACES: UR-078 | DR-218
*/
async diagnosticsSetLevel(level: string) : Promise<string> {
return await TAURI_INVOKE("diagnostics_set_level", { level });
},
/**
* Write a redacted diagnostics archive and return where it went.
*
* # Blocking I/O
*
* This reads and rewrites every log file. It is an `async` command so it does
* not block the IPC thread, but it must never be called from a player event
* callback see the deadlock note in CLAUDE.md.
*
* TRACES: UR-078 | DR-218
*/
async diagnosticsExport(serverUrl: string | null) : Promise<DiagnosticsBundle> {
return await TAURI_INVOKE("diagnostics_export", { serverUrl });
},
/**
* Format time in seconds to MM:SS display string
*
@@ -2037,6 +2067,34 @@ connectionError: string | null;
* Whether we're currently checking connectivity
*/
isChecking: boolean }
/**
* Where an export landed, so the UI can tell the user where to find it.
*/
export type DiagnosticsBundle = {
/**
* Absolute path to the written archive.
*/
path: string; sizeBytes: number;
/**
* How many log files went in, excluding the environment summary.
*/
fileCount: number }
/**
* Where logs live and how verbose they currently are.
*/
export type DiagnosticsInfo = {
/**
* Directory holding the rotating log files.
*/
logDir: string;
/**
* Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
*/
level: string;
/**
* Total bytes currently held by log files.
*/
totalSizeBytes: number }
/**
* On-disk usage of downloaded content, for the Downloads surface.
*
@@ -3108,7 +3166,8 @@ export type StreamQualityResponse =
* The ladder is deliberately expressed in bandwidth rather than resolution: 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. See docs/specs/streaming-bitrate-cap.md.
* afford. See docs/architecture/01-rust-backend.md ("Streaming quality
* ladder").
*
* TRACES: UR-074 | DR-162
*/
+22 -15
View File
@@ -96,7 +96,8 @@ describe("RepositoryClient", () => {
});
it("should pass multiple image options to backend", async () => {
const mockUrl = "https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token";
const mockUrl =
"https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token";
(invoke as any).mockResolvedValueOnce(mockUrl);
const options = {
@@ -133,9 +134,7 @@ describe("RepositoryClient", () => {
it("should throw error if not initialized before getImageUrl", async () => {
const newClient = new RepositoryClient();
await expect(newClient.getImageUrl("item123")).rejects.toThrow(
"Repository not initialized"
);
await expect(newClient.getImageUrl("item123")).rejects.toThrow("Repository not initialized");
});
});
@@ -243,7 +242,7 @@ describe("RepositoryClient", () => {
"repository_get_video_download_url",
expect.objectContaining({
quality,
})
}),
);
}
});
@@ -314,9 +313,7 @@ describe("RepositoryClient", () => {
it("should search with backend search command", async () => {
const mockResult = {
items: [
{ id: "item1", name: "Search Result 1", type: "Audio" },
],
items: [{ id: "item1", name: "Search Result 1", type: "Audio" }],
totalRecordCount: 1,
};
(invoke as any).mockResolvedValueOnce(mockResult);
@@ -353,7 +350,10 @@ describe("RepositoryClient", () => {
});
it("should get downloaded items with camelCase params", async () => {
const mockResult = { items: [{ id: "t1", name: "Track", type: "Audio" }], totalRecordCount: 1 };
const mockResult = {
items: [{ id: "t1", name: "Track", type: "Audio" }],
totalRecordCount: 1,
};
(invoke as any).mockResolvedValueOnce(mockResult);
const result = await client.getDownloadedItems("album1", { limit: 50 });
@@ -367,7 +367,12 @@ describe("RepositoryClient", () => {
});
it("should get download disk usage from backend", async () => {
const mockUsage = { sizes: { t1: 1000 }, partialContainers: {}, deviceTotalBytes: 1000, itemCount: 1 };
const mockUsage = {
sizes: { t1: 1000 },
partialContainers: {},
deviceTotalBytes: 1000,
itemCount: 1,
};
(invoke as any).mockResolvedValueOnce(mockUsage);
const usage = await client.getDownloadDiskUsage();
@@ -589,7 +594,9 @@ describe("RepositoryClient", () => {
it("should throw error if not initialized before playlist operations", async () => {
const newClient = new RepositoryClient();
await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow("Repository not initialized");
await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow(
"Repository not initialized",
);
await expect(newClient.createPlaylist("test")).rejects.toThrow("Repository not initialized");
await expect(newClient.deletePlaylist("pl-1")).rejects.toThrow("Repository not initialized");
});
@@ -599,9 +606,9 @@ describe("RepositoryClient", () => {
it("should throw error if invoke fails", async () => {
(invoke as any).mockRejectedValueOnce(new Error("Network error"));
await expect(client.create("https://server.com", "user1", "token", "server1")).rejects.toThrow(
"Network error"
);
await expect(
client.create("https://server.com", "user1", "token", "server1"),
).rejects.toThrow("Network error");
});
it("should handle missing optional parameters", async () => {
@@ -616,7 +623,7 @@ describe("RepositoryClient", () => {
"repository_get_image_url",
expect.objectContaining({
options: null,
})
}),
);
});
});
+35 -12
View File
@@ -40,7 +40,7 @@ export class RepositoryClient {
serverUrl: string,
userId: string,
accessToken: string,
serverId: string
serverId: string,
): Promise<string> {
log.debug("Creating Rust repository...");
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
@@ -137,7 +137,11 @@ export class RepositoryClient {
}
async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
return commands.repositoryGetNextUpEpisodes(
this.ensureHandle(),
seriesId ?? null,
limit ?? null,
);
}
/**
@@ -181,7 +185,11 @@ export class RepositoryClient {
/** Albums the user has played but not listened to recently ("rediscover"). */
async getRediscoverAlbums(parentId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null);
return commands.repositoryGetRediscoverAlbums(
this.ensureHandle(),
parentId ?? null,
limit ?? null,
);
}
async getGenres(parentId?: string): Promise<Genre[]> {
@@ -229,13 +237,13 @@ export class RepositoryClient {
async getVideoStreamUrl(
itemId: string,
mediaSourceId?: string,
audioStreamIndex?: number
audioStreamIndex?: number,
): Promise<string> {
return commands.repositoryGetVideoStreamUrl(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
audioStreamIndex ?? null
audioStreamIndex ?? null,
);
}
@@ -248,14 +256,14 @@ export class RepositoryClient {
itemId: string,
mediaSourceId?: string,
startTimeSeconds?: number,
audioStreamIndex?: number
audioStreamIndex?: number,
): Promise<string> {
return commands.repositoryGetAudioOnlyStreamUrlForVideo(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
startTimeSeconds ?? null,
audioStreamIndex ?? null
audioStreamIndex ?? null,
);
}
@@ -282,7 +290,11 @@ export class RepositoryClient {
* Get image URL from backend
* The Rust backend constructs and returns the URL with proper credentials handling
*/
async getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): Promise<string> {
async getImageUrl(
itemId: string,
imageType: ImageType = "Primary",
options?: ImageOptions,
): Promise<string> {
return commands.repositoryGetImageUrl(this.ensureHandle(), itemId, imageType, options ?? null);
}
@@ -294,9 +306,15 @@ export class RepositoryClient {
itemId: string,
mediaSourceId: string,
streamIndex: number,
format: string = "vtt"
format: string = "vtt",
): Promise<string> {
return commands.repositoryGetSubtitleUrl(this.ensureHandle(), itemId, mediaSourceId, streamIndex, format);
return commands.repositoryGetSubtitleUrl(
this.ensureHandle(),
itemId,
mediaSourceId,
streamIndex,
format,
);
}
/**
@@ -307,9 +325,14 @@ export class RepositoryClient {
async getVideoDownloadUrl(
itemId: string,
quality: QualityPreset = "original",
mediaSourceId?: string
mediaSourceId?: string,
): Promise<string> {
return commands.repositoryGetVideoDownloadUrl(this.ensureHandle(), itemId, quality, mediaSourceId ?? null);
return commands.repositoryGetVideoDownloadUrl(
this.ensureHandle(),
itemId,
quality,
mediaSourceId ?? null,
);
}
// ===== Favorite Methods (via Rust) =====
+1 -8
View File
@@ -81,11 +81,4 @@ export type PersonType =
| "Lyricist";
export type SessionCommand =
| "PlayPause"
| "Stop"
| "Pause"
| "Unpause"
| "NextTrack"
| "PreviousTrack"
| "Mute"
| "Unmute";
"PlayPause" | "Stop" | "Pause" | "Unpause" | "NextTrack" | "PreviousTrack" | "Mute" | "Unmute";
+27 -9
View File
@@ -19,36 +19,49 @@
const withSearch = $derived(showHeaderSearch({ pathname }));
</script>
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
<header
class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0"
>
<div class="px-4 py-3 flex items-center gap-4">
<!-- Logo -->
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]">
JellyTau
</a>
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]"> JellyTau </a>
<!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-1">
<a
href="/"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname ===
'/'
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: 'text-gray-400'}"
>
Home
</a>
<a
href="/library"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname.startsWith('/library') ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname.startsWith(
'/library',
)
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: 'text-gray-400'}"
>
Library
</a>
<a
href="/downloads"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/downloads' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname ===
'/downloads'
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: 'text-gray-400'}"
>
Downloads
</a>
<a
href="/settings"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/settings' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname ===
'/settings'
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: 'text-gray-400'}"
>
Settings
</a>
@@ -70,7 +83,12 @@
title="Downloads"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</a>
+34 -14
View File
@@ -1,8 +1,8 @@
<!-- TRACES: UR-039 | DR-045 -->
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { library } from '$lib/stores/library';
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { library } from "$lib/stores/library";
// When a className is supplied the parent positions this bar (e.g. inside a
// measured in-flow stack); otherwise it self-positions as a fixed bottom bar.
@@ -11,9 +11,14 @@
// Determine if a route is active
function isActive(path: string): boolean {
const pathname = $page.url.pathname;
if (path === '/') {
if (path === "/") {
// Home is active only when exactly on / or /home, not /library or /search
return pathname === '/' || (pathname.startsWith('/home') && !pathname.startsWith('/library') && !pathname.startsWith('/search'));
return (
pathname === "/" ||
(pathname.startsWith("/home") &&
!pathname.startsWith("/library") &&
!pathname.startsWith("/search"))
);
}
return pathname.startsWith(path);
}
@@ -24,36 +29,51 @@
<div class="flex items-center justify-around px-4 py-2">
<!-- Home Button -->
<button
onclick={() => goto('/')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/') && !isActive('/library') && !isActive('/search') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
onclick={() => goto("/")}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/') &&
!isActive('/library') &&
!isActive('/search')
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
aria-label="Home"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
<span class="text-xs">Home</span>
</button>
<!-- Search Button -->
<button
onclick={() => goto('/search')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/search') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
onclick={() => goto("/search")}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/search')
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
aria-label="Search"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
/>
</svg>
<span class="text-xs">Search</span>
</button>
<!-- Library Button -->
<button
onclick={() => { library.setCurrentLibrary(null); goto('/library'); }}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
onclick={() => {
library.setCurrentLibrary(null);
goto("/library");
}}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library')
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
aria-label="Library"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
<path
d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"
/>
</svg>
<span class="text-xs">Library</span>
</button>
+13 -9
View File
@@ -112,7 +112,9 @@
// Inline animation styles
const buttonStyle = $derived(isAnimating ? "animation: bounce-once 0.6s ease-in-out;" : "");
const svgStyle = $derived(isAnimating && isFavorite ? "animation: heart-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);" : "");
const svgStyle = $derived(
isAnimating && isFavorite ? "animation: heart-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);" : "",
);
</script>
<button
@@ -125,19 +127,20 @@
>
{#if isFavorite}
<!-- Filled heart with scale animation -->
<svg
class={svgClass}
style={svgStyle}
fill="currentColor"
viewBox="0 0 24 24"
>
<svg class={svgClass} style={svgStyle} fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{:else}
<!-- Outline heart -->
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg
class={sizeClasses[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -161,7 +164,8 @@
}
@keyframes bounce-once {
0%, 100% {
0%,
100% {
transform: translateY(0);
}
25% {
+2 -2
View File
@@ -10,7 +10,7 @@
* This escapes any overflow clipping boundaries
*/
function portal(node: HTMLElement) {
const container = document.createElement('div');
const container = document.createElement("div");
document.body.appendChild(container);
container.appendChild(node);
@@ -19,7 +19,7 @@
if (container.parentNode) {
document.body.removeChild(container);
}
}
},
};
}
</script>
+12 -2
View File
@@ -41,7 +41,12 @@
<form onsubmit={handleSubmit} class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
@@ -62,7 +67,12 @@
aria-label="Clear search"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
+7 -2
View File
@@ -44,7 +44,7 @@
<!-- Icon -->
<div class="flex-shrink-0 w-6 h-6 rounded-full {style.bg} flex items-center justify-center">
<svg class="w-4 h-4 {style.color}" fill="currentColor" viewBox="0 0 24 24">
<path d={style.path}/>
<path d={style.path} />
</svg>
</div>
@@ -60,7 +60,12 @@
aria-label="Dismiss"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
+33 -6
View File
@@ -82,7 +82,9 @@
<div
class="fixed inset-0 z-40"
onclick={() => close()}
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") close();
}}
role="button"
tabindex="-1"
aria-label="Close account menu"
@@ -110,7 +112,12 @@
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Downloads
</a>
@@ -121,8 +128,18 @@
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</a>
@@ -133,7 +150,12 @@
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8"
/>
</svg>
Display
</a>
@@ -146,7 +168,12 @@
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Sign out
</button>
+28 -17
View File
@@ -15,7 +15,7 @@
let serverName = $state("Jellyfin Server");
// Load session info asynchronously
auth.getCurrentSession().then(session => {
auth.getCurrentSession().then((session) => {
if (session) {
username = session.username ?? "User";
serverName = session.serverName ?? "Jellyfin Server";
@@ -56,7 +56,9 @@
<div
class="fixed inset-0 bg-black/70 z-[100] flex items-center justify-center p-4"
onclick={handleBackdropClick}
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }}
onkeydown={(e) => {
if (e.key === "Escape") handleBackdropClick();
}}
role="dialog"
aria-modal="true"
aria-labelledby="reauth-title"
@@ -70,13 +72,10 @@
<!-- Header -->
<div class="px-6 pt-6 pb-4 text-center">
<!-- Lock icon -->
<div class="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4">
<svg
class="w-8 h-8 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<div
class="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4"
>
<svg class="w-8 h-8 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -86,12 +85,10 @@
</svg>
</div>
<h2 id="reauth-title" class="text-xl font-semibold text-white mb-2">
Session Expired
</h2>
<h2 id="reauth-title" class="text-xl font-semibold text-white mb-2">Session Expired</h2>
<p class="text-sm text-gray-400">
Your session on <span class="text-white font-medium">{serverName}</span> has expired.
Please enter your password to continue.
Your session on <span class="text-white font-medium">{serverName}</span> has expired. Please
enter your password to continue.
</p>
</div>
@@ -102,7 +99,10 @@
<div class="block text-sm font-medium text-gray-400 mb-1" id="reauth-username-label">
Username
</div>
<div class="px-4 py-3 rounded-lg bg-gray-800/50 text-gray-300 text-sm" aria-labelledby="reauth-username-label">
<div
class="px-4 py-3 rounded-lg bg-gray-800/50 text-gray-300 text-sm"
aria-labelledby="reauth-username-label"
>
{username}
</div>
</div>
@@ -140,8 +140,19 @@
>
{#if $isAuthLoading}
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Authenticating...</span>
{:else}
+5 -1
View File
@@ -27,7 +27,11 @@
aria-label={label}
class={`text-gray-400 hover:text-white transition-colors ${className}`}
>
<svg class={`${sizeMap[size]} fill-none stroke-current`} stroke="currentColor" viewBox="0 0 24 24">
<svg
class={`${sizeMap[size]} fill-none stroke-current`}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>

Some files were not shown because too many files have changed in this diff Show More