Compare commits

...
Author SHA1 Message Date
dtourolle 214997144f feat(deps): upgrade Tauri to 2.11.5, and own the Android context it stopped setting
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m51s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Failing after 25s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 14s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m19s
The plugin versions could not be matched upward without this: both
tauri-plugin-log 2.9.0 and tauri-plugin-updater 2.10.1 require tauri
^2.10, and the tree was on 2.9.5. So the framework moves with them --
tauri 2.9.5 -> 2.11.5, tauri-build 2.5.3 -> 2.6.3, wry 0.53.5 -> 0.55.1
-- and every plugin's Rust crate and npm package is now pinned to the
same version on both sides.

That upgrade broke Android outright, and the breakage is the interesting
part.

Seven call sites in this crate reach JNI through
ndk_context::android_context(), which reads a process-global pair of
pointers. Nothing here ever set that global. `tao` did -- the windowing
layer under wry, three levels below anything this project names in
Cargo.toml. tao 0.34.5 called initialize_android_context() while starting
the activity and our code read what it left behind. tao 0.35.3 keeps the
same two pointers in a private struct and no longer publishes them.

The result, on every launch, was:

  PANIC at ndk-context/src/lib.rs:72: android context was not initialized
    8: ndk_context::android_context
    9: jellytau_lib::run::{{closure}}

Not a crash in our code, and not a change to our code: an undocumented
side effect of a transitive dependency disappeared. Relying on someone
else to populate a global is a dependency that does not appear in
Cargo.toml and gives no warning when it goes.

src-tauri/src/android_context.rs now owns that invariant instead of
assuming it. JNI_OnLoad captures the JavaVM as the shared library loads
-- the earliest moment available, and nothing in tao, wry or tauri
defines one to collide with. The Context is resolved lazily via
ActivityThread.currentApplication() and pinned as a global reference for
the process lifetime, since ndk_context stores a bare pointer and does
not own it. It publishes the Application rather than the Activity:
SecureStorage.initialize() immediately reduces its argument to
applicationContext anyway, and an Application cannot outlive itself the
way a retained Activity would.

Restoring the global keeps all seven callers untouched. Threading a VM
and Context handle through five credential call sites would have been a
larger change with more risk, on the credential path.

Failure now degrades instead of aborting: it is logged and credentials
fall back to the encrypted-file path, which the app already supports.

Verified on a device, R8-minified, not merely compiled:

  [INIT] Android JavaVM and Application published to ndk_context
  Android SecureStorage initialized successfully
  Android Keystore available via SecureStorage
  [INIT] Using system keyring for credential storage
  [CodecDetection] Detected 7 video codecs: av1,h263,h264,hevc,...

-- the real keystore path, not the fallback, and the app stays up. None
of this is reachable by CI: nothing there runs the app.

Also fixed here, both found the same way:

  - `tauri android build --apk true` is now `--apk`. The CLI took a value
    until 2.10; from 2.11 the stray `true` is a positional and the build
    fails before starting. Three call sites in build-android.sh and one
    in build-release.yml -- the latter builds the signed APK, by far the
    most-downloaded artifact.

  - scripts/build-android.sh ran `npm install` on its clean-build path in
    a bun project, ignoring bun.lock and re-resolving the tree. That is
    exactly how the plugin crate/package versions drift apart again.
    scripts/check-tooling.sh now fails on any npm/yarn/pnpm invocation or
    foreign lockfile, and runs in CI.

DR-222, DR-223.
2026-08-21 22:30:28 +02:00
dtourolle 9a19d30e6c fix(build): make the release actually buildable, and check it before tagging
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 18m41s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 31s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 9s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m5s
Preparing v0.10.0 meant building the release locally first. It did not
build. Two separate defects were sitting on master, both invisible to
every gate this project has, for the same reason: nothing in
build-and-test.yml runs `tauri build`. Only a tag does. So the first time
anyone would have discovered either was a failed release.

**Tauri plugin versions had drifted apart.** Tauri refuses to build when a
plugin's Rust crate and npm package are on different minor versions:

  tauri-plugin-log     (v2.8.0) : @tauri-apps/plugin-log     (v2.9.0)
  tauri-plugin-updater (v2.9.0) : @tauri-apps/plugin-updater (v2.10.1)

Introduced by the updater and diagnostics work in this same branch --
`cargo add` took what the pinned toolchain allowed while `bun add` took
latest, and the caret ranges let them separate. cargo check, clippy,
cargo test and svelte-check all passed.

Matching upward pulled wry 0.53.5 -> 0.54.2 along with wasm-bindgen,
web-sys and webkit2gtk: the webview layer, which on Linux is the video
playback path. That is not a change to make while cutting a release, so
the npm packages are pinned down to the crates instead -- exactly, not by
caret, since the caret is what allowed the drift. The upgrade is worth
doing deliberately, with a playback check, and ci-operations.md says so.

CI now runs `tauri info`, which performs the same comparison without
building. Verified by reintroducing the mismatch and watching it fail.

**The AppImage target had never been built.** It was added earlier in this
branch because the release notes had advertised an AppImage for months
while tauri.conf.json never produced one. It does not work out of the
box: linuxdeploy carries its own `strip`, too old to parse the .relr.dyn
section modern toolchains emit, and it fails on every bundled library --

  strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
  failed to bundle project `failed to run linuxdeploy`

Ubuntu 23.10+ links with -z pack-relative-relocs by default, so the CI
builder image fails exactly as a modern Arch host does. NO_STRIP=true is
linuxdeploy's documented escape hatch. The resulting 153 MB AppImage was
verified to be well-formed and to actually start.

Without this the release would have failed at the Linux build step --
the artifact check added earlier refuses to publish when no AppImage is
produced, which is the behaviour we want, but it would have refused a
tagged build rather than a local one.

Also: the traceability extractor now reads the tooling shell scripts that
carry TRACES comments. DR-207, DR-213 and DR-220 all had them and were
counted as uncovered because only .ts/.svelte/.rs were scanned. Listed
individually rather than globbing scripts/*.sh -- most implement nothing,
and adding one should be a decision.

DR-221.
2026-08-21 20:22:12 +02:00
dtourolle 5d02628689 fix(release): publish real notes, and stop shipping old releases' installers
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 15m1s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 42s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 10s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m6s
Two defects found while preparing v0.9.2, both of which had been shipping
for months without anything to notice them by.

**Every release note was the same 1,050 bytes.** All 35 releases from
v0.0.1 to v0.9.1 published identical generic install instructions whose
"What's New" section read "See CHANGELOG.md" -- a link that does not
resolve from a release page. A reader learned nothing about what changed
in any release the project has ever made.

The body now comes from the `## <version>` section of CHANGELOG.md, and a
missing section fails the release: notes that say nothing are worse than
a build that waits for a maintainer to write two sentences. The 35
published bodies have been backfilled from the changelog via the tea CLI.

This also corrects something introduced two commits ago. That change
generated the body from `bun run release:notes`, which CLAUDE.md is
explicit about -- its output is "a reviewed draft, not a final
changelog". Publishing it unreviewed proved the point immediately: the
v0.9.1..HEAD range contains a repo-wide prettier sweep, so every file in
src/ counted as changed, their TRACES resolved to nearly the whole
matrix, and the draft claimed the release had added the entire
application. The script now skips cosmetic commits (chore(format),
chore(deps), style) and reports how many rather than silently returning a
smaller set, but it stays a local drafting tool.

**Every release from v0.1.0 to v0.8.2 shipped every Windows installer
ever built.** src-tauri/target/*/release/bundle/ is not versioned, cargo
never cleans it, and the runner reuses the target directory -- so the
copy step's bundle/**/*-setup.exe glob collected the lot. v0.8.2 carried
sixteen installers, thirteen of them stale; v0.5.0 offered users a
download list going back to 0.1.0. Eight months, and nothing to notice it
by: the upload loop reported success, the files were real, and the page
looked busy rather than wrong. It stopped only because an unrelated cargo
cache change wiped the runner's target dir, so it was dormant, not fixed.

Both desktop builds now remove the bundle directory before building, so a
stale file cannot exist to be copied. Filtering the copy by version would
have hidden it instead. The Linux job gets the same treatment: it was
never hit only because Linux packaging is newer, and the glob is
identical.

scripts/check-release-artifacts.sh is the backstop for whatever
reintroduces one by a route nobody predicted. It runs before the SBOM,
the checksums and the upload -- all of which describe the file set, so a
stale artifact has to be caught before it is hashed and published as part
of the release. Verified against a reconstruction of the real v0.8.2
accumulation.

DR-219, DR-220, UT-210.
2026-08-21 19:54:46 +02:00
dtourolle cac9afa6bd Merge pull request 'Chore/infra hardening' (#15) from chore/infra-hardening into master
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m22s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
Reviewed-on: #15
2026-08-21 17:44:48 +00:00
dtourolle 2e3a864ef0 Merge branch 'master' into chore/infra-hardening
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
2026-08-21 17:44:27 +00:00
dtourolle 1d56517f07 docs(specs): add backend-owned stream selection
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
Rust becomes the single owner of which stream to play — direct play or
transcode, at what ceiling, over what transport — and hands every player
backend a self-describing StreamSelection instead of a bare URL. mpv,
ExoPlayer and the HTML5/hls.js path all consume one decision rather than
three places re-deriving it.

The motivating leak is concrete. VideoPlayer.svelte determines transport with
`currentStreamUrl.includes(".m3u8")`, in two places, for a URL Rust
constructed and therefore already knows the shape of. That is the boundary
rule in miniature: not item-type taxonomy, but the same error of
reconstructing a domain fact in the presentation layer because the wire shape
did not carry it. A tagged Transport enum deletes it.

The design line, which ExoPlayer forces: Rust decides *what stream*, the
player decides *how to deliver it*. ExoPlayer has genuine adaptive track
selection; this spec must not reimplement or fight it. Rust only adapts where
the player cannot (mpv) and the server actually offers a ladder.

Six phases, and phase 1 stands alone as pure ownership movement with no
behaviour change. Phase 4 (direct-play negotiation) is what removes the
transcode and unblocks the Linux native-video work. Phase 5 (adaptation) is
gated on counting EXT-X-STREAM-INF entries in a real playlist — the
acceptance criteria require that count be recorded before it is either
started or dropped.

Takes DR-121 from read-through-media-cache.md, which specced Rust-owned
quality reporting but never built it; that spec keeps its capture half.
2026-08-21 19:41:32 +02:00
dtourolle 99d96163d8 docs(specs): correct the spike's crash and ABR findings
Three corrections to the Linux native-video spike, each of which reverses
something recorded earlier in the same session:

- The crash is unexplained. It was first blamed on hwdec=auto-safe's Vulkan
  failures, on a misreading of the logs — those are two per run at start-up,
  not per-frame, and every clean run has the same two. A 300s soak on
  auto-safe survived. So did 240s of automated fullscreen toggling (~120
  transitions) and 240s of continuous resizing (~2000 reallocations). Three
  hypotheses, none reproduced. Recorded rather than dismissed: an
  intermittent fault nobody can reproduce is worse to inherit than a
  deterministic one.

- G5 drops to amber. It looked and felt smooth, but the only SIGSEGV observed
  came from the only session in which fullscreen was exercised, and the spike
  has no lifecycle handling at all — it never frees the render context. An
  implementation must bind that to the GL context's lifetime regardless of
  what caused this crash, because Android already paid for that lesson as
  DR-184.

- Finding 3's premise is in doubt. "The webview path already has real ABR via
  hls.js" was never checked against the URLs this app builds:
  get_video_stream_url requests a single rendition, the frontend has no
  level-handling code at all, and a quality switch is implemented by
  re-opening the stream. If the playlist is single-variant there is no
  adaptation to lose. The decisive test needs a live server and is recorded
  as unrun.

Also records hardware decode working through the render API (nvdec-copy
engaged), and that hwdec=vaapi silently fell back to software on this box.
2026-08-21 19:41:21 +02:00
dtourolle eda6e36d3d Merge pull request 'Infrastructure hardening: CI enforcement, supply chain, updater, diagnostics' (#14) from chore/infra-hardening into master
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m0s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 38s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m5s
Reviewed-on: #14
2026-08-21 17:33:53 +00:00
dtourolle f11f5eddd5 docs(specs): record the Linux native-video compositing spike
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 15m3s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 38s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m11s
Adds the spike write-up and re-opens finding 2 of
playback-backend-unification.md, which concluded that video cannot unify
on a native engine because the webview owns the surface.

That finding's general form has since been falsified on Android, where
native video composites behind a transparent WebView and ships on by
default. The evidence behind it was also entirely about foreign-window
embedding -- mpv's render API, drawing into a GL context we own inside
Tauri's own GTK tree, was never tested. The spike tests that one claim
and comes back green on Linux for both X11 and Wayland, bar the Tauri
default_vbox() half of G1.

Findings 3-6 are deliberately left standing. Finding 3 in particular --
mpv has no adaptive bitrate -- is an independent disqualifier that a
green compositing result does not clear, and the spike says so rather
than reading as a green light.

The next-free-id line moves to UR-079 / IR-033 / DR-219: this branch
allocated UR-077 and UR-078 for the updater and diagnostics work, and
DR-215 through DR-218 with them, after that line was last written.

Authored in a parallel session working in the same checkout; committed
here so it travels with the rest of the branch.
2026-08-21 19:06:13 +02:00
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
dtourolle d095e1f410 fix(ci): drop the bash-only shopt from the Linux artifact step
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 13m55s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m22s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 14m0s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m9s
Build & Release / Build Linux (push) Successful in 16m39s
Build & Release / Build Windows (push) Successful in 20m42s
Build & Release / Build Android (push) Successful in 29m18s
Build & Release / Create Release (push) Successful in 13s
"Prepare Linux artifacts" ran `shopt -s nullglob`, but the runner executes
`run:` blocks with POSIX sh, where shopt does not exist. It exited 127 and
failed the step -- so build-linux never uploaded, create-release (which
needs all three build jobs) never ran, and v0.9.0 and v0.9.1 both compiled
successfully but published nothing. The last release with assets is v0.8.2.

Reproduced under busybox sh: the current block prints "shopt: not found",
passes the unmatched rpm glob through literally ("cp: can't stat
'.../bundle/rpm/*.rpm'"), and exits 127. Without nullglob an unmatched
pattern stays literal, so test each candidate with [ -e ] instead; the
same input then exits 0 with the AppImage and deb copied.

traceability-check.yml already carries this rule in two places (`case`
instead of `[[ == ]]`, a pipe instead of a here-string). Keeping the fix
POSIX rather than adding `shell: bash` follows that convention and drops
the dependency on bash being present in the builder image.
2026-08-21 13:34:20 +02:00
dtourolle a7365b9511 fix(ci): cache the cargo registry, not the 16 GB target dir
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m10s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m17s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 13m59s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m13s
Build & Release / Build Linux (push) Failing after 16m29s
Build & Release / Build Android (push) Canceled after 0s
Build & Release / Create Release (push) Canceled after 0s
Build & Release / Build Windows (push) Canceled after 11m6s
The runner's 74 GB disk kept filling. Measured on the box: 24 GB of
act cache, 23.18 GB of it created in 20 days -- ~1.15 GB/day against a
30-day-unused / 90-day-used GC, so it could never converge.

Cause: src-tauri/target (16 GB locally: 9.6G debug, 3.2G release, 2.4G
android) was cached under five separate keys, all keyed on
hashFiles('**/Cargo.lock'). The release script stamps the version into
Cargo.lock, so all five invalidated on every chore(release) -- 32
distinct lockfile revisions in three months.

- Cache only registry/index, registry/cache and git/db. registry/src is
  omitted as well: cargo re-extracts it from the 155 MB of .crate
  tarballs rather than storing 1.1 GB extracted.
- Collapse the five per-job keys into one shared cargo-registry key.
  They existed to keep debug/release target artifacts from clobbering
  each other; with target uncached, registry contents are
  target-independent and every job wants the same crates.
- Split cargo-xwin into its own key. It tracks the xwin version in the
  builder image, not our lockfile, so keying it on Cargo.lock was
  re-downloading the whole Windows SDK on every release bump.
- CARGO_INCREMENTAL=0: never reused across runs, 3.5 GB of the debug dir.
- Installer artifact retention 30d -> 7d; tagged releases carry the
  binaries anyway.

Inflow drops from ~5 GB to ~150 MB per lockfile change. Tradeoff: Rust
jobs now compile cold every run (~31min vs ~9min on a cache hit for the
Linux release build). Most runs already paid that, since a release bump
invalidated every key. sccache with a hard size cap is the way back if
it bites.
2026-08-21 11:59:28 +02:00
dtourolle 16658889a2 fix(home): restart the hero banner timer on a manual change
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m31s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Failing after 14m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
The rotation interval was installed once when the banner mounted and never
touched again, so a swipe, arrow or dot tap inherited whatever was left of the
running countdown — swiping 5.5s into a 6s interval moved the banner on half a
second later.

The timer moves into heroRotation.ts as a small restartable object so it can be
unit-tested, and every manual navigation path restarts it from that moment.
Verified red-first: with restart() reverted to leave a running timer alone, the
regression test fails.

Release 0.9.1.
2026-08-20 23:11:30 +02:00
dtourolle 98b2ede8bd fix(arch): build with custom-protocol so the package can load its own UI
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 21s
The PKGBUILD ran a bare `cargo build --release`. Without
`--features tauri/custom-protocol` Tauri does not embed the frontend and serves
it from devUrl instead, so the package compiled, linked, installed and passed
every check — then launched into "Could not connect to localhost: Connection
refused". `tauri build` passes that feature for you and the Android build passes
it explicitly; this path never did, so the Arch package has never worked.

Adds a check() that catches it at build time. It tests for the *assets*, not for
the dev URL: devUrl is part of the config blob generate_context!() embeds either
way, so grepping for it reports a failure on a correct build. A content-hashed
filename from the vite output can only appear if the bundle was embedded — which
is also why the fixed binary is ~400 KB larger.

Found by installing the package and launching it, which is the only thing that
would have found it.
2026-08-20 22:43:34 +02:00
dtourolle 38d56e6c89 fix(scripts): check links in tracked files, not everything on disk
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 17m30s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 19s
The prune list was wrong three times running: it walked the scratch worktrees
under .claude/, then makepkg's vendored cargo registry under
packaging/arch/src/, reporting a dependency's broken README as if it were ours.
Every one of those directories is already git-ignored, so asking git for the
file list makes the exclusion rule the same one the repo already maintains —
and it cannot drift the way a hand-kept prune list did.

It also makes the script do what its header always said it did: check tracked
markdown. Untracked-but-unignored files are included on purpose, so a new doc is
checked before it is committed rather than after. The find(1) path stays as a
fallback for a non-git checkout.

CI was unaffected — a fresh checkout has none of those directories — but the
local gate cried wolf, which is how a gate stops being read.
2026-08-20 22:27:29 +02:00
dtourolle 4f4741cee5 fix(release): stamp the Arch package version, and ship the licence with it
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 17m49s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 24s
pkgver sat at 0.0.18 while the tree was on 0.8.x, because the Arch package is
built by makepkg rather than the tauri bundler and set-version.sh never touched
it. makepkg produced a package whose version bore no relation to the source it
was built from — the exact failure that script exists to prevent, in the one
file it had missed.

Dev versions are converted to a pkgver Arch accepts: a hyphen separates pkgver
from pkgrel, so 0.9.0-3-gabc1234 becomes 0.9.0.r3.gabc1234. pkgrel resets to 1,
since a new upstream version restarts its packaging revisions.

Also installs LICENSE into /usr/share/licenses — MIT is not in Arch's common
licences, so a package under it has to carry the text.

Arch is not part of the automated release (build-release.yml covers linux,
windows and android), so v0.9.0 is unaffected; this applies to anyone building
the package by hand.
2026-08-20 22:26:35 +02:00
dtourolle 20e2331560 chore(release): 0.9.0
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m43s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Failing after 17m59s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
2026-08-20 21:21:46 +02:00
dtourolle bb140a8734 docs(release): write the v0.9.0 changelog and refresh artifact names
release:notes is not usable for this batch: it maps changed files to their
TRACES, and the logging sweep touched 63 files spanning most of the codebase, so
it reports nearly every user requirement as changed — including ones explicitly
not implemented. Written by hand instead.

Also updates the checklist's artifact names for the rename and adds the rpm,
which the checklist never listed because it was never published.
2026-08-20 21:07:13 +02:00
dtourolle 28b600304f fix(scripts): check registry auth properly before pushing the builder image
`docker info | grep Username` only reports a Docker Hub session, so for a
private registry the guard never matched: every push dropped into an interactive
docker login, which hangs a non-interactive run. Checks the credential store for
the specific registry instead, and refuses with instructions rather than
prompting when there is no TTY.
2026-08-20 21:06:23 +02:00
dtourolle 8fbf4d92cb ci: match release bundles by extension, and ship the rpm
Renaming the app to JellyTau renamed its bundles, and the release job globbed
`bundle/deb/jellytau_*.deb`. The copy was wrapped in `if [ -f ... ]`, so the
rename would have dropped the .deb from the release silently — a green build
producing an incomplete release. Matching by extension removes the coupling
between the product name and the pipeline, and an empty dist/linux now fails
the job instead of passing quietly.

That `if [ -f "dir/"*.ext ]` guard was also wrong on its own terms: with more
than one match, test gets extra arguments and returns false.

Found while verifying the rename: the rpm has been built by every release since
deb+rpm became the bundle targets, and never copied, published or documented.
It ships now.

Also declares the package rename. Tauri kebab-cases productName into the
Debian package name, so "JellyTau" produces `jelly-tau` — a different package
from the `jellytau` earlier releases installed, which would have put a second
copy alongside the old one. deb now declares Replaces/Conflicts/Provides and
rpm Obsoletes/Provides, verified in the built control file.

TRACES: | DR-214
2026-08-20 21:01:43 +02:00
dtourolle d32ca13d00 chore: give the project its own identity instead of the scaffold's
Cargo.toml still carried `description = "A Tauri App"` and `authors = ["you"]`,
package.json's description was empty with no author or repository, and there was
no LICENSE file at all despite package.json declaring MIT.

The user-visible half matters more. productName was the scaffold's lowercase
"jellytau", which is what the Android *release* build shows under its icon and
what the deb/rpm/NSIS bundles carry as their display name. It went unnoticed
because build.gradle.kts overrides the label to "JellyTau Debug" for the debug
build type — the install a developer sees every day was the only correctly-cased
one. mainBinaryName pins the executable filename to "jellytau" so
build-windows-cross.sh and the Arch PKGBUILD, which both resolve it by name,
need no change.

strings.xml moves into the canonical android tree rather than being edited in
gen/, since sync-android-sources.sh already copies res/values/*.xml — so the fix
survives the next regeneration.

Bundle metadata (publisher, copyright, category, descriptions, licence) was
absent entirely, so the packages shipped with no maintainer or description. The
hand-written PKGBUILD and .desktop had all of it; only the generated packaging
was wrong.

Adds .env.example: three scripts require signing vars from a gitignored .env
and .gitignore already whitelists the example, but none existed.

TRACES: | DR-214
2026-08-20 20:38:16 +02:00
dtourolle 2a3f08f8a4 build: hand containerised build artifacts back to the host user
The compose services bind-mount the repo and build as root, so every artifact
they leave in src-tauri/target belongs to root on the host. It accumulates:
11,124 such files had built up, enough that cargo clean and scripts/clean.sh
failed with EACCES — and a plain cargo build died part-way through, because
build scripts compile for the host and land in target/debug even when
cross-compiling to Android. That is what blocked the device build in this batch.

Restores ownership at the end of each containerised build, reading the intended
owner from the checkout so no uid has to be plumbed through from the host. A
no-op when not running as root, so the native build scripts call it
unconditionally.

Running the containers as the host uid is the tidier fix and stays open — it
needs the cargo/bun cache volumes moved off /root first, which is why this is
not a one-line user: directive.

TRACES: | DR-213
2026-08-20 20:17:36 +02:00
dtourolle 68ca1d585d chore: regenerate bindings and the traceability matrix
bindings.ts picks up the library-exclusion commands and types from tauri-specta.
The matrix regenerates because validation.ts and its test are gone — the doc
link checker caught the stale references, which is the first time that gate has
paid for itself on a generated artifact rather than a hand-written link.

Also drops exclusions::is_excluded: a wrapper over is_excluded_by that only a
test called, while the trait impls hoist the snapshot themselves. The test now
calls the same path production does.
2026-08-20 20:14:15 +02:00
dtourolle 0815445aa7 feat(library): exclude chosen folders from music browsing
Replaces a hardcoded filter that dropped anything named "Podcasts" from music
results — one user's library layout compiled into the shipped product, keyed on
an English literal, applied only at the six call sites someone had remembered.

Exclusion is now a user setting stored in Rust and applied at the repository
layer's convergence points, so scope is decided once and is the same on every
screen. It matches on folder id rather than name: a title is not what an item
is, which is why an album legitimately called "Podcasts" used to vanish.

Deliberately not filtered: get_item (an id asked for by name was navigated to on
purpose, and refusing it would break playback of anything inside a hidden
folder), get_downloaded_items (hiding a download would leave the user unable to
delete a file whose disk usage they can still see), and the offline cache (an
exclusion is a view preference and must be reversible without a re-crawl).

Also removes src/lib/utils/validation.ts — six exported validators with no
caller outside their own test file, which made the module read as covered
input validation while guarding nothing.

TRACES: UR-076 | DR-209 | UT-203
2026-08-20 20:09:57 +02:00
dtourolle 048c99ebcc fix(downloads): allow the deliberate join_absolute_paths lint in a test
The assertion documents that PathBuf::join discards its base when handed an
absolute path — which is why confinement has to happen after the join, not
instead of it. clippy::join_absolute_paths flags that shape, correctly for
production code, so the lint is allowed here rather than the test weakened.

Worth recording: this lint would not have caught the original defect. The real
join sites pass a variable, and it only fires on a literal.
2026-08-20 20:09:19 +02:00
dtourolle 34026d22b4 fix(logging): keep debug logging in a packaged debug build
import.meta.env.DEV is true only under the vite dev server, but
scripts/build-android.sh produces the debug APK with a plain `bun run build` —
so the logger defaulted to warn there too and the debug package lost every
frontend message from logcat. `bun run android:logs` is a documented workflow
that depends on them.

vite now defines __JT_DEBUG_BUILD__ from Tauri's TAURI_ENV_DEBUG, which the CLI
sets while running beforeBuildCommand. The decision is split into a pure
resolveDefaultLogLevel(isDevServer, isDebugBuild) because neither
import.meta.env.DEV nor a vite define can be varied from inside a test.

Also replaces the pinned requirement counts in extract-traces.test.ts with
invariants. The pins guarded nothing the computeCoverage fixtures don't already
cover, while forcing every branch that adds a requirement to edit the numbers —
the comment above them had become a ledger of which branch contributed which row.

TRACES: | DR-204 | UT-201
2026-08-20 20:06:51 +02:00
dtourolle aeb29f916b docs(requirements): add rows for the path-confinement and query-binding work 2026-08-20 20:03:43 +02:00
dtourolle f83c7ed1f0 fix(downloads): confine download paths to the download root
file_path and target_dir reached PathBuf::join unchecked from the frontend, and
mark_download_completed stored a caller-supplied path that is later fed to
remove_file. A correct sanitiser already existed — download_item_and_start used
it — but download_item is itself a command taking file_path raw, so the guard
was simply routed around. It now lives inside download_item, alongside a
join-then-confine check modelled on media_server::resolve_path.

Sanitising is per path component, not whole-string: the latter would silently
turn downloads/x.mp3 into downloads_x.mp3 and relocate every existing download.

TRACES: | DR-211 | UT-205
2026-08-20 20:03:04 +02:00
dtourolle b313b61717 fix(repository): bind query parameters and encode URL values
Three consistency fixes, each one applying a pattern the same file already
used a few lines away: the offline get_items type filter now binds placeholders
like search at offline.rs:1786 does, build_get_items_endpoint percent-encodes
its values like the Genres block below it does, and player_set_volume clamps
NaN and out-of-range input at the command boundary rather than relying on each
backend to do it.

TRACES: | DR-212 | UT-206
2026-08-20 20:02:57 +02:00
dtourolle fb6bd5cae1 fix(thumbnails): confine cache writes to the cache directory
item_id and image_type reached the cache filename unsanitised while tag was
already being sanitised, and Path::join neither folds .. nor keeps the base
when handed an absolute path. Applies the tag's existing rule to all three
parts and adds a starts_with(cache_dir) check at the point of use, modelled on
media_server::resolve_path.

Not exploitable as shipped — server URLs must be HTTPS (auth/mod.rs) and
Android blocks cleartext, so the id would have to come from a server the user
chose to trust. This makes the write path consistent with how the rest of the
codebase already handles caller-supplied paths.

TRACES: | DR-210 | UT-204
2026-08-20 20:02:57 +02:00
dtourolle da6b039b29 fix(downloads): confine download paths to the download root
Both halves of the path a download writes to arrived from the frontend
unchecked. `start_download` and the queue pump built their target as
`PathBuf::from(target_dir).join(file_path)`, and `mark_download_completed`
stored a frontend-supplied `file_path` on the row verbatim — the same
column that is later read back into `std::fs::remove_file` when a
download is deleted. A correct sanitiser already existed and
`download_item_and_start` used it, but `download_item` is a command in
its own right, so calling it directly routed the guard around.

The guard moves inside. `confine_to_root` folds `..` away lexically and
requires the result to sit inside the storage root, modelled on
`media_server::resolve_path` — the check comes after the join because
`Path::join` drops the base when the joined half is absolute, so an
absolute `file_path` is obeyed rather than folded. `confine_queued_path`
sanitises a queued path per component (so the already-safe name
`download_item_and_start` passes in is not sanitised into a second,
different one) and confines it. Applied in `download_item`, at both join
sites, and to what `mark_download_completed` writes.

Every path the app builds for itself is returned unchanged, including
the absolute ones `download_series`/`download_season` produce from
`${targetDir}/videos`, so no existing row or file on disk is orphaned.
The pump fails an offending row rather than skipping it, because the
pump re-queries and would otherwise not terminate.

Not a live vulnerability: reaching these commands with hostile input
needs script execution in a webview whose CSP is `script-src 'self'`.
This is hardening and consistency.

TRACES: DR-211 | UT-205
2026-08-20 20:01:52 +02:00
dtourolle 080cdbf383 fix(player): clamp volume at the command boundary
player_set_volume passed `volume` through untouched. Each backend
clamps to 0.0..=1.0 for itself, so local playback was already safe, but
the remote branch reaches no backend: it converts with
`(volume * 100.0) as i32`, which turns infinity into i32::MAX. NaN is
handled explicitly since f32::clamp returns NaN for a NaN input and it
then survives every comparison downstream.

TRACES: DR-212 | UT-206
2026-08-20 20:00:35 +02:00
dtourolle 6b7ce512ed fix(online): percent-encode query values and path ids
build_get_items_endpoint pasted ParentId, IncludeItemTypes, SortBy and
SortOrder straight into the query string while the Genres parameter
twenty lines below and the SearchTerm parameter both percent-encode
theirs. Encode them the same way, per list element so the commas
Jellyfin splits on survive.

The per-call ids interpolated into request paths (item, person and
playlist ids) get the same treatment; a Jellyfin GUID is unchanged by
encoding, so this is consistency, not a behaviour change. self.user_id
is left alone throughout, as it is at the endpoint builders already.

TRACES: UR-007 | DR-212 | UT-206
2026-08-20 19:59:25 +02:00
dtourolle 55b37ba2f4 ci: make clippy a hard gate
The advisory step existed because the tree carried a warning backlog. Measured
on 1.97.1 — the pinned toolchain CI actually uses — that backlog is three
warnings, not the ~51 the comment claimed: two unnecessary_sort_by in
smart_cache and one redundant into_iter in offline. Fixed, so clippy now runs
with -D warnings and a warning means new breakage.

Worth recording why this took a toolchain pin to do safely: the same tree
measured 0 warnings on 1.92.0 and 3 on 1.97.1. Flipping the flag on a local
measurement, without the pin, would have reddened CI on the next push.

TRACES: | DR-206
2026-08-20 19:58:39 +02:00
dtourolle d52470e0cd fix(offline): bind item-type filter as query parameters
get_items built its `AND i.item_type IN (…)` fragment by interpolating
each requested type into the SQL string, while `search`, `get_favorites`
and `prune_stale_catalog` in the same file bind the identical filter as
`?` placeholders. Follow the existing pattern so the listing query is
consistent with its neighbours.

The type values bind between the six parent-matching ids and the
favourites user id, matching where `{type_filter}` lands in the
statement.

TRACES: UR-065 | DR-212 | UT-206
2026-08-20 19:56:40 +02:00
dtourolle e12f0065a6 fix(thumbnails): confine cache writes to the cache directory
The thumbnail cache built its filename from `item_id`, `image_type` and
`tag`, but only sanitised the tag. `Path::join` neither folds `..` nor
keeps its base when handed an absolute path, so a malformed id could
place a cache write outside the cache directory.

Sanitise all three parts through one helper using the rule the tag
already used (non-alphanumerics become `_`), so ids and types that were
already safe keep producing exactly the same filename, and resolve the
result against the cache dir with a lexical `..` fold plus a
`starts_with` check, modelled on `media_server::resolve_path`.

The database still stores the raw key and the resolved path, so the
lookup in `get_cached_path` keeps matching what the caller asks for.
2026-08-20 19:56:23 +02:00
dtourolle 63d4df0cde chore(tooling): keep lint and format out of the scratch worktrees
.claude/worktrees holds full checkouts of this repo, generated .svelte-kit
trees included, so 'eslint .' was linting every in-flight branch — 410 errors,
none of them ours. Same root cause the doc-link checker hit.
2026-08-20 19:55:29 +02:00
dtourolle 6b90582e3e chore(tooling): add lint/format gates, pin the toolchain, enforce commit checks
Adds the frontend's first linter and formatter — the Rust half has had
cargo fmt --check and clippy in CI for a while, while 274 TS/Svelte files had
only svelte-check. ESLint runs clean; 159 findings are recorded as warnings
rather than suppressed, so the backlog is visible without painting CI red.

Also: `bun run test` no longer drops into watch mode (the "Before Committing"
list told people to run a command that never returns), the traceability ratchet
moves 82% -> 88%, a pre-commit hook enforces the fast half of that list instead
of relying on memory, the dead webdriverio e2e suite and its five devDeps are
removed, and the Rust toolchain is pinned to 1.97.1 so the developer machine and
the CI builder image stop being five releases apart.

TRACES: | DR-205, DR-206, DR-207
2026-08-20 19:53:13 +02:00
dtourolle ea3c765561 chore: remove unused frontend validation module
`src/lib/utils/validation.ts` exported six validators (validateItemId,
validateImageType, validateMediaSourceId, validateUrlPathSegment,
validateNumericParam, validateQueryParamValue). Nothing outside its own
213-line test suite ever called them, so the module read as covered,
guarded input validation while guarding nothing — a green test run over
code no input ever passes through.

Deleting it does not weaken any check that was running; it removes the
false assurance that one was.

Note: the layer this validation belongs in per CLAUDE.md ("Validate all
inputs in Rust command handlers") does not implement it either. That is
a separate concern and is left untouched here.
2026-08-20 19:38:12 +02:00
dtourolle ac3cd67164 feat(library): exclude chosen folders from music browsing
Replaces `src/lib/utils/podcastFilter.ts` — a shipped personal workaround
that dropped any item whose name, album, album artist or artist was
literally "Podcasts" — with a real user setting applied in Rust.

The old filter was wrong twice over: it hardcoded one user's folder
layout keyed on an English literal, and it put a domain rule (what a
query should return) in the presentation layer. It slipped past
`check:boundary` only because it matched on names rather than on an
item-type array.

- `repository::exclusions` owns the rule and the process-wide id set,
  the same shape as `online::STREAMING_QUALITY` so it survives a
  repository being rebuilt on re-login.
- `HybridRepository` applies it where the cache and server legs of every
  cache-first query converge (`parallel_race` / `race_with_refresh`),
  plus the bespoke `get_items` path and the server-only reads. Filtering
  before the "has content" check is what makes a cache page of nothing
  but hidden items fall through to the server.
- Exclusion is by stable item id, never by name, and matches an item's
  own id or any container link it carries (parent, album, library,
  series, season, artist).
- A direct `get_item` lookup and the Downloads surface are deliberately
  unfiltered: hiding those would break playback and file management of
  anything inside a hidden folder.
- `LibrarySettings` persists to `app_settings` and is restored in the
  setup hook, alongside the streaming-quality cap. Default is an empty
  list — nobody inherits the old "Podcasts" behaviour.
- New commands `library_get_settings`, `library_set_settings` and
  `library_get_exclusion_candidates`; the candidates read goes through
  `get_items_unfiltered` so an already-hidden folder still appears in the
  picker and the setting can be undone.
- Settings page gains a "Hidden Folders" section that renders the
  backend's candidate list and sends back ticked ids; it decides nothing.

TRACES: UR-076 | DR-209 | UT-203
2026-08-20 19:38:05 +02:00
dtourolle f5bee069c0 fix(desktop): give the window a real title and a usable default size
tauri.conf.json still carried the scaffold defaults: a lowercase "jellytau"
title in an 800x600 window. The title is what the OS shows in the task
switcher and window list, and 800x600 is too small for a media library grid
with a mini player docked at the bottom.

Now "JellyTau" at 1280x800, with minWidth/minHeight held at the old 800x600
so the layout still has a defined floor when a user drags the window small.
2026-08-20 19:36:21 +02:00
dtourolle adcdadfcaf ci: run the documentation link checker
Wires scripts/check-doc-links.sh into build-and-test.yml next to the existing
boundary tripwire, and exposes it as `bun run check:links`.

The docs are the maintained source of truth for architecture and process and
cross-reference each other heavily, so a rename that misses a link quietly
turns a doc into a dead end. Pure shell — nothing is installed at job time.

The script itself is landing separately; this job step is red until it does.
2026-08-20 19:36:10 +02:00
dtourolle 6406ca3fad chore(tooling): add a pre-commit hook for the fast committing gates
CLAUDE.md's five-command "Before Committing" list was enforced by memory
alone. scripts/hooks/pre-commit now runs the half of it that finishes in
seconds — `bun run check`, `bun run test`, check-frontend-boundary.sh, and
`cargo fmt --all -- --check` only when staged files touch src-tauri/.

`cargo clippy` and `cargo test` are left out on purpose. Minutes per commit is
how a hook teaches people to type --no-verify; CI and `bun run test:all` are
where the slow gates belong.

Installed via `bun run hooks:install`, which sets core.hooksPath to the
tracked scripts/hooks directory rather than copying into .git/hooks, so later
changes to the hook reach everyone on their next pull.

The hook runs every gate before reporting, so one commit tells you everything
that is wrong rather than only the first thing. It exits 0 without running
anything during a merge, rebase, or cherry-pick, and when nothing is staged;
`git commit --no-verify` skips it as usual.
2026-08-20 19:36:02 +02:00
dtourolle 4af6ed0f98 build(rust): pin the toolchain to 1.97.1 for dev and CI
The Rust toolchain was unpinned on both sides, and the two sides had drifted
five releases apart: the CI builder image ships rustc 1.97.1, the development
machine was on 1.92.0. Clippy's lint set and rustfmt's output both change
between releases, so a green `cargo clippy` / `cargo fmt --check` locally said
nothing about CI and vice versa — which is the reason the clippy gate could
not be trusted enough to turn on.

src-tauri/rust-toolchain.toml pins channel 1.97.1 with the rustfmt and clippy
components. Deliberately no `targets` list: that would make rustup fetch the
Android and Windows std libraries on every plain `cargo test`, including on
machines that never cross-compile. The image already has them.

Dockerfile.builder installs that exact version instead of "latest stable at
rebuild time", and prints rustc/clippy versions so a mismatch is visible in
the build log.

The pin only becomes authoritative once the image is rebuilt and pushed
(scripts/build-builder-image.sh). Until then CI still runs whatever rustc the
current image has, and if that is not 1.97.1 rustup will download the pinned
toolchain at job time — a toolchain install in CI, which CLAUDE.md forbids.
Both files carry that warning next to the version.

Note: the clippy step in .gitea/workflows/build-and-test.yml is left advisory
here; tightening it wants a warning count measured on 1.97.1 first.
2026-08-20 19:35:51 +02:00
dtourolle 164157f98e chore(ci): raise the traceability ratchet from 82% to 88%
Actual coverage is 90% (`bun run traces:coverage`), so the gate had ~8 points
of slack — a requirement could stop being traced and CI would not notice.
Per the ratchet policy in the workflow, move it up to sit just under the real
figure.

MIN_COVERAGE_PERCENT in scripts/extract-traces.ts moves in lockstep: the
workflow comment says to keep the two in sync and extract-traces.test.ts
asserts it, so changing only the YAML turns the frontend suite red.

(The stale "fails below 50%" comment in scripts/test-all.sh, wrong since the
threshold moved to 82, was corrected in the preceding commit along with the
rest of that file.)
2026-08-20 19:35:38 +02:00
dtourolle 95eb16d5ef chore(tooling): add eslint + prettier, fix the test watch-mode default
Three gaps in the frontend tooling, all in the package.json script surface.

1. No JS/TS linter or formatter existed at all for 274 TS/Svelte files.

   Adds an ESLint flat config (typescript-eslint + eslint-plugin-svelte,
   Svelte 5 + TS strict) and prettier + prettier-plugin-svelte, plus the
   `lint`, `lint:fix`, `format`, `format:check` scripts.

   The tree is error-clean (`npx eslint .` exits 0). Getting there needed
   seven real one-line fixes (braced switch cases that leaked `const` across
   arms, a useless regex escape, two `let`s that never change, a thrown Error
   that dropped its `cause`, and two `// eslint-disable-next-line` comments
   documenting the Svelte 5 bare-read-for-dependency idiom). Everything else
   that fires is set to `warn` with the reason written next to it in
   eslint.config.js — notably ~94 dead bindings and `any` at the IPC
   boundary. Those are real findings to drive to zero, not noise to delete.

   `no-console` is OFF for now: a parallel change is moving all ~468 console
   calls onto a logger facade, and turning the rule on today would collide
   with it. eslint.config.js says so, and says to flip it to `error` once
   that lands.

   `prettier --write` is deliberately NOT run here — it would rewrite ~200
   files and swamp every other diff in flight. The gate is available; the
   sweep is a separate commit. Markdown and CI YAML are in .prettierignore
   because both are hand-laid-out (and docs/traceability.md is generated).

2. `bun run test` was bare `vitest`, i.e. watch mode — while CLAUDE.md's
   "Before Committing" list tells people to run it. It is now `vitest run`,
   with `test:watch` and `test:coverage` (also `--run`-ified) alongside.
   scripts/test-all.sh drops the now-redundant `--run`, and
   scripts/test-frontend.sh keeps `--watch`/`--ui`/`-w` working by routing
   them to a long-running vitest instead of the single-pass one.

3. The webdriverio e2e suite is deleted. It was last touched in January
   ("First working POC"), has never run since, and is not in CI — five
   devDependencies and two scripts of pure decoration. Removes e2e/,
   wdio.conf.ts, the two `test:e2e*` scripts, the @wdio/* + webdriverio
   devDeps, and the WebdriverIO block in .gitignore.

The package.json diff also carries `hooks:install` and `check:links`, wired
up by the following commits.
2026-08-20 19:35:17 +02:00
dtourolle ae26d5356a docs: fix remaining references to the moved build docs
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 27m6s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m26s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m6s
Updates the two referrers outside the docs tree that the move left behind, and
excludes .claude/ from the link checker — the agent worktrees under it are full
checkouts, so it was walking every in-flight branch and reporting their links
as ours.
2026-08-20 19:34:44 +02:00
dtourolle b025ed05f2 docs: repair the traceability matrix, link integrity and site nav
Every file link in docs/traceability.md was broken — all 2,840. The generator
emitted repo-root-relative hrefs from a file that lives in docs/, so the
artefact the whole TRACES system exists to produce was unnavigable in the repo
browser and on the published site alike. Fixed at the generator and covered by
a regression test, since the markdown output had no test at all.

Also: repairs the remaining broken relative links, adds
scripts/check-doc-links.sh so this class of defect fails a build instead of
rotting, publishes all 50 docs in the mdBook nav (was 21), moves the root-level
build docs under docs/build/ for consistency, retires the stale v0.6.0 audit
after confirming every still-open finding survives in the technical-debt table,
and records the oversized-module debt.

TRACES: | DR-208 | UT-202
2026-08-20 19:33:36 +02:00
dtourolle 2de91ae76c docs: move the root-level build docs under docs/build/
build-release.md, build-desktop-packages.md and build-windows.md sat at
the docs/ root while docker.md and build-builder-image.md were already in
docs/build/, so "where do build docs live" had two answers. They now have
one.

Referrers updated: README.md, docs-site/SUMMARY.md, and the ../ links
inside the moved files themselves, which each gained a level of depth —
Dockerfile, Dockerfile.arch, packaging/arch/PKGBUILD, CHANGELOG.md,
README.md, src-tauri/src/lib.rs and src/lib/services/webviewAudio.ts.
Every one of those was caught by check-doc-links.sh rather than by
reading, which is the point of having it.

Two referrers are left for their owners: CLAUDE.md line 173 and the
comment at scripts/build-windows-cross.sh line 11.
2026-08-20 19:32:04 +02:00
dtourolle 35157a6c59 refactor(logging): replace raw console calls with a leveled logger facade
484 ungated console.* calls across 63 frontend files shipped to end users —
248 console.log occurrences were verified present in the built bundle. The Rust
half of the app has used the log crate with a LevelFilter and a RUST_LOG
override since the beginning; the frontend had no equivalent.

Adds src/lib/utils/logger.ts: four levels, scoped loggers replacing the
hand-written "[Scope] " prefixes, debug in dev and warn in production, and a
localStorage override so a user can turn verbose logging on in a shipped build
to file a bug report. warn and error are never gated away.

The sweep itself is mechanical — no control flow, error handling, or message
semantics changed.

TRACES: | DR-204 | UT-201
2026-08-20 19:31:57 +02:00
dtourolle 3b55810a0e docs: publish every spec and build doc in the mdBook nav
SUMMARY.md drives the mdBook build and mdBook renders only what SUMMARY
references, so 31 of the 52 pages in docs/ were being written, reviewed
and merged without ever appearing on the published site: 25 of the 26
specs (only video-background-audio was listed), plus build-desktop-
packages, build-windows and defect-windows.

The specs are grouped into themed sections — playback, library and
browsing, downloads and offline, tooling and build — because a flat list
of 28 is not navigable, with SPEC-TEMPLATE and SPEC-REVIEW-CHECKLIST kept
together as the process docs someone reaches for before writing a spec.
2026-08-20 19:30:59 +02:00
dtourolle bf72f9869a build(scripts): add a documentation link integrity check (DR-208)
Walks every tracked .md file, resolves each relative inline link against
the directory the file lives in, and fails with the file:line and the
unresolved target if it is not on disk. Skips http(s)/mailto, pure
anchors, and links inside fenced code blocks (a template being shown to
the reader is sample text, not a live link).

This is the check that would have caught the 2,793 dead links in the
generated traceability matrix at the commit that introduced them, and the
handful of hand-written ones repaired alongside it. Nobody clicks 2,800
links, which is why the defect survived for months.

Two documented exceptions rather than silent ones: docs-site/SUMMARY.md
is copied into docs/ by publish-docs before rendering, so its links are
resolved from docs/ — which is what makes it catch a nav entry pointing
at a page that does not exist; and docs/README.md and docs/api-redirect.md
are generated by that same job and so are absent from the repo by design.

The header states what it deliberately cannot see, in the house style of
check-frontend-boundary.sh: it validates paths, not anchors. Resolving a
fragment needs a renderer's heading-slug rules, which differ between
Gitea, GitHub and mdBook, so a link to a renamed heading still passes.

The package.json script and CI wiring are added separately.
2026-08-20 19:30:59 +02:00
dtourolle 4567c63797 docs: raise the documented traceability gate to 88%
The spec review checklist still asked for >= 50%, the figure the gate sat
at before it was found to be unreachable; traceability-ci.md carried 82%
throughout. Both now read 88%, matching the ratchet, and the checklist
points at `bun run traces:coverage` rather than inviting anyone to trust a
number written in a document.

Also refreshes the two stale coverage snapshots in traceability-ci.md
(~86% from July 2026, and targets of 70% and 90% that the current 90%
already passes) and records the 50 -> 82 -> 88 ratchet history.
2026-08-20 19:30:48 +02:00
dtourolle 46a5219f8e docs: repair broken relative links
- traces-quick-ref.md: the four "where to find requirements" links pointed
  at README.md, but those anchors (#1-user-requirements and friends) live
  in requirements.md; the "See Also" links were written as if the file sat
  at the repo root (docs/traceability.md from inside docs/); and the
  extraction-script link needed ../ to reach scripts/README.md.
- release-checklist.md: the release-notes template linked ../../CHANGELOG.md
  (one level too deep) and ../../issues + ../../discussions, which are
  GitHub relative-URL idioms. The canonical remote is Gitea, whose release
  bodies render the template outside any repo path, so these are now
  absolute gitea.tourolle.paris URLs. Gitea has no discussions, so that
  link is dropped rather than pointed somewhere it does not exist.
- specs/favorites-browsing.md: linked the deleted
  src/lib/utils/tauriIntegration.test.ts.
2026-08-20 19:30:48 +02:00
dtourolle 1518d92ef4 fix(traces): make generated matrix links resolve from docs/
docs/traceability.md emitted each trace's file link with the
repo-root-relative path as the href, but the file is written to docs/ —
so every one of the 2,793 links resolved to docs/src-tauri/... or
docs/src/... and 404'd, in the Gitea repo browser and on the published
mdBook site alike. The matrix is the artefact the whole TRACES system
exists to produce, and it was unnavigable.

The href now carries a ../ prefix; the visible link text stays
repo-root-relative, since that is the path a developer greps for.

This survived because the markdown generator had no test at all — the
existing suite covers counting, coverage and dangling IDs only. UT-202
now generates a link for a file that really exists, resolves the href
against docs/, and asserts the target is on disk; it fails against the
old output. Watched red before the fix, per the red-green rule.

The live-requirements counts move with the rows added in the previous
commit: UR 75 -> 76, DR 194 -> 200, total 337 -> 344.
2026-08-20 19:30:37 +02:00
dtourolle 662cb3cd85 docs(requirements): add new IDs, retire the v0.6.0 audit, record module size
Adds the requirement rows other work in flight needs so `traces:validate`
stays green: DR-204 (frontend logging facade), DR-205 (ESLint + Prettier
gate), DR-206 (pinned Rust toolchain), DR-207 (pre-commit hook), DR-208
(documentation link integrity), DR-209 (server-side library folder
exclusion) and UR-076, plus §4 test rows UT-201, UT-202 and UT-203.

Deletes docs/codebase-audit.md. It was a 2026-08-16 snapshot of v0.6.0 at
commit be907b49 with no status markers, three releases stale, describing
code that had since changed — a document that half-describes the codebase
is worse than none. Everything in it still genuinely open already lived in
§5's table; the §5 preamble now records what was dropped as closed and
why, so nothing is silently re-raised or silently lost.

Also rewrites §5 row 11 with today's figures and the actual cost: the six
oversized modules are the same ones CLAUDE.md's Gotchas section keeps
having to warn about, which is the price being paid. Recorded, not
scheduled.

Fixes a dead link to the removed src/lib/services/playbackControl.ts,
which now points at src/lib/utils/playbackUnits.ts.
2026-08-20 19:30:30 +02:00
dtourolle d54d8cc7c4 refactor(logging): route frontend console calls through the logger
TRACES: | DR-204

484 ungated `console.*` calls across 63 non-test frontend files shipped to
end users with no way to turn them off. Mechanical substitution, no control
flow, error handling or message semantics changed:

  console.log / console.debug -> log.debug
  console.info                -> log.info
  console.warn                -> log.warn
  console.error               -> log.error

Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope
now carries them; scope names that already existed are preserved verbatim
(`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename
where a file had none. `src/routes/player/[id]/+page.svelte` keeps its
`NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than
flattening them into the page scope.

`grep -rn 'console\.' src/` now matches nothing outside the tests and the
facade itself.
2026-08-20 19:29:59 +02:00
dtourolle 4c82a0a025 feat(logging): add leveled logger facade
TRACES: | DR-204 | UT-201

The Rust half of the app logs through the `log` crate behind `env_logger`,
with `LevelFilter::Info` by default and `RUST_LOG` to turn the volume up
without a rebuild. The frontend had no equivalent at all: every
`console.log` written during development shipped to end users.

`createLogger(scope)` gives the frontend the same shape:

  - four levels (debug/info/warn/error), gated by severity;
  - verbose in dev, `warn` in production — warn and error are never gated
    away, because a silent failure in a networked media client is worse to
    support than a noisy console;
  - `localStorage["jellytau:logLevel"]`, read once at init, as the
    `RUST_LOG` equivalent so a user can gather verbose logs for a bug
    report without a rebuild. Guarded for SSR and for webviews where
    storage access throws;
  - the scope replaces the hand-written `"[Scope] …"` prefixes;
  - a thin pass-through: arguments reach `console.*` untouched and by
    reference, and `console` is resolved at call time so devtools
    overrides and test spies still see everything.
2026-08-20 19:29:48 +02:00
dtourolle 51d914777a ci: fix cache-key collisions and skip duplicate release-commit test run
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 28m26s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m41s
The test job and build-linux shared one cargo cache key; the test job's
debug artifacts claimed it first and actions/cache skips saving on an
exact-key hit, so Linux release builds compiled cold every time (~31min
vs ~9min for the correctly-keyed Windows job). Same collision between
android-check and build-android. Give the release jobs their own keys.

Also skip build-and-test.yml for chore(release) commits: the tag push
triggers build-release.yml on the same commit, which runs the identical
test suite, and the two ~1h workflows contended for the single runner
slot.
2026-08-19 22:00:19 +02:00
347 changed files with 21364 additions and 15378 deletions
+23
View File
@@ -0,0 +1,23 @@
# Local Android release signing.
#
# Copy to `.env` and fill in. `.env` is gitignored and is the single source of
# truth for local release signing — scripts/write-keystore-properties.sh reads
# it and regenerates src-tauri/gen/android/keystore.properties before every
# release build, because `tauri android init` overwrites that file.
#
# Only needed for `bun run android:build:release`. Debug builds sign with the
# local debug keystore and need nothing here.
#
# CI does not use this file: build-release.yml reconstructs the keystore from
# the ANDROID_KEYSTORE_BASE64 secret and writes the same properties itself.
# Key alias inside the keystore.
ANDROID_KEY_ALIAS=jellytau
# Absolute path to the .jks. Keep it outside the repo, or in the gitignored
# android-keystore/ directory.
ANDROID_KEYSTORE_FILE=/absolute/path/to/jellytau-release.jks
# Keystore and key passwords. These are secrets — never commit the filled-in .env.
ANDROID_KEYSTORE_PASSWORD=
ANDROID_KEY_PASSWORD=
+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)
+173 -25
View File
@@ -13,12 +13,22 @@ on:
- '**/*.md'
workflow_dispatch:
env:
# Incremental state is never reused between CI runs -- pure disk cost.
CARGO_INCREMENTAL: 0
jobs:
test:
name: Run Tests
# A release push triggers build-release.yml on the tag, which runs this exact
# test suite itself — and on a single-slot runner the two ~1h workflows would
# otherwise serialize/contend. Skip the duplicate for chore(release) commits.
# (head_commit is absent on pull_request/workflow_dispatch; startsWith(null,…)
# is false there, so those events still run.)
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
@@ -27,13 +37,22 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-host-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -56,10 +75,76 @@ jobs:
- name: Check frontend/backend boundary
run: bash scripts/check-frontend-boundary.sh
# The docs are the maintained source of truth for architecture and
# process, and they cross-reference each other heavily. A rename that
# misses a link turns a doc into a dead end silently. Pure shell + git —
# no tool is installed at job time.
- 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.
# Cheap tripwire for a class of defect this repo kept hitting: tooling on
# a rarely-taken path. scripts/build-android.sh ran `npm install` on its
# clean-build branch -- in a bun project, ignoring bun.lock and
# re-resolving the tree, which is how the Tauri plugin crate/package
# versions drifted apart and broke a release build. It survived because
# clean builds are rare.
- name: Check build tooling
run: bash scripts/check-tooling.sh
- 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
# Tauri refuses to build when a plugin's Rust crate and npm package are on
# different minor versions. Nothing here runs `tauri build` -- that only
# happens on a tag -- so a mismatch introduced on master stayed invisible
# until the release build, which is where it was found: v0.10.0 prep hit
# `tauri-plugin-log (v2.8.0) : @tauri-apps/plugin-log (v2.9.0)`. `cargo
# check`, clippy, the tests and svelte-check had all passed.
#
# `tauri info` performs the same comparison the bundler does, without a
# build. Grepping its output is crude, but the alternative is discovering
# this at tag time again.
- name: Check Tauri plugin versions match
run: |
set -e
if bunx tauri info 2>&1 | tee /tmp/tauri-info.txt | grep -q "version mismatched"; then
echo "::error::A Tauri plugin's Rust crate and npm package versions disagree."
echo "::error::The release build will refuse to start. Align them in"
echo "::error::src-tauri/Cargo.toml and package.json (both are pinned exactly)."
grep -A6 "version mismatched" /tmp/tauri-info.txt || true
exit 1
fi
echo "✅ Tauri plugin crate/package versions agree."
# 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,
@@ -71,21 +156,18 @@ jobs:
cd src-tauri
cargo fmt --all -- --check
# ⚠️ Advisory for now — clippy warnings do NOT fail this job yet.
# Clippy is a hard gate. It was advisory while the tree carried a warning
# backlog; that backlog is gone (0 warnings on 1.97.1, the pinned
# toolchain), so a warning here is now new breakage rather than old noise.
#
# The tree carries ~51 pre-existing warnings; adding `-D warnings` today
# would paint CI red on unrelated work. A compile *error* still fails the
# step, so this is not a no-op: it stops new breakage and surfaces the
# backlog in every run.
#
# TODO: once the existing warnings are cleared, tighten this to
# cargo clippy --all-targets -- -D warnings
# Flip that flag — do not delete the step. Track progress with
# `cd src-tauri && cargo clippy --all-targets 2>&1 | grep -c '^warning'`.
- name: Run clippy (advisory)
# This only means anything because src-tauri/rust-toolchain.toml pins the
# compiler: clippy's lint set moves between releases, so an unpinned gate
# would fail on whatever the runner happened to install. The pin and this
# flag stand or fall together — if you unpin, drop this back to advisory.
- name: Run clippy
run: |
cd src-tauri
cargo clippy --all-targets
cargo clippy --all-targets -- -D warnings
- name: Run Rust tests
run: |
@@ -105,7 +187,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
@@ -119,13 +201,22 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -148,3 +239,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"
+368 -91
View File
@@ -13,13 +13,15 @@ on:
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
# Incremental state is never reused between CI runs -- pure disk cost.
CARGO_INCREMENTAL: 0
jobs:
test:
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
@@ -27,13 +29,22 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-host-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -83,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
@@ -91,13 +102,22 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-host-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -118,21 +138,82 @@ 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.
# Same hazard as the Windows job: the bundle directory is never cleaned by
# cargo and the runner reuses src-tauri/target, while the copy step below
# globs bundle/deb/*.deb and friends. Windows is where this actually bit
# (v0.8.2 shipped thirteen stale installers), but only because Linux
# packaging is newer -- the glob is identical. Remove the directory so a
# stale artifact cannot exist to be copied.
- name: Clear previous bundle output
run: rm -rf src-tauri/target/release/bundle
- name: Build for Linux
run: bun run tauri build
env:
TAURI_SKIP_UPDATER: true
# linuxdeploy's bundled `strip` cannot parse the `.relr.dyn` section
# modern toolchains emit, and fails on every bundled library:
# strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
# failed to bundle project `failed to run linuxdeploy`
# Ubuntu 23.10+ links with -z pack-relative-relocs by default, so this
# image hits it. Skipping strip is linuxdeploy's documented escape
# hatch; the cost is a larger AppImage. Found by building the target
# locally before tagging -- nothing in CI builds the app, so a release
# would have been the first time anyone discovered the AppImage target
# does not work.
NO_STRIP: "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: |
mkdir -p dist/linux
# Copy AppImage
if [ -f "src-tauri/target/release/bundle/appimage/jellytau_"*.AppImage ]; then
cp src-tauri/target/release/bundle/appimage/jellytau_*.AppImage dist/linux/
# Match by extension, not by product name. Bundle filenames follow
# `productName`, so renaming the app (jellytau -> JellyTau) made the
# old `jellytau_*.deb` glob match nothing — and because the copy was
# wrapped in `if [ -f ... ]`, the artifact simply vanished from the
# release with no error. Each bundle directory holds one file.
#
# `if [ -f "dir/"*.ext ]` was also wrong on its own terms: with more
# than one match `test` gets extra arguments and fails.
#
# No `shopt -s nullglob` here: the runner executes `run:` blocks with
# POSIX sh, where shopt does not exist -- it exited 127 and killed the
# 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
# Copy .deb if built
if [ -f "src-tauri/target/release/bundle/deb/jellytau_"*.deb ]; then
cp src-tauri/target/release/bundle/deb/jellytau_*.deb dist/linux/
# 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/"
exit 1
fi
ls -lah dist/linux/
@@ -141,7 +222,7 @@ jobs:
with:
name: jellytau-linux
path: dist/linux/
retention-days: 30
retention-days: 7
build-windows:
name: Build Windows
@@ -151,7 +232,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
@@ -159,14 +240,31 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
~/.cache/cargo-xwin
src-tauri/target
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-windows-
${{ runner.os }}-cargo-registry-
- name: Cache Windows CRT/SDK (cargo-xwin)
uses: actions/cache@v3
with:
path: ~/.cache/cargo-xwin
# Contents track the xwin version baked into the builder image, not our
# lockfile -- keying this on Cargo.lock re-downloaded the whole SDK on
# every release bump. Bump the suffix by hand if the image's xwin moves.
key: ${{ runner.os }}-cargo-xwin-v1
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -188,6 +286,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/
@@ -197,14 +298,14 @@ jobs:
with:
name: jellytau-windows
path: dist/windows/
retention-days: 30
retention-days: 7
build-android:
name: Build Android
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
@@ -216,13 +317,22 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -267,8 +377,12 @@ jobs:
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
EOF
# `--apk` is a boolean flag, not `--apk true`. tauri-cli took a value here
# until 2.10; from 2.11 the stray `true` is parsed as a positional and the
# command fails with "unexpected argument 'true' found" before building.
# This line and scripts/build-android.sh must agree.
- name: Build signed Android APK
run: bun run tauri android build --apk true --target aarch64
run: bun run tauri android build --apk --target aarch64
- name: Collect & verify signed APK
run: |
@@ -286,7 +400,7 @@ jobs:
with:
name: jellytau-android
path: dist/android/
retention-days: 30
retention-days: 7
create-release:
name: Create Release
@@ -294,7 +408,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
@@ -323,63 +437,223 @@ jobs:
name: jellytau-android
path: artifacts/android/
# Runs before the SBOM, the checksums and the upload -- everything
# downstream describes this set of files, so a stale artifact must be
# caught before it gets hashed into SHA256SUMS and published as though it
# belonged to this release.
#
# See the script for the eight months of releases that shipped their
# predecessors' Windows installers.
- name: Verify artifacts belong to this release
run: |
./scripts/check-release-artifacts.sh \
"${{ steps.tag_name.outputs.VERSION }}" \
artifacts/linux artifacts/windows 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
# What the in-app update prompt shows. Same reviewed source as the
# release body -- the CHANGELOG section for this version, not the
# traceability draft.
NOTES="$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md | head -c 4000)"
[ -n "$NOTES" ] || NOTES="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
# The published body is the hand-written CHANGELOG.md section for this
# version. `bun run release:notes` is printed into the job log as a
# drafting aid, but is NOT published: CLAUDE.md is explicit that its
# output is "a reviewed draft, not a final changelog", and publishing it
# unreviewed proved the point -- a range containing a repo-wide prettier
# sweep resolved to nearly the whole requirement matrix and produced notes
# claiming one release had added the entire application.
#
# A missing CHANGELOG section fails the release. A release whose notes say
# nothing is worse than one that waits for a maintainer to write two
# sentences, and the checklist already requires that entry.
- 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 "" >> 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 "📋 Traceability draft (for reference; not published):"
bun run release:notes 2>/dev/null || echo "(could not derive a draft)"
echo ""
# The section between this version's heading and the next one.
CHANGES=$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md)
if [ -z "$(echo "$CHANGES" | tr -d '[:space:]')" ]; then
echo "::error::CHANGELOG.md has no '## $VERSION' section."
echo "::error::Add the entry for this version and re-tag; see docs/release-checklist.md."
exit 1
fi
{
echo "$CHANGES"
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 check for updates from here and can install a new"
echo "version in place, verifying its signature first."
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:
@@ -419,7 +693,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=82
MIN_THRESHOLD=89
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
exit 1
-5
View File
@@ -30,11 +30,6 @@ coverage
.nyc_output
*.lcov
# WebdriverIO E2E tests
e2e/logs/
e2e/screenshots/
wdio-*.log
# Vitest
.vitest
+35
View File
@@ -0,0 +1,35 @@
# Dependencies & build output
node_modules/
.svelte-kit/
# Scratch worktrees (git-ignored) — full checkouts of this repo
.claude/
build/
dist/
coverage/
/package/
# Rust backend (rustfmt owns this tree)
src-tauri/
# Generated by tauri-specta — regenerated on every Rust build, never hand-edited
src/lib/api/bindings.ts
# Lockfiles and generated data
bun.lock
*.lcov
# Generated docs (built by the publish-docs CI job)
docs/SUMMARY.md
docs/README.md
docs/api-redirect.md
docs-site/book/
# Hand-maintained Markdown (docs/, CHANGELOG.md, README.md, ...). Prettier
# reflows tables and wrapped prose, which would swamp real doc diffs and fight
# the hand-tuned layout of docs/requirements.md and docs/traceability.md
# (the latter is generated by scripts/extract-traces.ts).
**/*.md
# CI workflow YAML — formatting churn here would obscure real pipeline diffs.
.gitea/
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": { "parser": "svelte" }
}
]
}
+109
View File
@@ -9,6 +9,115 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.9.1
A one-line fix to the home screen, released on its own because it is the kind of
small wrongness you notice every time.
### 🐛 Fixes
- **Swiping the hero banner now buys you a full six seconds.** The rotation
timer was started once when the banner appeared and then left alone, so a
swipe, arrow or dot tap inherited whatever was left of the running countdown
— swipe five and a half seconds in and the banner moved on half a second
later, before you had read the title. Any manual change now restarts the
countdown from that moment. (UR-034 → DR-038)
## v0.9.0
An audit release. One new setting you asked for, two naming bugs that only ever
showed in builds a developer never looks at, and a large amount of tidying that
should be invisible in use.
Note for anyone upgrading a Linux package: the Debian/RPM package is now called
`jelly-tau` rather than `jellytau` (the packager derives it from the app name).
It declares the rename, so `apt`/`dnf` will replace the old package rather than
install a second copy. The command is still `jellytau`.
### ✨ Changes
- **You can now hide library folders from music browsing.** Pick the folders to
exclude in Settings; they disappear from albums, artists, genres, search and
the home rows alike. This replaces a filter that dropped anything *named*
"Podcasts" — one person's library layout compiled into the app, which meant an
album genuinely called "Podcasts" vanished while a podcast folder named
anything else stayed. Exclusion now matches on the folder itself, is decided
in one place rather than at the six screens someone remembered to filter, and
defaults to excluding nothing. (UR-076 → DR-209)
- **The app is called JellyTau again.** The Android release build showed
`jellytau` under its icon, and the Linux and Windows packages carried the same
lowercase name. The debug build has always overridden the label to "JellyTau
Debug", so the install a developer looks at every day was the only correctly
cased one and nobody saw it. (DR-214)
- **The RPM package is published.** It has been built by every release since
Linux packaging was added, and never copied out of the build — so it existed,
cost build time, and reached nobody. (DR-214)
- **Linux and Windows packages carry their own metadata.** Publisher, copyright,
category, description and licence were all absent, so the packages installed
with no maintainer and no description. The hand-written Arch package had all
of it; only the generated packaging was missing it. (DR-214)
### 🔒 Hardening
None of these were reachable in normal use — the app refuses plain-`http`
servers, Android blocks cleartext, and the webview runs under a CSP that bars
inline script — so they are consistency fixes rather than incidents. Each one
had the correct pattern already in the same file, a few lines away.
- **Thumbnail cache writes stay inside the cache directory.** The filename was
built from three values but only one was sanitised, and joining a path does not
fold `..` or keep the base when handed an absolute path. (DR-210)
- **Download paths stay inside the download directory.** A correct sanitiser
already existed, but the command that queues a download accepted a raw path,
so the guard could be routed around rather than being absent. (DR-211)
- **Query and URL values are bound and encoded, not pasted in.** The offline
item-type filter built SQL by string formatting while its sibling query used
placeholders, and browse URLs left values unencoded while the genre parameter
next to them was encoded properly. Volume is also range-checked at the command
boundary instead of relying on each player backend. (DR-212)
### 🛠 Development
Nothing here changes the app, but the previous release's audit found the tooling
claiming more than it delivered, and this is the repair.
- **The frontend has a real logger.** 484 `console` calls shipped to users and
ran on every device; the Rust half has had levelled logging with a runtime
override since the beginning. There is now a matching facade — quiet in
release builds, verbose in debug ones, with warnings and errors never
suppressed and `localStorage` able to turn the volume up in a shipped build to
diagnose a problem. (DR-204)
- **The traceability matrix is navigable.** Every one of its ~2,800 file links
was broken: the generator wrote repo-root paths into a file that lives in
`docs/`. The document the whole traceability system exists to produce could not
be clicked through, and had no test. Both are fixed, and a link checker now
fails the build on a dead documentation link. (DR-093, DR-208)
- **The Rust toolchain is pinned.** Developer machines and CI were five releases
apart, which meant a clean `cargo clippy` locally proved nothing about CI — the
same tree measured zero warnings on one and three on the other. With both sides
on the same compiler, clippy is now a hard gate instead of advisory. (DR-206)
- **The frontend has a linter and formatter**, its first — the Rust half has had
`cargo fmt --check` and clippy in CI for a while. A pre-commit hook runs the
fast checks, so the "before committing" list is enforced rather than
remembered. (DR-205, DR-207)
- **Containerised builds no longer leave root-owned files** in the working tree,
which had accumulated to the point of breaking `cargo clean` and, eventually,
`cargo build` itself. (DR-213)
- Removed: a webdriverio end-to-end suite that had not run in seven months and
was wired into nothing, and a frontend validation module whose six exported
functions had no caller outside their own tests — which made it read as
covered input validation while guarding nothing.
## v0.8.2
A single fix, for Android background audio.
+41 -7
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
@@ -170,7 +174,7 @@ canonical, maintained source; this file only summarizes. See
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
and [docs/build-release.md](docs/build-release.md).
and [docs/build/build-release.md](docs/build/build-release.md).
### Core principles (from the architecture docs)
@@ -212,7 +216,9 @@ and [docs/build-release.md](docs/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.
+47 -3
View File
@@ -52,13 +52,34 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
RUN curl -fsSL https://bun.sh/install | bash && \
ln -s /root/.bun/bin/bun /usr/local/bin/bun
# Install Rust using rustup
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
# Install Rust using rustup, pinned to an exact release.
#
# 🔴 RUST_VERSION must equal `channel` in src-tauri/rust-toolchain.toml.
#
# The two are a pair. rust-toolchain.toml is what makes a developer's `cargo
# clippy` agree with CI's; this line is what makes the image already contain that
# toolchain. If they drift, rustup silently downloads the pinned version the
# first time cargo runs inside a job — a toolchain install at job time, which
# CLAUDE.md's "🔴 CI installs no system tools" rule forbids (and which costs
# ~1min plus a network dependency on every build).
#
# 🔴 Changing this line does NOT change CI on its own: the image must be
# rebuilt and pushed (`scripts/build-builder-image.sh`) before the new pin is
# authoritative. Bump rust-toolchain.toml and this line together, rebuild, push,
# then merge.
#
# Was: `sh -s -- -y` (latest stable, whatever it happened to be on rebuild day).
ENV RUST_VERSION=1.97.1
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain "$RUST_VERSION" && \
. $HOME/.cargo/env && \
rustup default "$RUST_VERSION" && \
rustup target add aarch64-linux-android && \
rustup target add armv7-linux-androideabi && \
rustup target add x86_64-linux-android && \
rustup component add rustfmt clippy
rustup component add rustfmt clippy && \
rustc --version && \
cargo clippy --version
# Setup Android SDK
RUN mkdir -p $ANDROID_HOME && \
@@ -131,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"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Duncan Tourolle
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24 -1
View File
@@ -42,11 +42,34 @@ For the full set of build, test, and Android helper scripts, see
|-------|----------|
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
| Build & release process | [docs/build-release.md](docs/build-release.md) |
| Build & release process | [docs/build/build-release.md](docs/build/build-release.md) |
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
| 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.
+174 -804
View File
File diff suppressed because it is too large Load Diff
+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:
+23 -3
View File
@@ -22,15 +22,35 @@
- [Database Design](architecture/08-database-design.md)
- [Security](architecture/09-security.md)
# UX & Specs
# UX
- [UX Flows](ux-flows.md)
- [Video Background Audio](specs/video-background-audio.md)
# Specs — Pending Work
- [Specs Index](specs/README.md)
- [Spec Template](specs/SPEC-TEMPLATE.md)
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
- [Playback Backend Unification](specs/playback-backend-unification.md)
- [Linux Native Video Spike](specs/linux-native-video-spike.md)
- [Backend-Owned Stream Selection](specs/backend-owned-stream-selection.md)
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
- [libmpv2 Migration](specs/libmpv2-migration.md)
- [Read-Through Media Cache](specs/read-through-media-cache.md)
- [Scoped Search](specs/scoped-search.md)
- [Scoped Search Boundary](specs/scoped-search-boundary.md)
- [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md)
- [Frontend Domain Model](specs/frontend-domain-model.md)
- [Build Provenance](specs/build-provenance.md)
# Build & Release
- [Build & Release](build-release.md)
- [Build & Release](build/build-release.md)
- [Release Checklist](release-checklist.md)
- [Desktop Packaging](build/build-desktop-packages.md)
- [Windows Build](build/build-windows.md)
- [Defect Windows](defect-windows.md)
- [Docker](build/docker.md)
- [Builder Image](build/build-builder-image.md)
+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`.
@@ -6,14 +6,14 @@ run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
## One builder image (shared with CI)
The deb/rpm and Windows-cross flows build on the **unified registry builder**
([../Dockerfile.builder](../Dockerfile.builder) →
([../Dockerfile.builder](../../Dockerfile.builder) →
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
(Windows). There is **one** dependency source of truth — no per-stage tool
installs.
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
The desktop stages in [../Dockerfile](../../Dockerfile) are thin `FROM
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
your bind-mounted source (like the `dev` service), so source edits need no image
rebuild.
@@ -28,7 +28,7 @@ docker build -f Dockerfile.builder -t jellytau-builder:latest .
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
```
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../../Dockerfile.arch))
because `makepkg` is Arch-specific — it is not part of the unified builder.
| Target | Format | Docker command | Functional? |
@@ -40,7 +40,7 @@ because `makepkg` is Arch-specific — it is not part of the unified builder.
## Linux: deb + rpm
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
builder image ([../Dockerfile](../../Dockerfile), `desktop-linux-build` stage):
```bash
bun run docker:build:linux # deb + rpm -> ./dist
@@ -58,8 +58,8 @@ transcoded video). The deb/rpm declare these.
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
[../packaging/arch/PKGBUILD](../../packaging/arch/PKGBUILD) and build it with
`makepkg` on an Arch base image ([../Dockerfile.arch](../../Dockerfile.arch)):
```bash
bun run docker:build:arch # .pkg.tar.zst -> ./dist
+19 -6
View File
@@ -116,17 +116,30 @@ Runs after both builds succeed (only on version tags):
- **Use:** Run directly on any Linux distro
- **Installation:**
```bash
chmod +x jellytau_*.AppImage
./jellytau_*.AppImage
chmod +x JellyTau_*.AppImage
./JellyTau_*.AppImage
```
#### DEB Package
- **File:** `jellytau_*.deb`
- **File:** `JellyTau_*.deb`
- **Size:** ~80-120 MB
- **Use:** Install on Debian/Ubuntu/similar
- **Installation:**
```bash
sudo dpkg -i jellytau_*.deb
sudo dpkg -i JellyTau_*.deb
jellytau
```
- **Note:** the Debian package is named `jelly-tau` (Tauri kebab-cases
`productName`), while the command stays `jellytau`. The package declares
`Replaces`/`Conflicts`/`Provides: jellytau`, so upgrading from a release built
before the rename replaces it rather than installing a second copy.
#### RPM Package
- **File:** `JellyTau-*.rpm`
- **Use:** Install on Fedora/openSUSE/similar
- **Installation:**
```bash
sudo rpm -i JellyTau-*.rpm
jellytau
```
@@ -294,8 +307,8 @@ bun run tauri build # Local build test
```
### Documentation
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes
2. Update [README.md](../README.md) with new features
1. Update [CHANGELOG.md](../../CHANGELOG.md) with changes
2. Update [README.md](../../README.md) with new features
3. Document breaking changes
4. Add migration guide if needed
+3 -3
View File
@@ -12,10 +12,10 @@ job / SMTC lockscreen), but it runs and plays media.
h264 fine. No Windows-specific code.
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
ExoPlayer (Android); neither exists on Windows. Instead
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
`create_player_backend()` in [../src-tauri/src/lib.rs](../../src-tauri/src/lib.rs)
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
URL to a webview `<audio>` element (see
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
[../src/lib/services/webviewAudio.ts](../../src/lib/services/webviewAudio.ts)),
which reports state back through the same `player_report_*` round-trip the video
path uses. Pure Rust + Tauri events.
@@ -33,7 +33,7 @@ Tauri CLI bundle the **NSIS installer from a Linux host**.
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
The builder image ([../Dockerfile.builder](../../Dockerfile.builder)) bakes in the
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
`llvm`, and `nsis`.
+188
View File
@@ -0,0 +1,188 @@
# 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.
## Tauri plugin versions are pinned in pairs
Every Tauri plugin exists twice: a Rust crate in `src-tauri/Cargo.toml` and an
npm package in `package.json`. **The Tauri CLI refuses to build when the two are
on different minor versions** — not a warning, a hard stop before compilation.
Both sides are therefore pinned *exactly* (`"2.8.0"`, not `"^2.8.0"`). A caret
range is what let them drift apart in the first place: `bun add` took the latest
npm package while cargo held an older crate, and nothing noticed until a release
build refused to start.
Nothing in `build-and-test.yml` runs `tauri build` — that happens only on a tag —
so this class of breakage used to be invisible until release day. The
`Check Tauri plugin versions match` step runs `tauri info`, which performs the
same comparison without building.
To upgrade a plugin, move **both** sides together and re-run that step. Expect
the Rust side to be the constraint: a newer plugin crate may pull a large
transitive upgrade (bumping `tauri-plugin-log` to 2.9.0 also moved `wry`,
`wasm-bindgen`, `web-sys` and `webkit2gtk`), which touches the webview and
therefore video playback. That is a change to make deliberately, with a full
build and a playback check — not one to slip into a release.
## 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.
-532
View File
@@ -1,532 +0,0 @@
# JellyTau Codebase Audit
**Date:** 2026-08-16 · **Version:** v0.6.0 · **Commit:** `be907b49` (master)
A review of the Rust/Svelte/Android codebase against its own requirements matrix
and against current Android and Tauri v2 platform practice. Every finding was
verified by running the project's own tooling or reading the code it points at —
nothing here is inferred from documentation alone.
**Scale:** 55,835 LOC Rust · 50,490 LOC TS/Svelte · 530 requirements · 824 traces
| Severity | Count |
|----------|-------|
| High | 5 |
| Medium | 9 |
| Low | 6 |
| Tests passing | 1,719 |
| Untraced requirements | 86 |
| Traceability coverage | 86% (285/330) |
> **Revisions, 2026-08-16.** Three rankings changed after device testing and
> platform research, all documented in place:
> - **B1 High → Low.** The predicted impact was refuted on a physical Android 16
> device. The residual risk turned out to be a different, narrower one.
> - **B7 Low → Medium, re-framed.** The original reading of predictive back was
> backwards: at targetSdk 36 it is already enabled, not merely un-opted-into.
> - **B8 added (Medium).** Android 16 Local Network Protections versus a
> LAN-hosted Jellyfin server.
> - **D3 Medium → Low.** The "820 unwraps" figure was a measurement error; the
> real number is 19, and none are in command handlers.
> - **B1's stated mechanism was wrong** even though its conclusion held. FGS
> notifications are *not* exempt from `POST_NOTIFICATIONS`; media-session
> notifications are. See B1 — the distinction changes what the fix should be.
>
> Original ranking was 6 High / 8 Medium / 5 Low.
**Verified by running:** `bun run check` · `bun run test` · `cargo test` ·
`cargo clippy --all-targets` · `bun run check:boundary` · `bun run traces:json`
**Device-verified (2026-08-16):** B1 and B2 were checked against a physical HONOR
ROD2-W09 running Android 16 (SDK 36) with the shipped app installed. B2 was
confirmed; B1 was refuted and downgraded.
**Not covered:** the e2e suite (`test:e2e` is not wired into CI and was not run),
Windows and Arch packaging paths, and the docs-site build. B3, C1 and C2 still
need a device/desktop playback pass.
---
## A. Requirements versus code
The traceability matrix is the project's own claim about what is built. Of 530
defined requirement IDs, 86 carry no `TRACES:` tag anywhere in the tree. Most of
those gaps are documentation debt rather than missing features — which is
precisely the problem, because it makes the matrix unreliable as evidence.
### A1 · High · Twelve requirements are marked "Done" but have zero traces
`UR-006` (lockscreen/BLE control), `UR-037` (video library presentation),
`IR-006` (Android MediaSession), `IR-008` (audio focus), `IR-022` (person/cast
API), `IR-024` (home-screen API) and six Jellyfin API requirements (`JA-006`,
`JA-009`, `JA-013`, `JA-014`, `JA-015`, `JA-018`) all claim completion with
nothing pointing at an implementation.
These features demonstrably work — lockscreen control, Next Up, favourites are
all shipped. The code is there; the tags are not. That means the matrix currently
over-reports on exactly the requirements a reviewer would most want to verify,
and a regression in any of them would leave no trace to follow.
**Fix:** Tag the existing implementations. Highest value per keystroke in the
whole audit: six of the twelve are single Jellyfin API call sites.
### A2 · Medium · Requirement statuses contradict each other across layers
`UR-020` (subtitle selection) and `UR-021` (audio track selection) are marked
*Done*, while the integration requirements they decompose into — `IR-018` and
`IR-019`, both libmpv-specific — are still *Planned*. Similarly `IR-005` (MPRIS)
sits at *Planned* under a *Done* `UR-006`.
The likely truth is that these user requirements were satisfied through a
different path than the one originally specified (HTML5 `<video>` and ExoPlayer
rather than libmpv), and the IRs were never re-scoped. Left as-is, the matrix
reads as though shipped features depend on unbuilt integrations.
**Fix:** Re-scope or retire the stale IRs so each Done UR rests on Done IRs.
### A3 · Medium · The traceability gate is set far below actual coverage
`traceability-check.yml` fails only below 50%. Real coverage is well above that,
so the gate cannot catch a coverage regression until roughly half the matrix has
rotted. A gate that can only fire after a catastrophe is not protecting anything.
**Measured coverage: 86% (285/330)** — UR 71/75, IR 19/32, DR 166/187, JA 29/36.
IR is by far the weakest dimension, which corroborates A1.
**Fix applied:** `MIN_THRESHOLD` ratcheted 50 → 82, with the ratchet policy
written into the workflow (only goes up; never lowered to make a red build pass).
The same figure is mirrored as `MIN_COVERAGE_PERCENT` in
`scripts/extract-traces.ts` so local `traces:coverage` gates on the same bar, and
a test parses the workflow YAML and fails if the two drift apart.
### A4 · Low · Two traced IDs do not exist in the requirements document
`DR-189` and `UT-188` are referenced by `TRACES:` comments but are defined
nowhere in `docs/requirements.md`. The extraction tool accepts them silently, so
typos and renames pass unnoticed.
**Fix:** Add a dangling-ID check to the extractor and fail CI on it — cheap, and
it keeps the matrix honest in both directions.
### A5 · Not a gap · The remaining untraced requirements are legitimately unbuilt
`UR-016`, `UR-022` and `UR-070` are Planned or Proposed, and `UR-031`
(crossfade) is explicitly blocked by `DR-034`. Their absence from the trace graph
is correct and needs no action — noted so it does not get swept into the fix list.
---
## B. Android platform practice
The app targets SDK 36 with a minSdk of 24. Several manifest and WebView settings
still reflect an earlier target level.
### B1 · Low · `POST_NOTIFICATIONS` is declared but never requested at runtime
*Downgraded from High. The original ranking was refuted by device testing — the
evidence is below, and it is the reason this finding is now near-trivial.*
The permission appears in the manifest, but there is no `requestPermissions` call
anywhere in the Kotlin, Rust or TypeScript sources, and
`JellyTauPlaybackService.startForeground()` runs with no `checkSelfPermission`
guard. On Android 13+ notification permission defaults to denied.
This was ranked High on the theory that it would suppress the media notification
and with it the lockscreen transport controls (`UR-006`). Testing on an HONOR
ROD2-W09 running **Android 16 (SDK 36)**, with the shipped app installed and
playing, shows otherwise. The permission is genuinely denied:
```
POST_NOTIFICATIONS: granted=false, flags=[USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
appops POST_NOTIFICATION: ignore
```
and the notification is nonetheless live and complete:
```
ServiceRecord{... com.dtourolle.jellytau/.player.JellyTauPlaybackService}
isForeground=true foregroundId=1 types=0x00000002
foregroundNoti=Notification(flags=NO_CLEAR|FOREGROUND_SERVICE
category=transport actions=3 vis=PUBLIC)
```
**`UR-006` is not at risk.** But the *reason* is not the one this audit first
gave, and the correction is load-bearing rather than pedantic.
The first explanation here was "foreground-service notifications are exempt." That
is wrong. Android's own wording is that the permission covers "non-exempt
(**including Foreground Services (FGS)**) notifications", and that users who deny
it see FGS notices "in the Task Manager but [not] in the notification drawer" — an
FGS notification is explicitly *not* exempt. What is exempt is **media-session**
notifications. The platform predicate is `Notification.isMediaNotification()`,
requiring `MediaStyle`/`DecoratedMediaCustomViewStyle` **and** a non-null
`EXTRA_MEDIA_SESSION`; it is byte-identical across API 3336, and
`NotificationManagerService` has no FGS clause in either enforcement site.
Why the difference matters: under the FGS theory, anything the service posts is
safe, and the code needs no care. Under the correct one, the exemption is earned
per-notification by the token — so losing the token loses not just the shade entry
but the lockscreen controls entirely, since SystemUI's media carousel
(`MediaDataProcessor.onNotificationAdded`) gates on the *same* predicate. A
token-less notification never even reaches the notification listener.
**The real risk here is not the permission — it is how narrowly the exemption is
earned.** AOSP's `Notification.isMediaNotification()` grants it only when the
style is `MediaStyle`/`DecoratedMediaCustomViewStyle` **and**
`Notification.EXTRA_MEDIA_SESSION` holds a non-null *platform* session token. If
either is missing while the permission is denied, the notification is **silently
suppressed** — no exception, no log.
JellyTau earns it at two sites, both of which hang it on a null-safe call:
```kotlin
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSessionCompat?.sessionToken) // :273 and :466
```
Ordering currently saves it — `mediaSessionCompat` is assigned in `onCreate`
(:195) and `createBasicNotification()` is only reached from `onStartCommand`
(:251) — and the device test confirms it works. But it is one reordering away
from breaking invisibly, and only for users who denied the permission, which is
a population most developers never test as.
**Fix:** Keep the permission declared — download-service FGS notifications are
*not* covered by the media exemption, and this app has a downloads feature that
may want them. Comment both `setMediaSession` sites to record what earns the
exemption, and log loudly if the token is ever null at build time, converting a
silent failure into a diagnosable one.
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt:251`, `:273`, `:466`
### B2 · High · Cloud backup is on by default, and it will break credential restore
The manifest sets neither `android:allowBackup="false"` nor a
`dataExtractionRules`/`fullBackupContent` file, so Android's default applies: the
app's data directory is backed up to the user's Google account. That ships the
SQLite catalogue — library metadata and watch history — off the device.
The credential path makes it worse rather than better. `SecureStorage.kt`
encrypts with AES/GCM under an Android Keystore key, and Keystore keys are never
backed up. A user restoring onto a new phone therefore gets the ciphertext
without the key: undecryptable credentials and a silent authentication failure,
with no code path that recognises the situation.
**Fix applied.** `allowBackup="false"`. Extraction rules that merely excluded the
DB and credential prefs would have left nothing worth backing up: the SQLite
catalogue is a rebuildable mirror of the server and watch state lives server-side,
so there is no user-authored data to preserve.
**A gap this audit missed:** on API 31+, `allowBackup="false"` disables *cloud*
backup but **not device-to-device transfer**, which reproduces the identical
failure — the prefs travel, the Keystore key does not. A
`data_extraction_rules.xml` excluding all five domains from both `<cloud-backup>`
and `<device-transfer>` was added to close it.
**A real bug found while fixing this:** the Rust encrypted-file fallback in
`credentials.rs` propagated a decrypt failure as `CredentialError::Encryption`,
which `storage_get_access_token` turned into a hard `Err` — so an undecryptable
blob was an error state, not a logout. It now logs and returns an empty map, so
the caller sees `NotFound``Ok(None)` → login screen, and the next sign-in
self-heals the file. `SecureStorage.getCredential` on the Kotlin side already
returned null, but could not distinguish "nothing stored" from "unreadable" and
left the dead blob in prefs forever; it now separates the cases and discards it.
Three tests written and watched fail first, per the red→green rule.
### B3 · High · `MIXED_CONTENT_ALWAYS_ALLOW` undoes the network security config
`network_security_config.xml` is careful and well-argued: cleartext blocked
everywhere, exempted only for `127.0.0.1` so the local media server can serve
downloads. Its own comment warns "this must not become a blanket cleartext
opt-in."
But `MainActivity.kt` sets `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW`, which
permits the WebView to load http subresources into an https page from any origin.
Alongside it, `allowFileAccess = true` and `allowContentAccess = true` are both
broader than anything the app needs, since Tauri serves the UI from its own scheme
and media comes from the token-guarded loopback server. These read as leftovers
from before the media server existed.
**Fix:** Drop to `MIXED_CONTENT_COMPATIBILITY_MODE` and set both file and content
access to false, then verify offline video still plays.
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt:504-507`
### B4 · Medium · Android TV is half-declared
The manifest advertises `LEANBACK_LAUNCHER` and a non-required leanback feature,
but omits `<uses-feature android:name="android.hardware.touchscreen"
android:required="false"/>` and an `android:banner`. That combination fails Play's
TV validation, and on a real TV the app would launch into a UI with no D-pad focus
model behind it.
**Fix:** Either commit to TV — add the feature declaration, a banner, and a focus
pass — or remove the leanback category until you do.
### B5 · Medium · `jvmTarget` is pinned to 1.8 under compileSdk 36
The Kotlin target has not moved with the SDK. AGP 8 warns on it, and it locks the
Kotlin sources out of APIs and desugaring behaviour that everything else in the
toolchain assumes.
**Fix:** Move `jvmTarget` and the Java source/target compatibility to 17.
### B6 · Low · Media3 is several minor versions behind
`androidx.media3` is pinned at 1.5.0 across exoplayer, hls, session and common.
Given how much of this app's hard-won behaviour lives in ExoPlayer edge cases —
truncated progressive streams, background audio handoff, HLS resume — staying
current on its bug-fix releases has unusually high value here.
**Fix:** Schedule a Media3 bump with a device pass over the playback regression list.
### B7 · Medium · Predictive back is already on, not merely un-opted-into
*Upgraded from Low, and re-framed — the original framing was backwards.*
The audit first read the absent `enableOnBackInvokedCallback` as the app
*forgoing* the Android 13+ back-gesture preview. That is not what the flag means
at this target level. Predictive back is enabled by default for apps targeting
recent SDKs, and Android 16's own behaviour-change list carries "Migration or
opt-out required for predictive back" — with the opt-out being removed. Targeting
36, JellyTau is already getting predictive back; it simply hasn't been checked
against it.
That matters more than a missing opt-in would, because the app does not use
ordinary Android back. It runs a WebView with its own history model —
`src/lib/utils/navigation.ts` tracks a depth counter, applies a popstate delta,
and falls back to a path when `history.back()` would trap the user, with
`scrollRestore.ts` keying off the same popstate events. That is exactly the kind
of custom back handling predictive back is most likely to disagree with.
**Fix:** This is a device test, not a code change — exercise the back gesture
(including the drag-and-release preview and the cancel) from a library page, a
detail page, the player, and the settings screen, and watch for the depth counter
desynchronising. Only change code if it misbehaves.
Separately and unrelatedly: `JellyTauPlaybackService` is `exported="true"` with a
`MediaSessionService` intent filter — conventional for Media3, but it means any
app on the device can attempt to bind and drive playback. Confirm the session's
`onConnect` callback rejects unknown packages.
### B8 · Medium (forward-looking) · Android 16 Local Network Protections vs a LAN Jellyfin server
*New finding, surfaced while researching B1.*
Android 16's behaviour-change list includes **Local Network Permission**. JellyTau's
entire purpose is reaching a Jellyfin server that, for most users, sits on the
local network — so a permission gate on local-network access is a direct threat to
the app's core function, not a peripheral concern.
Stated carefully, because the timing matters: in Android 16 this is **opt-in for
testing**, not enforced by default, with enforcement signalled for a future
release. Nothing is broken today, and the device test will not surface it. But
this is the rare platform change that could stop the app working at all, and it
is much cheaper to handle before it is mandatory.
**Fix:** Investigate what the permission will require, then test the app against
it with the opt-in flag enabled on the Android 16 device already to hand. Track it
as a release-blocking item for whichever Android version enforces it.
---
## C. Tauri v2 configuration
The capability model here is genuinely well done — see section E. The gaps are in
the two settings that govern what a compromised web layer could reach.
### C1 · High · `"csp": null` contradicts the project's own security convention
`CLAUDE.md` lists "keep the CSP restrictive in `tauri.conf.json`" as a standing
rule; the config disables CSP entirely. With it off, any script that reaches the
web layer inherits the full IPC surface.
The realistic exposure today is low, and worth stating plainly rather than
inflating: the frontend has a single `{@html}` — an app-owned icon in
`GenericGenreBrowser.svelte`, not server data — and no `innerHTML`, `eval` or
`new Function` outside tests. So this is a missing defence rather than an open
hole. But it is the defence that stops the next careless interpolation of a
Jellyfin-supplied string from becoming a full compromise.
**Fix:** Set a CSP permitting `'self'`, `asset.localhost`, `http://127.0.0.1:*`
for media, and the configured Jellyfin origin for images. Expect one or two
iterations against HLS playback.
### C2 · Medium · The asset protocol scope is wider than what it serves
`assetProtocol.scope` is `$APPDATA/**`, which covers the whole app data directory
— the SQLite database and the credential store included — while the protocol only
needs to reach cached thumbnails and downloaded media.
Since `DR-137` introduced the token-guarded loopback media server, the asset
protocol's remaining job may be thumbnails alone, which would make the narrowing
nearly free.
**Fix applied:** scoped to `$APPDATA/thumbnails/**`. Confirmed on device that
`jellytau.db` (8 MB catalogue) and `shared_prefs` sit in the `$APPDATA` root and
are now outside the grant.
**But device testing found the finding was aimed at the wrong thing.** The asset
protocol is not narrowly used — it is **entirely unused at runtime**:
- `getCachedImageUrl` in `imageCache.ts` has **no production callers**. Its only
references are its own test file. `convertFileSrc`'s sole production mention
sits inside that uncalled function, so it never executes.
- The real path is `MediaCard``CachedImage``commands.imageGetUrl()`, which
returns **base64 from Rust**. Every image in the app is a `data:` URI delivered
over IPC.
- Confirmed on device: zero `asset.localhost` requests across a full session of
browsing home, the library list and a poster grid; the thumbnail cache stayed
at 12 files and never grew, because nothing calls `thumbnailSave` either.
Two consequences worth acting on, neither yet done:
1. **The `protocol-asset` Cargo feature and the whole `assetProtocol` config
block can likely be removed**, which retires the attack surface rather than
shrinking it. `imageCache.ts` is dead code and can go with it.
2. **`img-src` in the new CSP can be much tighter.** It currently grants
`http: https:` on the reasoning that thumbnails are fetched direct-from-server
on a cache miss — but they are not; they arrive as data URIs. With no
webview-side server image loads anywhere in `src/`, `img-src 'self' data:
blob:` should suffice. That is a real tightening the CSP work left on the
table because it reasoned from the dead code path.
Both need their own device pass, since a wrong `img-src` blanks every image.
### C3 · Low · Shipped desktop bundles have no update path
The bundle targets deb, rpm and nsis, but `tauri-plugin-updater` is not among the
dependencies. Every desktop user upgrades by manually fetching a new package,
which in practice means a long tail of installs pinned to whatever version they
first downloaded.
**Fix:** Add the updater plugin with a signed release manifest, or document the
manual upgrade path in the README so the omission is at least deliberate.
---
## D. CI and code health
Local discipline in this project is strong and well documented. CI enforces only
part of it, which means the discipline holds exactly as long as every contributor
remembers it.
### D1 · High · CI runs neither `cargo clippy` nor `cargo fmt --check`
`CLAUDE.md` requires both before committing. Neither appears anywhere in
`.gitea/workflows/`. The build-and-test job runs the boundary check, the frontend
tests, the Rust tests and an Android `cargo check` — a good set, with the two lint
gates missing.
Clippy currently reports 51 warnings across the lib and its tests, including
unused imports and a redundant import that a gate would have stopped at the door.
**Fix:** Add both to the test job. Start with `-D warnings` on new code only if
clearing the existing 51 is too large a first step.
### D2 · Medium · A flaky test will intermittently redden CI
`offlineCatalog.test.ts` — "pushes include=true while the server is reachable"
(`UT-068`) — timed out at the 5 s limit during a full-suite run, then passed twice
in isolation taking 1.13 s and 0.61 s.
**Root cause (corrected):** this audit originally attributed it to a real
wall-clock timer. It isn't. The cost is the **first dynamic
`import("./offlineCatalog")`**, which pays to transform the service and its whole
dependency graph (~1072 ms cold) inside a test body, charged against vitest's 5 s
default. Later re-imports after `vi.resetModules()` cost ~30 ms. Under full-suite
contention the cold transform alone crosses the limit.
**Fix applied:** warm the import once at collection time with a top-level
`await import(...)`, so no test is timing the compiler. Slowest test 1072 ms →
129 ms; file total 1170 ms → 238 ms. Timeout deliberately left at the default.
A latent cross-test leak was also fixed alongside it — the store shim's
subscribers were never cleared, so every module instance discarded by
`resetModules()` kept pushing its own visibility value.
**Location:** `src/lib/services/offlineCatalog.test.ts:58`
### D3 · Low · ~~820~~ **19** production `unwrap()`/`expect()` calls
*Downgraded from Medium. This audit substantially overstated the problem, and the
correction is worth recording because the measurement error is instructive.*
The original 820 figure came from grepping for `unwrap()`/`expect()` and filtering
lines containing "test". That does not exclude test *modules* — it only excludes
lines with "test" in them. Scripting the actual `#[cfg(test)]` boundaries gives
**19 real production sites**, not 820. `player/mod.rs`'s 154 hits, for instance,
are *all* past its `#[cfg(test)]` at line 2183, as are the bulk of
`repository/offline.rs`, `storage/mod.rs` and `commands/download/mod.rs`.
**More importantly: zero bare unwraps exist in any `#[tauri::command]` handler.**
The specific risk this finding was built around — a panic inside a command killing
the task and stranding shared player state — is already absent.
The same correction applies to the lock half: all 33 raw `.lock().unwrap()` hits
were in test modules (three weren't even code, but prose in `utils/lock.rs`'s doc
comment). Production was already fully on `lock_safe()`/`read_safe()`/
`write_safe()`. Converting them was consistency work, not a bug fix.
**What is genuinely worth doing** is a three-site cluster, all the same pattern —
`Runtime::new().unwrap()` in threads owning playback-critical state:
| | Site | Consequence of a panic |
|---|------|------------------------|
| 1 | `session_poller/mod.rs:102` | Poller thread dies silently; it drives remote-mode state *and* offline→online recovery, so the app strands offline with nothing surfaced |
| 2 | `player/mpv_backend.rs:424` | Position reporting stops mid-playback; the scrubber freezes while audio keeps going |
| 3 | `player/android/mod.rs:761` | Same pattern across a JNI boundary; progress reporting dies and no resume points are written |
**Fix:** One shared helper returning `Option<Runtime>` and logging on failure
retires all three. The remaining 16 are startup `expect()`s and two provably
infallible calls.
### D4 · Low · Five files carry a disproportionate share of the complexity
`player/mod.rs` (4,726 lines), `repository/offline.rs` (4,696),
`repository/online.rs` (3,702), `commands/player/mod.rs` (3,299) and
`commands/download/mod.rs` (3,226), plus `VideoPlayer.svelte` (2,778) on the
frontend.
These are the same files the changelog keeps returning to for deadlocks and
playback regressions. Not a defect in itself, and not worth a speculative
refactor — but the next time one of them needs substantial work, splitting it is
likely cheaper than continuing to grow it.
---
## E. Verified sound
Things this audit specifically went looking for and found in good order —
including one that looked alarming from the warning output and turned out to be
fine.
| Area | Finding |
|------|---------|
| **The 9 "MutexGuard across await" warnings are test-only** | All nine sit in `#[tokio::test]` functions holding a serialization lock, not in the production async paths that `CLAUDE.md`'s deadlock gotcha warns about. |
| **The local media server is exemplary** | Loopback-only bind, a 32-hex-char per-session token, lexical `..` folding rather than `canonicalize`, and a test asserting reads stay inside the data directory. |
| **Tauri capabilities are minimal** | Three permissions total — `core:default`, `opener:default`, `core:path:default`. No blanket grants, no `withGlobalTauri`. |
| **SQL is parameterised** | Two `format!`-built statements in the whole Rust tree, neither interpolating caller-controlled input into a query. |
| **R8 keep rules are correct and explained** | JNI-loaded player and security classes, the JavascriptInterface bridges and Media3 are all kept, each with a comment naming the crash it prevents. |
| **Type and boundary gates are green** | `svelte-check`: 0 errors, 0 warnings. `check:boundary` passes with three reviewed allowlist entries. 698 Rust tests and 1,021 frontend tests pass. |
---
## F. Suggested order
Sequenced so the cheap gates land before the work they would have caught. B2
leads because it is the finding a user is most likely to actually feel.
*(B1 originally led this list. It was demoted to row 10 after device testing —
see B1. This is a good advertisement for testing a finding before scheduling
work against it.)*
| # | Finding | What it buys | Effort |
|---|---------|--------------|--------|
| 1 | B2 | Catalogue and credentials stop leaving the device; restore stops failing silently | S — **confirmed on device**: `ALLOW_BACKUP` set, Google transport active |
| 3 | D1 | Lint discipline becomes enforced rather than remembered | S |
| 4 | B3 | The network security config actually holds | S — needs an offline-playback check |
| 5 | A1 | The matrix stops over-reporting on twelve shipped requirements | M — mostly mechanical |
| 6 | D2 | CI stops flaking | S |
| 7 | C1 · C2 | The web layer stops being one interpolation away from full IPC | M — iterate against HLS |
| 8 | A2 · A3 · A4 | The matrix becomes self-consistent and defended by a real gate | M |
| 9 | B4 · B5 · B6 · B7 | Platform hygiene brought level with the SDK target | M |
| 10 | B1 · D3 · C3 · D4 | Long-tail robustness; opportunistic rather than scheduled | L |
+1
View File
@@ -36,6 +36,7 @@ went unexercised until a later feature leaned on them.
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
### Why they took so long to surface
+23 -5
View File
@@ -109,8 +109,9 @@ git push origin v1.2.0
## After Release (Workflow Complete)
- [ ] Download artifacts from release page:
- [ ] `jellytau_*.AppImage` (Linux)
- [ ] `jellytau_*.deb` (Linux)
- [ ] `JellyTau_*.AppImage` (Linux)
- [ ] `JellyTau_*.deb` (Linux)
- [ ] `JellyTau-*.rpm` (Linux)
- [ ] `jellytau-release.apk` (Android)
- [ ] `jellytau-release.aab` (Android)
@@ -125,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
@@ -255,9 +274,8 @@ First build takes longer (cache warming). Subsequent releases are faster due to
**Android:** 8.0+
### 🔗 Links
- [Changelog](../../CHANGELOG.md)
- [Issues](../../issues)
- [Discussion](../../discussions)
- [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
- [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)
---
Built with Tauri, SvelteKit, and Rust 🦀
+78 -26
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,6 +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 | 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 |
---
@@ -117,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 |
@@ -235,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 |
@@ -337,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 |
@@ -381,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 |
@@ -392,6 +395,26 @@ 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 | - | 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 | 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-219 | Release notes are the reviewed CHANGELOG entry, not a generated draft. Every release from v0.0.1 to v0.9.1 published the same ~1,050 bytes of generic install instructions whose "What's New" section said "See CHANGELOG.md" — a link that does not resolve from a release page. Thirty-five releases, byte-identical, telling a reader nothing about what changed. The workflow now publishes the `## <version>` section of CHANGELOG.md and fails the release if that section is absent, since notes that say nothing are worse than a build that waits for two sentences. `release:notes` is printed into the job log as a drafting aid but is deliberately *not* published: CLAUDE.md calls its output "a reviewed draft, not a final changelog", and publishing it unreviewed proved why — a range containing a repo-wide formatting sweep resolved to nearly the entire requirement matrix and produced notes claiming one release had added the whole application. The script now skips cosmetic commits (`chore(format)`, `chore(deps)`, `style`) when deriving a range's files, and says how many it skipped rather than silently reporting a smaller set | Tooling | - | Done |
| DR-220 | A release ships only its own artifacts. `src-tauri/target/*/release/bundle/` is not versioned, cargo never cleans it, and the CI runner reuses the target directory — so the copy step's `bundle/**/*-setup.exe` glob collected every installer ever built there. Every release from v0.1.0 to v0.8.2 shipped its predecessors': sixteen Windows installers on v0.8.2, thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0. It went unnoticed for eight months because there was nothing to notice — the upload loop reported success, the files were real, and the page looked busy rather than wrong. It stopped only when an unrelated cache change wiped the runner's target dir, leaving the defect dormant rather than fixed. Both desktop builds now clear the bundle directory first, so a stale file cannot exist to be copied — filtering the copy by version would have hidden it instead. `scripts/check-release-artifacts.sh` is the backstop for the next route nobody predicts: it runs before the SBOM, the checksums and the upload, and refuses to publish when any artifact's embedded version disagrees with the tag | Tooling | - | Done |
| DR-221 | The release path is exercised before a tag exists. Nothing in `build-and-test.yml` runs `tauri build` — only a tag does — so a whole class of breakage was invisible until release day, and two instances of it were sitting on master at once. Tauri refuses to build when a plugin's Rust crate and npm package differ by minor version, which the updater and logging work had introduced (`tauri-plugin-log 2.8.0` against `@tauri-apps/plugin-log 2.9.0`) while `cargo check`, clippy, the tests and `svelte-check` all passed; both sides are now pinned exactly rather than by caret, since a caret is what let them separate, and CI runs `tauri info` to compare them without building. The AppImage target had never once been built: linuxdeploy carries a `strip` too old to parse the `.relr.dyn` section modern toolchains emit, so bundling failed on every library — and Ubuntu 23.10+ links with `-z pack-relative-relocs` by default, so the builder image fails the same way a modern Arch host does. `NO_STRIP=true` is linuxdeploy's documented escape hatch; the cost is a larger, unstripped bundle. Both were found by building the target locally before tagging rather than by publishing a release that could not build | Tooling | - | Done |
| DR-222 | Build tooling matches the package manager the project declares. `scripts/build-android.sh` ran `npm install` on its clean-build path — in a bun project, where `packageManager` says bun and `bun.lock` is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a `package-lock.json` that `.gitignore` then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. `scripts/check-tooling.sh` fails on any npm/yarn/pnpm invocation or foreign lockfile | Tooling | - | Done |
| DR-223 | The Android JavaVM and Application are published into `ndk_context` by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — `tao` did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: `PANIC ... android context was not initialized`. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: `JNI_OnLoad` captures the JavaVM as the shared library loads, and the Application is resolved lazily via `ActivityThread.currentApplication()` and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what `SecureStorage.initialize()` immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the app | Security | UR-012 | 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 |
---
@@ -476,6 +499,9 @@ Internal architecture, components, and application logic.
| UR-073 | - | DR-158 |
| 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 |
---
@@ -678,7 +704,17 @@ Internal architecture, components, and application logic.
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
| UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done |
| UT-201 | The logging facade gates by level: a message below the active level is not emitted at all, one at or above it reaches the sink, changing the level at run time changes what passes without touching the call sites, and a scoped logger tags its output with the subsystem | DR-204 | Proposed |
| UT-202 | Generated traceability-matrix file links resolve from `docs/`: an emitted href, resolved against the directory `traceability.md` is written to, points at a file that exists on disk; the visible link text stays repo-root-relative; the `#Lnn` anchor survives; and a bare repo-root href — the regression that made every link 404 as `docs/<path>` — is rejected | DR-093 | Done |
| UT-203 | Library folder exclusion filters by id, not by name: an excluded folder's items are absent from a music query, an item whose *title* merely contains an excluded folder's name is kept, and clearing the exclusion restores the items | DR-209 | Proposed |
| UT-204 | Thumbnail cache writes stay inside the cache directory: a traversal-style and an absolute `item_id` both fail to produce a file outside it, a filename made only of already-safe characters is byte-identical to the one the previous code produced, and an odd id still round-trips through `get_cached_path` | DR-210 | Done |
| UT-205 | Queued download paths cannot escape the download root — traversal, absolute and `..` forms are refused — while the four real path shapes the app builds, including the absolute one `download_series` produces, come back unchanged; and a completed download cannot register a file outside the root | DR-211 | Done |
| 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 |
| UT-210 | Cosmetic-commit detection for release notes: a `chore(format)`, `chore(deps)` or `style` subject is skipped when deriving a range's changed files, while `fix`, `feat`, `ci`, `docs`, a bare `chore:` and `chore(release):` are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmetic | DR-219 | Done |
### Integration Tests
@@ -704,12 +740,28 @@ Internal architecture, components, and application logic.
## 5. Technical Debt
### Open items from the codebase audit (2026-08-16)
### Open items carried over from the v0.6.0 codebase audit
Findings from [codebase-audit.md](codebase-audit.md) that were **not** addressed
in v0.8.0, plus items the device-verification pass turned up. Ordered by what
would hurt most if left. The audit doc carries the full reasoning and evidence
for each.
The 2026-08-16 audit (v0.6.0, commit `be907b49`) was a point-in-time snapshot
with no status markers, and by v0.8.2 most of it had been either fixed or
overtaken. It was **retired** rather than left to rot into a document that
half-describes the code: what survived it is the table below, which is now the
record. Each row is self-contained — the audit is not needed to act on it.
What was dropped as demonstrably closed, so it is not re-raised: the CSP and
asset-protocol scope findings (now DR-198), cloud backup and credential restore,
the WebView mixed-content override (DR-199), `POST_NOTIFICATIONS` and the
media-session exemption (DR-200), the `jvmTarget` 1.8 pin (now 17), the
half-declared Android TV leanback category (removed), the untraced-but-Done
requirements and the contradictory UR/IR statuses (re-scoped in §2.1), the 50%
traceability gate (ratcheted, and gated on a live denominator by DR-093), the
flaky `offlineCatalog` test, the clippy warning backlog (cleared, and `cargo
fmt --check` plus clippy now run in CI), and the "820 production `unwrap()`s"
figure — a measurement error that counted test modules, corrected in the audit
itself to ~19 and standing at 27 today, none of them in a command handler. The
three `Runtime::new().unwrap()` sites that genuinely matter survive as row 5.
Ordered by what would hurt most if left.
> **Closed 2026-08-17:** the R8-minified release APK was validated on device.
> That was the last item gating confidence in the v0.8.0 release itself; R8
@@ -719,17 +771,17 @@ for each.
| # | Item | Why it matters | Size |
|---|------|----------------|------|
| 1 | **Android 16 Local Network Protections** (audit B8) | The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against | M |
| 1 | **Android 16 Local Network Protections** | The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against | M |
| 2 | **The traceability matrix cannot see Kotlin** | `scripts/extract-traces.ts` walks only `src`, `src-tauri/src` and `scripts`, so every `TRACES:` comment in `src-tauri/android/**` is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android tree | S |
| 3 | **Delete the asset protocol outright** | It is not narrowly used, it is **unused**. `getCachedImageUrl` has no production callers (only its own test file), so `convertFileSrc` never executes; images arrive as base64 `data:` URIs from `image_get_url`. Confirmed on device: zero `asset.localhost` requests across a full browsing session. Dropping `protocol-asset` and the `assetProtocol` block retires the surface instead of shrinking it, and `imageCache.ts` goes with it | S |
| 4 | **Tighten `img-src`** | The v0.8.0 CSP grants `img-src … http: https:` on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in `src/`, `'self' data: blob:` should suffice. Needs its own device pass — a wrong `img-src` blanks every image, silently | S |
| 5 | **Three `Runtime::new().unwrap()` in playback-critical threads** (audit D3) | `session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers) | S |
| 6 | **Confirm the playback service rejects unknown callers** (audit B7, second half) | `JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (The predictive-back half of B7 was verified working on device and needs nothing) | S |
| 7 | **Media3 is several minor versions behind** (audit B6) | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M |
| 8 | **Shipped desktop bundles have no update path** (audit C3) | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M |
| 5 | **Three `Runtime::new().unwrap()` in playback-critical threads** | `session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers) | S |
| 6 | **Confirm the playback service rejects unknown callers** | `JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (Predictive back, raised alongside this, was verified working on device and needs nothing) | S |
| 7 | **Media3 is several minor versions behind** | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M |
| 8 | **Shipped desktop bundles have no update path** | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M |
| 9 | **`DR-042` overstates what ships** | It promises "poster cards, year, **and rating badges**", but `MediaCard.svelte` renders only `productionYear`; `CommunityRating`/`OfficialRating` appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced one | S |
| 10 | **Stray duplicate `JellyTauPlayer.kt`** | A copy exists at `src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt`, outside the canonical `src-tauri/android/src` tree that `sync-android-sources.sh` reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next edit | S |
| 11 | **Five files carry a disproportionate share of complexity** (audit D4) | `player/mod.rs` (4.7k lines), `repository/offline.rs` (4.7k), `repository/online.rs` (3.7k), `commands/player/mod.rs` (3.3k), `commands/download/mod.rs` (3.2k), plus `VideoPlayer.svelte` (2.8k). The same files the changelog keeps returning to for deadlocks and playback regressions. Not worth a speculative refactor — but the next time one needs substantial work, splitting it is likely cheaper than growing it | L |
| 11 | **Six modules carry a disproportionate share of the complexity** | `src-tauri/src/player/mod.rs` (4,732 lines), `src-tauri/src/repository/offline.rs` (4,705), `src-tauri/src/repository/online.rs` (3,760), `src-tauri/src/commands/player/mod.rs` (3,327), `src-tauri/src/commands/download/mod.rs` (3,238) and `src/lib/components/player/VideoPlayer.svelte` (2,786) — all still growing. The cost is not the line count itself, it is that **these are the same modules `CLAUDE.md`'s Gotchas section keeps having to warn about**: the deadlock rule about locking in event callbacks, the `AutoplayDecision` scrutinee, the "no lifecycle calls after an `await` in `onMount`" rule, the HLS `master.m3u8` rule, the download concurrency cap. A file that needs a standing warning in the project's onboarding document is a file whose invariants are no longer local to it, and every such warning is a rule a newcomer has to be *told* rather than one the structure enforces. **Recorded, not scheduled** — a speculative refactor of six files this size buys nothing on its own. The trigger is the next time one of them needs substantial work: splitting it then is likely cheaper than growing it, and each rule that moves from Gotchas into a module boundary is one fewer thing to remember | L |
### Linux Keyring Integration Workaround
@@ -776,18 +828,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**:
@@ -802,7 +854,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`
@@ -843,7 +895,7 @@ deprecated in current Media3.)
**Affected Files**:
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
- [src/lib/utils/playbackUnits.ts](../src/lib/utils/playbackUnits.ts) - Position conversion (the shared helper the "Future Fix" below called for; `playbackControl.ts`, previously listed here, has since been removed)
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
+81
View File
@@ -0,0 +1,81 @@
# 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-079**,
**IR-033**, **DR-224**. 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 |
|---|---|
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Phase 1 (delete the `.m3u8` sniff) stands alone. Unblocks Linux native video. |
| [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. |
| [linux-native-video-spike.md](linux-native-video-spike.md) | **Spike run 2026-08-21: compositing works on Linux, X11 and Wayland.** G1-G6 green bar the Tauri `default_vbox()` half of G1. Needs an implementation spec that answers adaptive bitrate. |
## 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 |
+14 -1
View File
@@ -50,7 +50,20 @@ Copy the boxes into the review comment (or the PR) and tick them.
- [ ] Linked to existing URs, or new URs/DRs are allocated in
[requirements.md](../requirements.md).
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
- [ ] 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
+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.
@@ -0,0 +1,242 @@
# Spec: Backend-owned stream selection
**Status:** Proposed
**Requirements:** UR-079 (new) → DR-219 … DR-224 (new); **implements and extends
DR-121**, currently allocated to
[read-through-media-cache.md](read-through-media-cache.md) and not started.
Re-check `requirements.md` before allocating — the ids moved twice while this was
being written (`DR` max was 215, then 218).
**UX spec:** the quality selector in `VideoPlayer.svelte` already exists; this
changes what fills it, not how it looks.
**Supersedes / revises:** takes DR-121 out of
[read-through-media-cache.md](read-through-media-cache.md), which should keep
only its capture/eviction half. Unblocks
[linux-native-video-spike.md](linux-native-video-spike.md).
**Destination on completion:**
[01-rust-backend.md](../architecture/01-rust-backend.md) — extends the
"Streaming quality ladder" section; and
[03-data-flow.md](../architecture/03-data-flow.md) — playback initiation. The
durable half is the layer line and the `StreamSelection` contract; phases and
acceptance criteria are disposable.
## Summary
Make Rust the single owner of *which stream to play* — direct play or transcode,
at what ceiling, over what transport — and hand every player backend a
self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5
`<video>`/hls.js path all become consumers of the same decision rather than three
places that re-derive it.
Nothing about how playback *looks* changes. What changes is that the frontend
stops inferring transport from a URL string, and that direct play becomes
possible at all.
## Motivation
Four concrete problems, all the same shape.
**1. The frontend sniffs transport out of the URL.**
[VideoPlayer.svelte:569](../../src/lib/components/player/VideoPlayer.svelte#L569):
```ts
const isHlsStream = currentStreamUrl.includes(".m3u8");
```
and again inline at line 2364. Rust *built* that URL and knows exactly what it
is; the frontend re-derives it by substring match. Change the endpoint, add a DASH
path, serve a progressive file, and this silently picks wrong. This is the
boundary rule in miniature — not item-type taxonomy, but the same error: a
domain fact reconstructed in the presentation layer because the wire shape did
not carry it.
**2. There is no direct-play path.** `get_video_stream_url` always builds an HLS
transcode URL (`TranscodingProtocol=hls`, `VideoCodec=h264` first). Every video
play burns server CPU, even when the file would play untouched. This is the cost
the Linux native-video work exists to remove, and it cannot be removed without a
decision that does not currently exist anywhere in the codebase.
**3. Quality is a process-wide global.** `streaming_quality()` /
`set_streaming_quality()` in `repository/online.rs` read and write a static.
It is not per-session or per-item, so it cannot express "this 4K remux needs a
ceiling, that podcast does not", and two concurrent playbacks would share one
setting.
**4. Rust cannot say what qualities *this* media source supports.** The selector
is populated from a fixed enum rather than from what the source actually offers.
DR-121 already names this; it has not been built.
### The prior question
Finding 3 of [playback-backend-unification.md](playback-backend-unification.md)
holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence
in this repo suggests **there is no ABR today**: a single rendition is requested,
no level-handling code exists anywhere in the frontend, and a quality switch is
implemented by re-opening the stream.
**Run this before sizing the adaptation work.** It needs a live server:
```
curl -s "https://<server>/Videos/<itemId>/master.m3u8?api_key=<key>&…" \
| grep -c EXT-X-STREAM-INF
```
`1` → there is no adaptation to preserve, and the adaptation half of this spec
collapses to "pick well at open". `>1` → finding 3 stands and DR-223 applies.
**Everything else in this spec is worth doing either way** — the ownership
problems above are independent of the answer.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| Direct play vs direct stream vs transcode | Rust | Depends on Jellyfin's `PlaybackInfo`, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test. |
| Transport of the chosen stream (HLS / progressive / local file) | Rust | Rust constructs the URL; it is the only place that *knows* rather than infers. Today the frontend guesses from `.m3u8`. |
| Which qualities this media source can offer | Rust | Derived from the source's own streams and the quality→transcode-parameter mapping that `get_video_download_url` already holds. DR-121. |
| The quality ceiling in force, per playback session | Rust | Domain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static. |
| Deciding to re-negotiate mid-playback (if adaptation is needed) | Rust | It performs the HTTP and already derives reachability from real traffic via `ConnectivityMonitor`. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent. |
| Frame-level delivery *within* the selected stream, including a player's own ABR | **Player** | ExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses *what to request*, never how a player paces bytes. See "The line". |
| Rendering the selector, showing the current quality, ordering the list | Frontend | Pure presentation over a backend-supplied list. |
| Poster, letterbox, controls, overlay z-order | Frontend | Unchanged. |
### The line
**Rust decides *what stream*. The player decides *how to deliver it*.**
This matters most for ExoPlayer, which already does real adaptive track selection
over HLS. This spec must not reimplement that or fight it — if a multi-variant
playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The
same restraint applies to any future backend that gains the capability. Rust only
steps in where the player has no such ability (mpv) *and* the server actually
offers a ladder.
Borderline row, with its tie-breaker: "which media source of a multi-source item"
looks like a user choice, and its *presentation* is. The default and the
constraint set are domain → **Rust**, per the borderline-defaults-to-Rust rule.
## Design
### The contract
One self-describing selection replaces the bare URL. Nested fields are
camelCase over the wire (`#[serde(rename_all = "camelCase")]`); the enums are
tagged so the frontend matches a tag instead of parsing a string.
```rust
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct StreamSelection {
pub url: String,
pub transport: Transport,
pub playback_kind: PlaybackKind,
/// The negotiated rendition; None when direct-playing the source as-is.
pub rendition: Option<Rendition>,
/// What this media source can offer — fills the selector (DR-121).
pub available: Vec<QualityOption>,
}
#[derive(Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Transport { Hls, Progressive, LocalFile }
#[derive(Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
```
`Transport` is the field that deletes the `.m3u8` sniff. The frontend picks
hls.js on `Hls` and the element's own loader otherwise — a tag match, not a
substring search.
### Re-negotiation
Rust emits `stream-selection-changed` (kebab-case, per convention) carrying a new
`StreamSelection` plus the position to resume at. The existing
`playerSetStreamQuality` response already has exactly the right shape — a tagged
`strategy` that tells the caller who reloads, with the backend handling native
itself and handing HTML5 a URL for `reloadSource`
([index.ts:198](../../src/lib/player/index.ts#L198)). **Extend that; do not
invent a second mechanism.** It is the one piece of this that is already right.
Note the existing wart to preserve or fix deliberately, not accidentally:
tauri-specta keeps those response fields snake_case (`new_url`), and the facade
comments say so.
### Phases
1. **DR-219** `StreamSelection` + `Transport`; delete the `.m3u8` sniff. No
behaviour change — pure ownership move, and independently shippable.
2. **DR-220** Per-session quality ceiling replacing the `online.rs` static.
3. **DR-221** `available` populated from the media source (DR-121's substance).
4. **DR-222** Direct-play/direct-stream negotiation via `PlaybackInfo`. This is
the phase that unlocks native video and removes the transcode.
5. **DR-223** Adaptation, **only if the playlist check says a ladder exists**.
Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing
the phase-1 re-negotiation path. A local proxy synthesizing a single-variant
playlist is a last resort, not a starting point.
6. **DR-224** ExoPlayer and mpv consume `StreamSelection` unchanged, proving the
contract is player-agnostic rather than HTML5-shaped.
Phases 14 stand on their own merits with no dependency on the ladder question.
## Out of scope
- Rendering, compositing, and the Linux native-video work itself. This spec
unblocks [linux-native-video-spike.md](linux-native-video-spike.md); it does
not contain it.
- Replacing hls.js. It stays as the HLS loader for the webview path.
- Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".
- The download/capture half of [read-through-media-cache.md](read-through-media-cache.md)
(DR-122, DR-124, DR-125), which keeps its own spec.
- Audio. The same argument applies, but video is where the transcode cost is.
## Acceptance criteria
- [ ] The `.m3u8` substring check is gone from `VideoPlayer.svelte` (both sites)
and transport comes from the tagged enum.
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes — and the reviewer confirms by reading that
no transport/kind decision was reconstructed in `src/`, since the tripwire
only catches item-type array literals.
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes and
coverage stays ≥ the CI ratchet.
- [ ] The `EXT-X-STREAM-INF` count is recorded in this spec before DR-223 is
started or dropped.
- [ ] DR-121 is removed from `read-through-media-cache.md` with a pointer here.
## Testing
- Rust: `PlaybackInfo` fixtures → expected `PlaybackKind`, one per branch
(supported container direct-plays; unsupported codec transcodes; a ceiling
below the source bitrate transcodes even when the codec is fine).
- Rust: `Transport` round-trips through serde with the tag the frontend matches.
- Frontend: adapter selection driven by `transport`, including the case a URL
ending `.m3u8` is served as `Progressive` — that test fails on today's code,
which is the point.
- Extend `tauriIntegration.test.ts` for the new command params (camelCase rule).
- No test asserts a URL substring.
## TRACES
| Piece | Tag |
|---|---|
| `StreamSelection` / `Transport` | `UR-079 \| DR-219` |
| Per-session ceiling | `UR-074 \| DR-220` |
| `available` from media source | `UR-079 \| DR-221, DR-121` |
| Direct-play negotiation | `UR-079 \| DR-222` |
| Adaptation, if built | `UR-079 \| DR-223` |
| ExoPlayer/mpv consumers | `UR-003, UR-004 \| DR-224` |
## Notes for the implementer
- **Phase 1 is worth doing on its own**, even if everything after it is dropped.
It removes a real leak and costs almost nothing.
- Do not frame any phase as "no Rust changes required" — that framing is what
produced the leak `scoped-search-boundary.md` records.
- `ConnectivityMonitor` is the precedent for DR-223: derive network facts from
real traffic, never from a side-channel poller.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes. Requirement ids in particular moved twice
during the writing of this spec.
-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 [tauriIntegration.test.ts](../../src/lib/utils/tauriIntegration.test.ts): 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.
+469
View File
@@ -0,0 +1,469 @@
# Spec: Linux native video — bounded compositing spike
**Status:** **Run 2026-08-21 — compositing works; G5 carries an open crash.**
The compositing claim it set out to test is falsified on Linux. See "Result".
This file stays open until the implementation spec exists; ABR is unresolved.
**Requirements:** none allocated. This spike produces a decision record, not
product code — same shape as
[playback-backend-unification.md](playback-backend-unification.md), which is
Accepted with no requirement ids of its own. Ids are allocated by the
*implementation* spec that follows a green result.
**UX spec:** n/a
**Supersedes / revises:** re-opens finding 2 of
[playback-backend-unification.md](playback-backend-unification.md) on Linux only.
Its findings 3, 4, 5 and 6 stand unchallenged and are **not** in scope here.
**Destination on completion:**
[05-platform-backends.md](../architecture/05-platform-backends.md) — a "Native
Video Compositing (Linux)" section alongside the existing Android one. The
durable half is the mechanism and the two traps below; the gates and phases are
disposable.
## Summary
Test one falsifiable claim: *a native video surface cannot be composited with a
Tauri webview on Linux.* The claim is load-bearing — it is why Linux video goes
through an h264 HLS transcode into a WebKitGTK `<video>` element instead of
decoding directly in the mpv instance we already run. The spike renders one mpv
frame beneath the webview, on both X11 and Wayland, and stops. It ships no
product code and flips no defaults.
A green result does **not** authorise native video on Linux; it authorises
writing the spec that would.
## Motivation
[playback-backend-unification.md](playback-backend-unification.md) finding 2
concluded that native video cannot be composited with a Tauri webview, on
evidence from `tauri-plugin-libmpv`'s platform table, wry#284, tauri#6343, and a
Tauri maintainer's 2024 statement that a GTK widget as a child X11 window is
"a bit hacky and it is not possible on Wayland at all."
Two things have changed since that was written, and one thing was never tested.
**1. The general claim has already been falsified on one platform — by us.**
Android now renders ExoPlayer video on a TextureView at index 0 *behind a
transparent Tauri WebView*, with the Svelte controls drawn over it, on by
default. See
[05-platform-backends.md](../architecture/05-platform-backends.md#native-video-compositing-android).
That is exactly the composition finding 2 said was impossible, shipped. What
survives of the finding is a narrower, WebKitGTK-specific claim — which is worth
testing on its own terms rather than inheriting.
**2. A Tauri app now ships Linux native mpv as an active platform.**
[MaxVideoPlayer](https://github.com/MaxMB15/MaxVideoPlayer) (354 commits) embeds
libmpv via **EGL + X11 child window / Wayland subsurface**, with Linux and macOS
active and Windows only planned — the inverse of the plugin matrix finding 2
sampled. Its existence does not prove our case works, but it does mean the
Wayland half of the maintainer quote is out of date.
**3. The render API was never tested.** Every source in finding 2 describes
*foreign-window embedding*: `--wid`, child windows, a second toplevel
position-synced to a `getBoundingClientRect()` div. That is a different mechanism
from mpv's render API, where **we** own the GL context and mpv draws into an FBO
we hand it (`mpv_render_context_create` / `mpv_render_context_render`, with an
upstream [GTK example](https://github.com/mpv-player/mpv-examples/pull/44/files)).
Tauri v2 exposes `WebviewWindow::gtk_window()` and `default_vbox()`, so the
target is a widget inside Tauri's own GTK tree — not a foreign window, not a
second toplevel, and therefore not the thing that was found broken.
The prize is direct play: no h264 transcode, hardware decode, libass subtitles,
and no server CPU burned on every Linux play.
## The blocker a green spike does not clear
🔴 **Read this before treating a green result as a green light.**
Finding 3 of the unification spec stands: **mpv has no adaptive bitrate.** It
delegates HLS to FFmpeg's demuxer, which picks one variant at open and never
adapts. The webview path has real ABR via hls.js. Compositing is necessary for
native video on Linux; it is not sufficient.
There is a plausible answer, and this spike exists partly to make it testable:
**ABR only matters on the transcode path.** A direct-played file has no variant
ladder to adapt between — the adaptation the server offers *is* the transcode.
So "mpv when the stream is direct-play, HTML5 + hls.js when the server
transcodes" would sidestep finding 3 rather than fight it, and it maps onto a
decision Rust already makes when it builds the stream URL.
That is a **hypothesis, not a conclusion.** It is out of scope here. Record it in
the spike's decision note so the follow-up spec starts from it.
## Layer assignment
The spike introduces no product logic. The table below is the assignment the
*follow-up* would inherit, written now so a green result cannot drift into
frontend decisions during implementation.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which backend renders video on this platform (`use_html5_element`, `supports_native_video`) | Rust | Already there — `get_player_status` in `commands/player/mod.rs` computes it from a `cfg!`. The spike would widen that `cfg!`, not relocate the decision. The frontend already consumes it via `createAdapter`. |
| Whether *this stream* is direct-play or transcoded, and therefore whether mpv or hls.js renders it | Rust | Domain. It depends on Jellyfin's `PlaybackInfo` response, container/codec support, and the bitrate cap — all of which change when Jellyfin's API or our quality ladder changes. The frontend must never re-derive it from a URL shape. |
| Creating, sizing, and destroying the GL surface; the mpv render context | Rust | Owns the backend and the GTK window handle. There is no presentation decision in it. |
| Where controls, subtitles, and the mini-player sit above the video, and the letterbox/poster treatment | Frontend | Pure presentation; changes only if the UI is redesigned. Precisely the split the Android path already uses. |
| Reserving the video rectangle in layout and marking the shell transparent | Frontend | Presentation. `nativeVideo.ts` + the `[data-native-video="active"]` rule in `app.css` already do this for Android and are platform-agnostic. |
Borderline row, stated with its tie-breaker: *"is the surface currently
attached?"* reads like view state, but the Android work found that a surface left
in the hierarchy outlives its player (DR-184). Attachment is backend lifecycle →
**Rust**, with the frontend told about it, not asked.
## Design
A throwaway branch. No merge to `master` except the decision note.
### What gets built
One `#[cfg(target_os = "linux")]` experiment behind a feature flag, in a scratch
binary or an ignored test — **not** in `MpvBackend`'s constructor path:
1. From `app.get_webview_window(...)`, take `gtk_window()` and `default_vbox()`.
2. Reparent the webview into a `gtk::Overlay`: `GLArea` as the main child, the
webview as the overlay child.
3. Set the webview background to fully transparent (wry does this when
`"transparent": true`; verify it reaches `webkit_web_view_set_background_color`).
4. In the `GLArea`'s `render` signal, drive
`mpv_render_context_render` with `MPV_RENDER_PARAM_OPENGL_FBO` pointing at the
FBO GTK bound for us.
5. Play one local file. Draw an opaque HTML element over the video area.
`video = no` and `audio-display = no` are set in
[mpv_backend.rs:135-141](../../src-tauri/src/player/mpv_backend.rs#L135-L141);
the spike overrides them on its own `Mpv` handle rather than editing that path.
### Bindings
The current pin is `libmpv = { git = "…/libmpv-rs", branch = "master" }` — the
dead pin [libmpv2-migration.md](libmpv2-migration.md) exists to replace. The
render API lives in `libmpv2-sys` (`mpv_render_context_render`); the safe wrapper
was only ever a PR against the old crate. **Use `libmpv2-sys` raw FFI directly in
the spike.** Do not block the spike on the migration, and do not let the spike
half-perform it — if the spike goes green the migration becomes a hard
prerequisite of the implementation, which is the ordering
[windows-native-audio-backend.md](windows-native-audio-backend.md) already sits
in.
### IPC
None. The spike crosses no boundary. If it goes green, the follow-up changes only
the *value* of the existing `useHtml5Element` / `supportsNativeVideo` fields — no
new wire shapes, no `bindings.ts` regeneration.
## Gates
Each is pass/fail with a named failure. Stop at the first red and write it up —
a red result is a successful spike.
| # | Question | Fails if |
|---|---|---|
| G1 | Can a custom GTK widget join Tauri's widget tree and survive the window's lifetime? | `default_vbox()` is absent/unusable, or reparenting the webview breaks input or crashes. |
| G2 | Does the webview still paint, with a transparent backdrop, over that widget? | The backdrop renders opaque black ([wry#1540](https://github.com/tauri-apps/wry/issues/1540)) or the webview stops repainting ([tauri#12800](https://github.com/tauri-apps/tauri/issues/12800)). **This is the highest-risk gate.** |
| G3 | Does mpv render a frame into our FBO? | The render context refuses GTK's context, or frames land in the wrong buffer. |
| G4 | Does HTML drawn over the video area actually appear over it? | Video covers the controls — the exact failure wry#284 and tauri#6343 report. Without this, the whole thing is worthless: our controls, subtitles and mini-player all sit over the video. |
| G5 | Does it survive resize, fullscreen, and SPA navigation away and back? | Flicker on resize, or a surface that outlives its route. |
| G6 | Does it hold on **both** X11 and Wayland? | Either session backend fails. Wayland is the one the 2024 maintainer quote says is impossible — test it first, not last. |
G6 is not a nice-to-have. A result that only holds on X11 is red for a project
shipping to current desktops.
### Time box
If G1G4 are not all green, stop and write the result up. The value of this spike
is a dated, method-specific answer — including "still no, and here is the
mechanism" — not a working player.
## Result (2026-08-21)
Run on GNOME, kernel 7.1.8, libmpv 2.5.0 (mpv 0.41.0), GTK 3.24.52, WebKitGTK
2.52.6, wry 0.53.5 — the versions `src-tauri/Cargo.lock` resolves. Spike source:
a ~250-line standalone crate using wry + gtk + `libmpv2-sys` raw FFI, driving
mpv's render API with an update callback, frame-gated repaints and
`report_swap`.
| Gate | Result | Observed mechanism |
|---|---|---|
| G1 widget in GTK tree | 🟡 **partial** | `GtkOverlay` with `GtkGLArea` as main child and the wry webview as overlay child works, built directly. **Tauri's own `default_vbox()` was not exercised** — see below. |
| G2 webview paints transparently over it | ✅ green | `with_transparent(true)` alone. No window-level transparency was used or needed. |
| G3 mpv renders into our FBO | ✅ green | `vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO` into the FBO GTK binds. |
| G4 HTML over video | ✅ green | Opaque panel and a translucent control bar both drew over moving video. |
| G5 resize / drag / fullscreen | 🟡 **green on appearance, suspect underneath** | No flicker, gap or misalignment, and smooth once frame pacing was correct (trap 3). But the only crash observed came from the only session where fullscreen was exercised — see "What is still open". |
| G6 X11 **and** Wayland | ✅ green | Identical on both; `GDK_BACKEND` flipped between runs. |
**Finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
is false on Linux** when tested by the render API rather than by foreign-window
embedding. Wayland — the half the 2024 maintainer quote called impossible — is
green.
Better than the gate asked for: the translucent bar composited *alpha* against
the video, not merely opaque-over. Scrims, gradient fades and subtitle backdrops
therefore work, which is most of how a player UI actually looks. mpv also painted
the letterbox bars black on its own — the Android equivalent was a shipped defect
(DR-194).
### Three traps, each of which cost a debugging cycle
Carry these into the implementation; each produced a failure that looked like a
platform limitation and was not.
1. **`LC_NUMERIC` must be reset *after* `gtk::init()`, not before.** mpv refuses
to start under a non-C numeric locale. `mpv_backend.rs` already handles this,
but it has no GTK init in front of it; on this path `gtk::init()` applies the
user's locale afterwards and `mpv_create` returns null.
2. **libepoxy exports GL entry points as *data* symbols.** There is no `glFoo`
function to resolve — there is `epoxy_glFoo`, a variable holding a lazily
resolving function pointer. `get_proc_address` must return the pointer *stored
at* that symbol; returning the symbol's own address makes mpv jump into
non-executable data and take SIGSEGV/SEGV_ACCERR on the first GL call. The
`epoxy` crate does this correctly but is unusable — its `gl_generator`
dependency pulls a yanked `xml-rs`.
3. **Frame pacing is not optional, and its symptom is misleading.** Driving
`queue_render()` off the widget's frame clock on every tick, without calling
`mpv_render_context_report_swap` after each render, leaves mpv with nothing to
time against. Playback looks fine in a window and **judders at fullscreen**
which reads as a compositing or GPU limit and is neither. The fix is to
register `mpv_render_context_set_update_callback`, redraw only when it says a
frame is ready, and report the swap afterwards. Fullscreen was smooth
immediately once both were in place.
### Hardware decode through the render API
Tested by asking mpv what it actually selected (`hwdec-current`), not what it was
asked for. All three ran 20s clean at a steady 30 fps.
| `hwdec` | `hwdec-current` | Note |
|---|---|---|
| `vaapi` | `no` | **Did not engage** on this box — silently fell back to software. `vainfo` is not installed, so the libva driver for the Iris Xe iGPU is likely absent. No render-API error; this looks like a missing driver package, not a compositing limit. |
| `auto` | `nvdec-copy` | Hardware decode **does** work through the render API, on the discrete RTX 3050. Copy-back rather than zero-copy interop. |
| `no` | `no` | Software. Clean baseline. |
The load-bearing result is the middle row: **hardware decode is compatible with
mpv's render API**, so the direct-play prize is real and not traded away for
software decoding. Which decoder to prefer is an implementation question — on a
hybrid Intel+NVIDIA laptop `auto` reached for the discrete GPU in copy-back mode,
which is the least efficient hardware path. An implementation should evaluate
zero-copy VA-API on the iGPU (after confirming the driver is installed) before
accepting `auto`.
`hwdec=auto-safe` probes Vulkan video decode, which this GPU does not support.
It logs two `Failed setup for format vulkan` / `no frame!` pairs at start-up and
then settles on `nvdec-copy` — the same place `auto` lands. A first reading of
these logs mistook the start-up pair for a per-frame flood; **it is not**. Every
run, clean or crashed, contains exactly two. `auto-safe` is not implicated in
anything.
### What is still open
- **The Tauri half of G1.** The spike built its own `GtkOverlay`. The app must
instead reach `WebviewWindow::gtk_window()` / `default_vbox()` and reparent
Tauri's existing webview into an overlay. Low risk — the same widgets, one
extra reparent — but unproven, and it is the only place Tauri-specific
behaviour could still bite.
- 🔴 **ABR — finding 3's premise is in doubt.** Finding 3 says mpv would regress
streaming quality because "the webview path already has real ABR via hls.js".
Three pieces of evidence in this repo suggest that is **not true of the URLs we
actually build**:
1. `get_video_stream_url` (`repository/online.rs`) requests a *single*
rendition — one `VideoBitrate`, one `MaxStreamingBitrate`, one `MaxHeight`.
Jellyfin transcodes to what it is asked for; it does not build a ladder.
2. The frontend contains **no level-handling code at all** — no `hls.levels`,
no `LEVEL_SWITCH`, no `currentLevel`. The `abrEwma*` options in
`VideoPlayer.svelte` are default tuning with nothing to act on. hls.js is
serving as an HLS *demuxer* (WebKitGTK cannot play HLS natively), not as an
adaptation engine.
3. That function's own comment describes a quality switch as **rebuilding the
URL** — "every path that re-opens a stream (quality switch, transcoded seek,
audio-track switch)". Manual selection by stream re-open is what you build
when there is no adaptation, and mpv can do the same thing.
**The decisive test has not been run** and needs a live server plus an API key:
count `#EXT-X-STREAM-INF` lines in a real `master.m3u8`. One line means there
is no ABR to lose and this blocker disappears. More than one means finding 3
stands and the work below applies.
If ABR does turn out to be real, it belongs in **Rust**, not in mpv, and there
are three designs in increasing cost: pick the variant at open; re-open at a
new bitrate on sustained throughput drops (this is the quality-switch path the
app already has, so it is nearly free); or run a local proxy serving mpv a
synthesized single-variant playlist while swapping renditions underneath. The
middle option is almost certainly sufficient.
Either way the **direct-play path still does not exist** — every video play
currently goes through the HLS transcode endpoint. Building it is the real
project; the compositing work proven above is the smaller half.
- 🔴 **One unexplained SIGSEGV.** A ~180s
run died in a *decoder* thread (libavcodec -> `av_log` -> libmpv's log handler
-> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the
stack, so the fault is on the mpv/ffmpeg side of the process rather than in the
compositing seam.
Three hypotheses were tested and **none reproduced it**:
| Hypothesis | Test | Result |
|---|---|---|
| `hwdec=auto-safe`'s Vulkan failures | 300s soak on `auto-safe` | Survived. Also based on a misreading — the failures are 2 per run at start-up, not per-frame. Dead. |
| Fullscreen transitions recreating the GL context under mpv's render context | 240s soak, ~120 automated transitions | Survived, no core dumped. |
| Continuous resize thrashing the GL framebuffer | 240s soak, ~2000 resizes | Survived, no core dumped. |
**The crash is therefore unexplained.** It was observed exactly once, in the
only session a human interacted with, and did not recur in ~13 minutes of
targeted stress across the three most plausible causes. It is recorded here
rather than dismissed precisely because nothing explains it: an intermittent
fault that nobody can reproduce is worse to inherit than a deterministic one,
not better.
The underlying concern stands regardless of which test eventually reproduces
it. A SIGSEGV in an unrelated thread is characteristic of memory corruption,
and this spike never calls `mpv_render_context_free` and never tears down on
`unrealize` — it has no defence against the GL context being recreated beneath
the render context. That is DR-184 on Android restated: a surface outliving its
player. An implementation must bind the two lifetimes together whether or not
this particular crash is ever explained.
**Therefore G5 is recorded green on appearance only**, and this crash is the
single largest piece of unfinished business in the spike. Do not read the green
gates above as "safe to build on" until it is explained or a long soak clears
it.
- Long-run stability, seeking, track switching, HDR, and multi-window were not
exercised at all.
## Out of scope
- Any change to the shipping Linux video path. `experimentalNativeVideo` in
`adapters/index.ts` is a **suppressor, never a promoter**; the spike must not
change that.
- Adaptive bitrate. See "The blocker a green spike does not clear".
- Windows and macOS — different mechanisms, and **Windows is the easier case, not
the endangered one**. See below.
- Android. Already shipped; it is the precedent, not the target.
- Crossfade, the libmpv2 migration, and the audio-parity work.
### Why Windows is unaffected, and cheaper
Nothing here can regress Windows. `use_html5_element` is already a per-platform
`cfg!` in `get_player_status` — Android native, everything else HTML5 — so
divergent video paths are the existing design rather than something this
introduces. Windows keeps `<video>` + hls.js whatever this spike returns.
The mechanism does not port: `default_vbox()`, `GtkOverlay` and `GtkGLArea` are
GTK3/WebKitGTK concepts. But the *question* is already answered more favourably
there. Both mpv plugins list Windows as **fully tested** and Linux as broken,
because WebView2 honours a transparent background — the "native surface beneath a
transparent webview" approach that fails on WebKitGTK is the one that works on
Windows. That asymmetry is why
[windows-native-audio-backend.md](windows-native-audio-backend.md) can call
Windows "the cleanest available win".
Windows' cost is packaging, not compositing: the build cross-compiles with MSVC +
`cargo-xwin`, so libmpv arrives as a bundled prebuilt DLL (the ⚠️ in finding 5's
comparison table). That cost is already committed for *audio*. Once the DLL ships
to replace `WebviewAudioBackend`, Windows video is largely a follow-on.
Sequencing, if native video is ever pursued on both:
1. [libmpv2-migration.md](libmpv2-migration.md) — prerequisite for either.
2. [windows-native-audio-backend.md](windows-native-audio-backend.md) — already
specced; lands the DLL and a real Windows backend.
3. Windows native video — cheap once 2 exists, and does not need this spike.
4. Linux native video — needs this spike, and runs independently of 13.
### Does this add a backend?
No — and the trajectory is convergence, not proliferation.
`create_player_backend` in `lib.rs` already selects between four
`PlayerBackend` impls by `cfg!`: `MpvBackend` (Linux), `ExoPlayerBackend`
(Android), `WebviewAudioBackend` (Windows and anything else), and `NullBackend`
as the graceful-init fallback. The HTML5 video path is not among them — it is a
frontend adapter reporting through `player_report_*`, not a `PlayerBackend`.
This spike adds none of these. `MpvBackend` already exists and already runs on
Linux; it merely sets `video = no` at construction. Giving it video widens an
existing backend rather than introducing an engine.
Following the sequence above, the count goes **down**: replacing
`WebviewAudioBackend` with mpv on Windows leaves two native engines — mpv
(Linux + Windows) and ExoPlayer (Android) — with native video riding on both.
Two is the floor, for a reason worth stating so nobody re-litigates it: Android
cannot drop ExoPlayer even if libmpv runs there, because the foreground service,
`MediaSessionCompat` and lockscreen control are built on it (finding 7 puts the
cost at that rewrite, not at the bindings). The HTML5 path does not go away
either — it is the transcode/ABR route and the fallback.
The trait surface converges too: `ExoPlayerBackend` already implements the
video-surface lifecycle for Android native compositing, so teaching `MpvBackend`
video follows a path already walked rather than opening a second one.
- Adopting `tauri-plugin-libmpv` or `tauri-plugin-mpv` as dependencies. Both
report Linux window embedding as not working and are small projects
(20 and ~70 commits); read them, do not depend on them.
## Acceptance criteria
The deliverable is a decision, not a feature.
- [ ] Each of G1G6 recorded green/red **with the observed mechanism**, not just
the verdict.
- [ ] X11 and Wayland results reported separately, each naming the compositor
and WebKitGTK version tested.
- [ ] The direct-play/transcode ABR hypothesis recorded as open, with whatever
the spike learned about it.
- [ ] `docs/specs/README.md` updated — this spec listed, and its row moved or
deleted per the result.
- [ ] The Linux claim in the `createAdapter` doc comment
([adapters/index.ts:12-13](../../src/lib/player/adapters/index.ts#L12-L13))
corrected either way: if red, cite this spike instead of asserting it; if
green, it is wrong and must be rewritten.
- [ ] On **red**: finding 2 of
[playback-backend-unification.md](playback-backend-unification.md) gains a
dated note naming the render-API method as also tested, and this file is
deleted. The verdict lives in the design-authority spec, not in a second
file that contradicts nothing.
- [ ] On **green**: an implementation spec exists, allocating ids from
**UR-077 / IR-033 / DR-216** (re-check `requirements.md` — the README's
"next free DR-215" is stale, DR-215 landed), and it must answer ABR before
being accepted.
- [ ] No spike code on `master`. If any lands, the standard gates apply:
`bun run check`, `bun run test`, `bun run check:boundary`, `cargo fmt`,
`cargo clippy`, `bun run test:rust`.
## Testing
No automated tests. A compositing result is a visual, per-session-backend
observation and cannot be asserted in `cargo test` or vitest — pretending
otherwise would produce a test that passes on a headless runner and tells us
nothing.
Capture a screenshot per gate. G4 specifically: an opaque HTML element over the
video area, photographed showing the video *behind* it.
If it goes green, the implementation spec inherits the testable surface the
Android work already established — `nativeVideoLayers.test.ts` asserts the
`app.css` selector list and the `data-native-video` contract, and both are
platform-agnostic.
## TRACES
None. No requirement-implementing code is produced. The implementation spec that
follows a green result allocates from DR-216 and tags there.
## Notes for the implementer
- **Read [playback-backend-unification.md](playback-backend-unification.md)
first, in full.** This spike disputes exactly one of its six findings, on one
platform, by one method it did not try. Everything else in it is still binding
— particularly finding 3.
- Test **Wayland first**. It is the gate most likely to be red and the one that
makes the rest moot.
- The frontend plumbing already exists from the Android work: `createAdapter`,
`NativePlayerAdapter`, `nativeVideo.ts`, `videoSurface.ts`, and the
`[data-native-video="active"]` rule. A green spike is far cheaper to implement
than it would have been a year ago — which is itself part of why the question
is worth re-asking.
- The Android record in
[05-platform-backends.md](../architecture/05-platform-backends.md#native-video-compositing-android)
lists six shipped defects from getting this right on one platform. Expect the
Linux equivalents (the surface outliving its player, the shell painting over
it, unpainted letterbox bars) rather than rediscovering them.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes.
@@ -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.
+28 -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
@@ -87,6 +90,17 @@ The most recent comment on tauri#6343 (2026-05-23) confirms it is still unsolved
**The principle to carry forward: audio can unify on a native engine; video
cannot, because video needs a surface and the webview owns the surface.**
> **Re-opened on Linux (2026-08-21).** This finding's general form has since been
> falsified on Android — native video now composites behind a transparent Tauri
> WebView and ships on by default (see
> [05-platform-backends.md](../architecture/05-platform-backends.md#native-video-compositing-android)).
> The evidence above is also entirely about *foreign-window embedding*; mpv's
> render API, drawing into a GL context we own inside Tauri's own GTK tree, was
> never tested. [linux-native-video-spike.md](linux-native-video-spike.md) tests
> that one claim on Linux. **Findings 3-6 below are untouched by it** - in
> particular finding 3, which is an independent disqualifier a green spike would
> not clear.
### 3. mpv would regress streaming quality
mpv has **no adaptive bitrate**. It delegates HLS to FFmpeg's demuxer, which
@@ -97,6 +111,17 @@ The webview path already has real ABR via hls.js. Moving video to mpv would be a
**downgrade** on every platform — no graceful degradation on weak networks, and
quality changes requiring teardown and reload.
> **Premise in doubt (2026-08-21).** "The webview path already has real ABR"
> was not verified against the URLs this app actually builds.
> `get_video_stream_url` requests a *single* rendition (one `VideoBitrate`, one
> `MaxHeight`), the frontend has **no** level-handling code (`hls.levels`,
> `LEVEL_SWITCH`, `currentLevel` appear nowhere), and this repo implements a
> quality switch by *re-opening the stream* — all of which point to a
> single-variant playlist, i.e. no ABR to lose. The decisive test is counting
> `#EXT-X-STREAM-INF` lines in a real `master.m3u8`; it needs a live server and
> has not been run. See
> [linux-native-video-spike.md](linux-native-video-spike.md).
### 4. Crossfade is architecturally blocked on mpv
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
@@ -167,9 +192,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)
+15 -14
View File
@@ -15,7 +15,7 @@ The CI/CD pipeline automatically validates that code changes are properly traced
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
- ✅ Automatic trace extraction
- ✅ Coverage validation against minimum threshold (82%, ratcheted)
- ✅ Coverage validation against minimum threshold (88%, ratcheted)
- ✅ Modified file checking
- ✅ Artifact preservation
- ✅ Summary reports
@@ -43,7 +43,7 @@ Extracts all TRACES comments from:
### 2. Coverage Thresholds
The workflow checks:
- **Minimum overall coverage:** 82% (`MIN_THRESHOLD`)
- **Minimum overall coverage:** 88% (`MIN_THRESHOLD`)
Denominators are **derived from `docs/requirements.md` at run time** — they are
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
@@ -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
@@ -67,9 +67,10 @@ or if it computes above 100%, which can only mean the gate is miscounting.
#### Ratchet policy
`MIN_THRESHOLD` **only ever goes up.** It is deliberately set a few points below
the coverage actually achieved (82 against a real 86%), so a genuine regression
the coverage actually achieved (88 against a real ~90%), so a genuine regression
trips it. It previously sat at 50 while true coverage was 86%: nearly half the
matrix could have rotted before CI objected.
matrix could have rotted before CI objected. It was ratcheted 50 → 82 when that
was found, and 82 → 88 once coverage had held above 88% for several releases.
When coverage rises durably, raise the threshold to just under the new figure.
**Never lower it to make a red build pass** — add the missing TRACES comments
@@ -151,13 +152,13 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
### On Push to Main Branch
1. ✅ Extracts all traces from code
2. ✅ Validates coverage is >= 82%
2. ✅ Validates coverage is >= 88%
3. ✅ Generates full traceability report
4. ✅ Saves report as artifact
### On Pull Request
1. ✅ Extracts all traces
2. ✅ Validates coverage >= 82%
2. ✅ Validates coverage >= 88%
3. ✅ Checks modified files for TRACES
4. ✅ Warns if new code lacks TRACES
5. ✅ Suggests proper format
@@ -165,7 +166,7 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
### Failure Scenarios
The workflow **fails** (blocks merge) if:
- Coverage drops below 82%
- Coverage drops below 88%
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
- JSON extraction fails
- Invalid trace format
@@ -203,12 +204,12 @@ below threshold. Numbers are deliberately not pinned here; the previous snapshot
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
made the broken CI arithmetic look plausible for so long.
As of July 2026 overall coverage is ~86% (182/212).
As of August 2026 overall coverage is ~90%.
### Targets
- **Short term** (Sprint): Maintain ≥82% overall (the current ratchet)
- **Medium term** (Month): Reach 70% overall coverage
- **Long term** (Release): Reach 90% coverage with focus on:
- **Short term** (Sprint): Maintain ≥88% overall (the current ratchet)
- **Medium term** (Month): Hold above 90% and ratchet the gate to match
- **Long term** (Release): Reach 95% coverage with focus on:
- IR requirements (API clients)
- JA requirements (Jellyfin API endpoints)
- Remaining UR/DR requirements
@@ -241,14 +242,14 @@ When submitting a pull request:
- [ ] All new code has TRACES comments linking to requirements
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
- [ ] Workflow passes (coverage ≥ 82%)
- [ ] Workflow passes (coverage ≥ 88%)
- [ ] No coverage regressions
- [ ] Artifact traceability report was generated
## Troubleshooting
### "Coverage below minimum threshold"
**Problem:** Workflow fails with coverage < 82%
**Problem:** Workflow fails with coverage < 88%
**Solution:**
1. Run `bun run traces:json` locally
+5340 -4779
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -52,10 +52,10 @@ fn test_queue_next() {
## Where to Find Requirements
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements)
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements)
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements)
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements)
1. **User Requirements (UR):** [requirements.md](requirements.md#1-user-requirements)
2. **Integration Requirements (IR):** [requirements.md](requirements.md#21-integration-requirements)
3. **Development Requirements (DR):** [requirements.md](requirements.md#23-development-requirements)
4. **Jellyfin API (JA):** [requirements.md](requirements.md#22-jellyfin-api-requirements)
## How to Add TRACES
@@ -139,13 +139,13 @@ bun run traces:json | jq '.requirements."UR-005"'
## CI/CD Validation
The workflow automatically checks:
- ✅ Coverage stays >= 82% (a ratchet — raise it, never lower it)
- ✅ Coverage stays >= 88% (a ratchet — raise it, never lower it)
- ✅ Every traced ID is defined in `docs/requirements.md`
- ✅ New files have TRACES
- ✅ JSON format is valid
- ✅ Reports are generated
See [traceability-ci.md](docs/traceability-ci.md) for details.
See [traceability-ci.md](traceability-ci.md) for details.
## Tips & Tricks
@@ -199,10 +199,10 @@ A: Yes! TRACES show your implementation plan.
## See Also
- [Full Traceability Matrix](docs/traceability.md)
- [CI/CD Pipeline Guide](docs/traceability-ci.md)
- [Requirements Specification](README.md)
- [Extraction Script](scripts/README.md#extract-tracests)
- [Full Traceability Matrix](traceability.md)
- [CI/CD Pipeline Guide](traceability-ci.md)
- [Requirements Specification](requirements.md)
- [Extraction Script](../scripts/README.md#extract-tracests)
---
+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
-24
View File
@@ -1,24 +0,0 @@
# E2E Test Configuration
# Copy this file to .env and fill in your test credentials
# Jellyfin Server Configuration
TEST_SERVER_URL=https://demo.jellyfin.org/stable
TEST_SERVER_NAME=Demo Server
# Test User Credentials
TEST_USERNAME=demo
TEST_PASSWORD=
# Optional: Specific test data IDs (for testing playback, etc.)
# You can find these IDs in your Jellyfin server
TEST_MUSIC_LIBRARY_ID=
TEST_MOVIE_LIBRARY_ID=
TEST_ARTIST_ID=
TEST_ALBUM_ID=
TEST_TRACK_ID=
TEST_MOVIE_ID=
TEST_EPISODE_ID=
# Test Timeouts (milliseconds)
TEST_TIMEOUT=60000
TEST_WAIT_TIMEOUT=15000
-376
View File
@@ -1,376 +0,0 @@
# E2E Testing with WebdriverIO
End-to-end tests for JellyTau using WebdriverIO and tauri-driver. These tests run against a real Tauri app instance with an **isolated test database**.
## Quick Start
```bash
# 1. Configure test credentials (first time only)
cp e2e/.env.example e2e/.env
# Edit e2e/.env with your Jellyfin server details
# 2. Build the frontend
bun run build
# 3. Run E2E tests
bun run test:e2e
```
## Configuration
### Test Credentials
E2E tests use credentials from `e2e/.env` (gitignored). Copy the example file to get started:
```bash
cp e2e/.env.example e2e/.env
```
**e2e/.env** (your private file):
```bash
# Your Jellyfin test server
TEST_SERVER_URL=https://your-jellyfin.example.com
TEST_SERVER_NAME=My Test Server
# Test user credentials
TEST_USERNAME=testuser
TEST_PASSWORD=yourpassword
# Optional: Specific test data IDs
TEST_MUSIC_LIBRARY_ID=abc123
TEST_ALBUM_ID=xyz789
# ... etc
```
**Important:**
- ✅ `.env` is gitignored - your credentials stay private
- ✅ Tests fall back to Jellyfin demo server if `.env` doesn't exist
- ✅ Share `.env.example` with your team so they can set up their own
### Isolated Test Database
**Your production data is safe!** E2E tests use a completely separate database:
- **Production:** `~/.local/share/com.dtourolle.jellytau/` - Your real data ✅
- **E2E Tests:** `/tmp/jellytau-test-data/` - Isolated test data ✅
This is configured via the `JELLYTAU_DATA_DIR` environment variable in `wdio.conf.ts`.
## Architecture
### Test Structure
```
e2e/
├── .env.example # Template for test credentials
├── .env # Your credentials (gitignored)
├── specs/ # Test specifications
│ ├── app-launch.e2e.ts # App initialization tests
│ ├── auth.e2e.ts # Authentication flow
│ └── navigation.e2e.ts # Navigation and routing
├── pageobjects/ # Page Object Model (POM)
│ ├── BasePage.ts # Base class with common methods
│ ├── LoginPage.ts # Login page interactions
│ └── HomePage.ts # Home page interactions
└── helpers/ # Test utilities
├── testConfig.ts # Load .env configuration
└── testSetup.ts # Setup helpers
```
### Page Object Model
Tests use the Page Object Model pattern for maintainability:
```typescript
// Good: Using page objects
import LoginPage from "../pageobjects/LoginPage";
await LoginPage.waitForLoginPage();
await LoginPage.connectToServer(testConfig.serverUrl);
await LoginPage.login(testConfig.username, testConfig.password);
// Bad: Direct selectors in tests
await $("#server-url").setValue("https://...");
await $("button").click();
```
## Writing Tests
### Using Test Configuration
Always use `testConfig` for credentials and server details:
```typescript
import { testConfig } from "../helpers/testConfig";
describe("My Feature", () => {
it("should test something", async () => {
// Use testConfig instead of hardcoded values
await LoginPage.connectToServer(testConfig.serverUrl);
await LoginPage.login(testConfig.username, testConfig.password);
// Access optional test data
if (testConfig.albumId) {
// Test with specific album
}
});
});
```
### Test Data IDs
For tests that need specific content (albums, tracks, etc.):
1. Find the ID in your Jellyfin server (check the URL when viewing an item)
2. Add it to your `e2e/.env`:
```bash
TEST_ALBUM_ID=abc123def456
```
3. Use it in tests:
```typescript
if (testConfig.albumId) {
await browser.url(`/album/${testConfig.albumId}`);
}
```
### Example Test
```typescript
import { expect } from "@wdio/globals";
import LoginPage from "../pageobjects/LoginPage";
import { testConfig } from "../helpers/testConfig";
describe("Album Playback", () => {
beforeEach(async () => {
// Login before each test
await LoginPage.waitForLoginPage();
await LoginPage.fullLoginFlow(
testConfig.serverUrl,
testConfig.username,
testConfig.password
);
});
it("should play an album", async () => {
// Skip if no test album configured
if (!testConfig.albumId) {
console.log("Skipping - no TEST_ALBUM_ID configured");
return;
}
// Navigate to album
await browser.url(`/album/${testConfig.albumId}`);
// Click play
const playButton = await $('[aria-label="Play"]');
await playButton.click();
// Verify playback started
const miniPlayer = await $(".mini-player");
expect(await miniPlayer.isDisplayed()).toBe(true);
});
});
```
## Running Tests
### Commands
```bash
# Run all E2E tests
bun run test:e2e
# Run in watch mode (development)
bun run test:e2e:dev
# Run specific test file
bun run test:e2e -- e2e/specs/auth.e2e.ts
```
### Before Running
**Always build the frontend first:**
```bash
bun run build
cd src-tauri && cargo build
```
The debug binary expects built frontend files in the `build/` directory.
## Test Files
### app-launch.e2e.ts
Basic app initialization tests:
- App launches successfully
- UI renders correctly
- Unauthenticated users redirect to login
**Status:** ✅ Working (no credentials needed)
### auth.e2e.ts
Full authentication flow:
- Server connection (2-step process)
- Login form validation
- Error handling
- Complete auth flow
**Status:** ✅ Working with any Jellyfin server
### navigation.e2e.ts
Routing and navigation:
- Protected routes
- Redirects
- Navigation after login
**Status:** ⚠️ Needs valid credentials (configure `.env`)
## Configuration Reference
### wdio.conf.ts
Main WebdriverIO configuration:
```typescript
{
port: 4444, // tauri-driver port
maxInstances: 1, // Run tests sequentially
logLevel: "warn", // Reduce noise
framework: "mocha",
timeout: 60000, // 60s test timeout
capabilities: [{
"tauri:options": {
application: "path/to/app",
env: {
JELLYTAU_DATA_DIR: "/tmp/jellytau-test-data" // Isolated DB
}
}
}]
}
```
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `TEST_SERVER_URL` | Jellyfin server URL | `https://demo.jellyfin.org/stable` |
| `TEST_SERVER_NAME` | Server display name | `Demo Server` |
| `TEST_USERNAME` | Test user username | `demo` |
| `TEST_PASSWORD` | Test user password | `` (empty) |
| `TEST_MUSIC_LIBRARY_ID` | Music library ID | undefined |
| `TEST_ALBUM_ID` | Album ID for playback tests | undefined |
| `TEST_TRACK_ID` | Track ID for tests | undefined |
| `TEST_TIMEOUT` | Mocha test timeout (ms) | `60000` |
| `TEST_WAIT_TIMEOUT` | Element wait timeout (ms) | `15000` |
## Debugging
### View Application During Tests
Tests run with a visible window. To pause and inspect:
```typescript
it("debug test", async () => {
await LoginPage.waitForLoginPage();
// Pause for 10 seconds to inspect
await browser.pause(10000);
await LoginPage.enterServerUrl(testConfig.serverUrl);
});
```
### Check Logs
- **WebdriverIO logs:** Console output (set `logLevel: "info"` in config)
- **tauri-driver logs:** Stdout/stderr from driver process
- **App logs:** Check app console (if running with dev tools)
### Common Issues
**"Connection refused" in browser body**
- Frontend not built: Run `bun run build`
- Solution: Always build before testing
**"Element not found" errors**
- Selector might be wrong
- Element not loaded yet - add wait: `await element.waitForDisplayed()`
**"Invalid session id"**
- Normal when app closes between tests
- Each test file gets a fresh app instance
**Tests fail with "no .env file"**
- Copy `e2e/.env.example` to `e2e/.env`
- Configure your Jellyfin server details
**Database still using production data**
- Check `wdio.conf.ts` has `JELLYTAU_DATA_DIR` env var
- Rebuild app: `cd src-tauri && cargo build`
## Platform Support
### Supported
- ✅ **Linux** - Primary development platform
- ✅ **Windows** - Supported (paths auto-detected)
- ✅ **macOS** - Supported (paths auto-detected)
### Not Supported
- ❌ **Android** - E2E testing requires Appium + emulators (out of scope)
- Desktop tests cover 90% of app logic anyway
## Team Collaboration
### Sharing Test Configuration
**DO:**
- ✅ Commit `e2e/.env.example` with template values
- ✅ Update README when adding new test data requirements
- ✅ Use descriptive variable names in `.env.example`
**DON'T:**
- ❌ Commit `e2e/.env` with real credentials
- ❌ Hardcode server URLs in test files
- ❌ Skip authentication in tests (always test full flows)
### Setting Up for a New Team Member
1. **Clone repo**
2. **Copy env template:** `cp e2e/.env.example e2e/.env`
3. **Configure credentials:** Edit `e2e/.env` with your Jellyfin server
4. **Build frontend:** `bun run build`
5. **Run tests:** `bun run test:e2e`
That's it! No shared credentials needed.
## Best Practices
1. **Use testConfig:** Never hardcode credentials
2. **Use Page Objects:** Keep selectors out of test specs
3. **Wait for Elements:** Always use `.waitForDisplayed()`
4. **Independent Tests:** Each test should work standalone
5. **Skip Gracefully:** Check for optional test data before using
6. **Build First:** Always `bun run build` before running tests
7. **Clear Names:** Use descriptive `describe` and `it` blocks
## Future Enhancements
- [ ] Add more page objects (Player, Library, Queue, Settings)
- [ ] Create test data fixtures
- [ ] Add visual regression testing
- [ ] Mock Jellyfin API for faster, more reliable tests
- [ ] CI/CD integration (GitHub Actions)
- [ ] Test report generation
- [ ] Screenshot capture on failure
- [ ] Video recording of test runs
## Resources
- [WebdriverIO Documentation](https://webdriver.io/)
- [Tauri Testing Guide](https://v2.tauri.app/develop/tests/webdriver/)
- [tauri-driver GitHub](https://github.com/tauri-apps/tauri/tree/dev/tooling/webdriver)
- [Mocha Documentation](https://mochajs.org/)
- [Page Object Model Pattern](https://webdriver.io/docs/pageobjects/)
-105
View File
@@ -1,105 +0,0 @@
import fs from "node:fs";
import path from "node:path";
/**
* Test configuration loaded from .env file
*/
export interface TestConfig {
serverUrl: string;
serverName: string;
username: string;
password: string;
musicLibraryId?: string;
movieLibraryId?: string;
artistId?: string;
albumId?: string;
trackId?: string;
movieId?: string;
episodeId?: string;
timeout: number;
waitTimeout: number;
}
/**
* Load test configuration from .env file
* Falls back to demo server if .env doesn't exist
*/
export function loadTestConfig(): TestConfig {
const envPath = path.join(__dirname, "..", ".env");
const config: TestConfig = {
serverUrl: "https://demo.jellyfin.org/stable",
serverName: "Demo Server",
username: "demo",
password: "",
timeout: 60000,
waitTimeout: 15000,
};
// Try to load .env file
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, "utf-8");
const lines = envContent.split("\n");
for (const line of lines) {
// Skip comments and empty lines
if (line.trim().startsWith("#") || !line.trim()) continue;
const [key, ...valueParts] = line.split("=");
const value = valueParts.join("=").trim();
switch (key.trim()) {
case "TEST_SERVER_URL":
if (value) config.serverUrl = value;
break;
case "TEST_SERVER_NAME":
if (value) config.serverName = value;
break;
case "TEST_USERNAME":
if (value) config.username = value;
break;
case "TEST_PASSWORD":
config.password = value; // Can be empty
break;
case "TEST_MUSIC_LIBRARY_ID":
if (value) config.musicLibraryId = value;
break;
case "TEST_MOVIE_LIBRARY_ID":
if (value) config.movieLibraryId = value;
break;
case "TEST_ARTIST_ID":
if (value) config.artistId = value;
break;
case "TEST_ALBUM_ID":
if (value) config.albumId = value;
break;
case "TEST_TRACK_ID":
if (value) config.trackId = value;
break;
case "TEST_MOVIE_ID":
if (value) config.movieId = value;
break;
case "TEST_EPISODE_ID":
if (value) config.episodeId = value;
break;
case "TEST_TIMEOUT":
if (value) config.timeout = parseInt(value, 10);
break;
case "TEST_WAIT_TIMEOUT":
if (value) config.waitTimeout = parseInt(value, 10);
break;
}
}
} else {
console.warn(
"⚠️ No e2e/.env file found. Using demo server credentials."
);
console.warn(
" Copy e2e/.env.example to e2e/.env and configure your test server."
);
}
return config;
}
// Export a singleton instance
export const testConfig = loadTestConfig();
-53
View File
@@ -1,53 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
/**
* Clears the JellyTau database and cache before tests
* This ensures each test run starts with a fresh state
*/
export function clearAppData() {
const appDataDir = path.join(
os.homedir(),
".local/share/com.dtourolle.jellytau"
);
try {
if (fs.existsSync(appDataDir)) {
// Remove database file
const dbPath = path.join(appDataDir, "jellytau.db");
if (fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath);
console.log("Cleared test database");
}
// Clear any cache files if needed
// Add more cleanup as needed
}
} catch (error) {
console.warn("Failed to clear app data:", error);
// Don't fail tests if cleanup fails
}
}
/**
* Wait for element with retries
* Useful for elements that might take time to appear
*/
export async function waitForElement(
selector: string,
timeout: number = 15000,
retries: number = 3
): Promise<WebdriverIO.Element> {
for (let i = 0; i < retries; i++) {
try {
const element = await $(selector);
await element.waitForDisplayed({ timeout });
return element;
} catch (error) {
if (i === retries - 1) throw error;
await browser.pause(1000);
}
}
throw new Error(`Element ${selector} not found after ${retries} retries`);
}
-31
View File
@@ -1,31 +0,0 @@
export default class BasePage {
async waitForElement(selector: string, timeout: number = 10000) {
const element = await $(selector);
await element.waitForDisplayed({ timeout });
return element;
}
async clickElement(selector: string) {
const element = await this.waitForElement(selector);
await element.click();
}
async enterText(selector: string, text: string) {
const element = await this.waitForElement(selector);
await element.setValue(text);
}
async getText(selector: string): Promise<string> {
const element = await this.waitForElement(selector);
return await element.getText();
}
async isElementDisplayed(selector: string): Promise<boolean> {
try {
const element = await $(selector);
return await element.isDisplayed();
} catch (error) {
return false;
}
}
}
-55
View File
@@ -1,55 +0,0 @@
import BasePage from "./BasePage";
class HomePage extends BasePage {
// Selectors
get loadingSpinner() {
return $(".animate-spin");
}
get browseLibrariesButton() {
return $("button*=Browse all libraries");
}
get offlineBanner() {
return $(".bg-amber-600\\/90");
}
// Carousel sections
get heroSection() {
return $("div"); // Hero banner would need specific selector
}
// Actions
async waitForHomePageLoad(timeout: number = 15000) {
// Wait for loading spinner to disappear
try {
await this.loadingSpinner.waitForDisplayed({ timeout: 5000 });
await this.loadingSpinner.waitForDisplayed({ timeout, reverse: true });
} catch {
// Spinner might not appear if page loads quickly
}
}
async isOffline(): Promise<boolean> {
try {
return await this.offlineBanner.isDisplayed();
} catch {
return false;
}
}
async clickBrowseLibraries() {
await this.browseLibrariesButton.click();
}
async hasContent(): Promise<boolean> {
// Check if browse button exists (indicates loaded state)
try {
return await this.browseLibrariesButton.isExisting();
} catch {
return false;
}
}
}
export default new HomePage();
-116
View File
@@ -1,116 +0,0 @@
import BasePage from "./BasePage";
class LoginPage extends BasePage {
// Selectors
get pageTitle() {
return $("h1");
}
get serverUrlInput() {
return $("#server-url");
}
get connectButton() {
return $('button[type="submit"]');
}
get usernameInput() {
return $("#username");
}
get passwordInput() {
return $("#password");
}
get signInButton() {
return $('button[type="submit"]');
}
get errorMessage() {
return $(".bg-red-900\\/50");
}
get backButton() {
return $("button*=Back");
}
get serverNameDisplay() {
return $('p.text-\\[var\\(--color-jellyfin\\)\\]');
}
// Actions
async waitForLoginPage(timeout: number = 10000) {
await this.serverUrlInput.waitForDisplayed({ timeout });
}
async enterServerUrl(url: string) {
await this.serverUrlInput.setValue(url);
}
async clickConnect() {
await this.connectButton.click();
}
async connectToServer(url: string) {
await this.enterServerUrl(url);
await this.clickConnect();
// Wait for transition to login form
await this.usernameInput.waitForDisplayed({ timeout: 10000 });
}
async enterUsername(username: string) {
await this.usernameInput.setValue(username);
}
async enterPassword(password: string) {
await this.passwordInput.setValue(password);
}
async clickSignIn() {
await this.signInButton.click();
}
async login(username: string, password: string) {
await this.enterUsername(username);
await this.enterPassword(password);
await this.clickSignIn();
}
async fullLoginFlow(serverUrl: string, username: string, password: string) {
await this.waitForLoginPage();
await this.connectToServer(serverUrl);
await this.login(username, password);
}
async isOnServerStep(): Promise<boolean> {
try {
return await this.serverUrlInput.isDisplayed();
} catch {
return false;
}
}
async isOnLoginStep(): Promise<boolean> {
try {
return await this.usernameInput.isDisplayed();
} catch {
return false;
}
}
async getErrorMessage(): Promise<string> {
await this.errorMessage.waitForDisplayed({ timeout: 5000 });
return await this.errorMessage.getText();
}
async hasError(): Promise<boolean> {
try {
return await this.errorMessage.isDisplayed();
} catch {
return false;
}
}
}
export default new LoginPage();
-39
View File
@@ -1,39 +0,0 @@
import { expect } from "@wdio/globals";
describe("Application Launch", () => {
it("should launch the application", async () => {
// Wait for body element to appear
const body = await $("body");
await body.waitForDisplayed({ timeout: 15000 });
// Verify app launched successfully
expect(await body.isDisplayed()).toBe(true);
});
it("should render the main app container", async () => {
// The app has a root div with specific classes
const appContainer = await $("div.h-screen.bg-\\[var\\(--color-background\\)\\]");
// Verify the main container exists
expect(await appContainer.isExisting()).toBe(true);
expect(await appContainer.isDisplayed()).toBe(true);
});
it("should show JellyTau branding", async () => {
// The app should show JellyTau title on login page (default state)
const title = await $("h1");
await title.waitForDisplayed({ timeout: 10000 });
const titleText = await title.getText();
expect(titleText).toContain("JellyTau");
});
it("should redirect unauthenticated users to login", async () => {
// Wait for login page elements to appear
const serverUrlInput = await $("#server-url");
await serverUrlInput.waitForDisplayed({ timeout: 10000 });
// Verify we're on the login page
expect(await serverUrlInput.isDisplayed()).toBe(true);
});
});
-145
View File
@@ -1,145 +0,0 @@
import { expect } from "@wdio/globals";
import LoginPage from "../pageobjects/LoginPage";
import { testConfig } from "../helpers/testConfig";
describe("Authentication Flow", () => {
beforeEach(async () => {
// Each test starts fresh - app should redirect to login
await LoginPage.waitForLoginPage();
});
describe("Server Connection", () => {
it("should display the server connection form", async () => {
expect(await LoginPage.isOnServerStep()).toBe(true);
expect(await LoginPage.pageTitle.getText()).toContain("JellyTau");
});
it("should show server URL input field", async () => {
const serverInput = await LoginPage.serverUrlInput;
expect(await serverInput.isDisplayed()).toBe(true);
expect(await serverInput.getAttribute("placeholder")).toContain("jellyfin");
});
it("should have a disabled connect button when URL is empty", async () => {
const connectButton = await LoginPage.connectButton;
// Button should be disabled when input is empty
expect(await connectButton.isEnabled()).toBe(false);
});
it("should enable connect button when URL is entered", async () => {
await LoginPage.enterServerUrl(testConfig.serverUrl);
const connectButton = await LoginPage.connectButton;
expect(await connectButton.isEnabled()).toBe(true);
});
it("should show error for invalid server URL", async () => {
await LoginPage.enterServerUrl("not-a-valid-url");
await LoginPage.clickConnect();
// Wait for error to appear
await browser.pause(2000);
expect(await LoginPage.hasError()).toBe(true);
});
it("should transition to login form on successful connection", async () => {
// Using configured test server
await LoginPage.connectToServer(testConfig.serverUrl);
// Should now be on login step
expect(await LoginPage.isOnLoginStep()).toBe(true);
expect(await LoginPage.isOnServerStep()).toBe(false);
});
});
describe("User Login", () => {
beforeEach(async () => {
// Connect to configured test server before each login test
await LoginPage.connectToServer(testConfig.serverUrl);
});
it("should display login form after server connection", async () => {
expect(await LoginPage.usernameInput.isDisplayed()).toBe(true);
expect(await LoginPage.passwordInput.isDisplayed()).toBe(true);
expect(await LoginPage.signInButton.isDisplayed()).toBe(true);
});
it("should show server information", async () => {
// Server name and URL should be displayed
const serverName = await LoginPage.serverNameDisplay;
expect(await serverName.isDisplayed()).toBe(true);
});
it("should have back button to return to server selection", async () => {
expect(await LoginPage.backButton.isDisplayed()).toBe(true);
await LoginPage.backButton.click();
await browser.pause(500);
// Should be back on server step
expect(await LoginPage.isOnServerStep()).toBe(true);
});
it("should disable sign in button when username is empty", async () => {
const signInButton = await LoginPage.signInButton;
expect(await signInButton.isEnabled()).toBe(false);
});
it("should enable sign in button when username is entered", async () => {
await LoginPage.enterUsername("demo");
const signInButton = await LoginPage.signInButton;
expect(await signInButton.isEnabled()).toBe(true);
});
it("should show error for invalid credentials", async () => {
await LoginPage.login("invalid-user", "wrong-password");
// Wait for error
await browser.pause(2000);
expect(await LoginPage.hasError()).toBe(true);
});
// Enable this test by configuring e2e/.env with valid credentials
it.skip("should successfully login with valid credentials", async () => {
await LoginPage.login(testConfig.username, testConfig.password);
// Wait for redirect to home page
await browser.pause(3000);
// Should redirect away from login page
const currentUrl = await browser.getUrl();
expect(currentUrl).not.toContain("/login");
});
});
describe("Full Authentication Flow", () => {
it("should complete full auth flow with test server", async () => {
// Test the complete flow
await LoginPage.waitForLoginPage();
// Step 1: Enter server URL
expect(await LoginPage.isOnServerStep()).toBe(true);
await LoginPage.enterServerUrl(testConfig.serverUrl);
await LoginPage.clickConnect();
// Wait for transition
await browser.pause(2000);
// Step 2: Should be on login form
expect(await LoginPage.isOnLoginStep()).toBe(true);
// Step 3: Enter credentials
await LoginPage.enterUsername(testConfig.username);
await LoginPage.enterPassword(testConfig.password);
// Verify form is filled
const username = await LoginPage.usernameInput.getValue();
expect(username).toBe(testConfig.username);
});
});
});
-39
View File
@@ -1,39 +0,0 @@
import { expect } from "@wdio/globals";
import LoginPage from "../pageobjects/LoginPage";
import HomePage from "../pageobjects/HomePage";
import { testConfig } from "../helpers/testConfig";
describe("Navigation", () => {
it("should redirect unauthenticated users to login", async () => {
// App should automatically redirect to login when not authenticated
await LoginPage.waitForLoginPage();
expect(await LoginPage.isOnServerStep()).toBe(true);
});
it("should prevent direct access to protected routes", async () => {
// Try to navigate to a protected route
await browser.url("http://localhost:4444/session/fake-session-id/url");
await browser.pause(1000);
// Should redirect back to login
await LoginPage.waitForLoginPage(5000);
expect(await LoginPage.isOnServerStep()).toBe(true);
});
// This test requires valid authentication - configure e2e/.env to enable
it.skip("should allow navigation after login", async () => {
// Login first
await LoginPage.fullLoginFlow(
testConfig.serverUrl,
testConfig.username,
testConfig.password
);
// Wait for home page
await HomePage.waitForHomePageLoad();
// Should be able to navigate
expect(await HomePage.hasContent()).toBe(true);
});
});
+199
View File
@@ -0,0 +1,199 @@
// ESLint flat config for the JellyTau frontend (Svelte 5 + TypeScript strict).
//
// TRACES: | DR-205
//
// Scope: `src/` (the presentation layer), `scripts/` (build tooling), and the
// root config files. The Rust backend is linted by clippy, not by this config.
//
// Formatting is NOT ESLint's job here — `eslint-config-prettier` is applied last
// and switches off every stylistic rule that would fight `prettier`. Run
// `bun run format` / `bun run format:check` for layout.
import js from "@eslint/js";
import ts from "typescript-eslint";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
import prettier from "eslint-config-prettier";
import svelteConfig from "./svelte.config.js";
export default ts.config(
{
// Kept in one place so `npx eslint .` and editor integrations agree.
ignores: [
"node_modules/",
".svelte-kit/",
// Scratch worktrees (git-ignored) hold full checkouts of this repo,
// including their own generated .svelte-kit trees. Without this, `eslint .`
// lints every in-flight branch and reports its generated code as ours.
".claude/",
"build/",
"dist/",
"coverage/",
"package/",
"src-tauri/",
// Generated by tauri-specta on every Rust build — never hand-edited, and
// its shape is dictated by the Rust command definitions.
"src/lib/api/bindings.ts",
],
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: {
...globals.browser,
...globals.es2021,
},
},
rules: {
// 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.
//
// 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".
//
// ⚠️ warn, not error: the tree carries ~94 genuinely dead bindings (stale
// imports, `$state` left over from refactors, unused `catch (e)`). Every
// one is a real finding, but fixing them here would mean ~50 unrelated
// files in this tooling commit. Clear the backlog, then promote to
// "error".
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
},
],
// Warn-only rules: each flags something real, but the existing tree has
// more instances than can be fixed without swamping unrelated diffs.
// Drive these to zero and promote them to "error" — do not delete them.
//
// `any` at the Tauri IPC boundary, mostly in code predating the
// tauri-specta bindings (~25 sites outside tests).
"@typescript-eslint/no-explicit-any": "warn",
// Empty catch/if bodies that swallow an error.
"no-empty": ["warn", { allowEmptyCatch: true }],
// Prefer `import type` so type-only imports are erased cleanly by the
// bundler instead of pulling a module in at run time.
"@typescript-eslint/consistent-type-imports": "off",
// Not applicable to this app (~130 hits, all no-ops). SvelteKit's
// `resolve()` exists so hrefs keep working under a non-empty
// `kit.paths.base`; JellyTau is an adapter-static SPA served from the
// Tauri webview root and svelte.config.js sets no `base`. Re-enable this
// the day a base path is introduced — the rule is otherwise correct.
// (Declared here, not in the *.svelte block: `goto()` is also called from
// plain .ts modules such as src/lib/utils/navigation.ts.)
"svelte/no-navigation-without-resolve": "off",
},
},
{
// Svelte components: the parser needs the project's svelte.config.js so it
// resolves preprocessors and Svelte 5 runes the same way the build does.
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
parser: ts.parser,
svelteConfig,
},
},
rules: {
// Warn-only — real findings, but each fix is a behavioural refactor that
// does not belong in a tooling commit:
// require-each-key keyed {#each} changes DOM reuse semantics
// prefer-svelte-reactivity Set/Map -> SvelteSet/SvelteMap changes
// reactivity, not just syntax
// prefer-writable-derived $state + $effect -> writable $derived
// no-at-html-tags {@html} sites need an XSS review each
"svelte/require-each-key": "warn",
"svelte/prefer-svelte-reactivity": "warn",
"svelte/prefer-writable-derived": "warn",
"svelte/no-at-html-tags": "warn",
// Warn-only: this rule cannot see the Svelte *compiler's* warning set, so
// it reports `<!-- svelte-ignore a11y_… -->` as unused when the compiler
// may still be emitting the warning it suppresses. Verify against a real
// `bun run check` before deleting any of them.
"svelte/no-unused-svelte-ignore": "warn",
},
},
{
// 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.
files: [
"scripts/**/*.{ts,js}",
"*.config.{ts,js}",
"*.config.*.{ts,js}",
"svelte.config.js",
"eslint.config.js",
],
languageOptions: {
globals: {
...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",
},
},
{
// Test files: vitest globals are enabled in vitest.config.ts.
files: ["**/*.{test,spec}.{ts,js}", "src/test/**/*.{ts,js}"],
languageOptions: {
globals: {
...globals.node,
...globals.vitest,
},
},
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
// `require()` inside one is the documented escape hatch.
"@typescript-eslint/no-require-imports": "off",
// Several tests deliberately replay a production assignment sequence
// (`currentStreamUrl = newStreamUrl; hasSeeked = false;`) to document the
// `$effect` they stand in for. The "useless" write is the subject under
// test, not dead code.
"no-useless-assignment": "off",
},
},
);
+34 -16
View File
@@ -1,7 +1,14 @@
{
"name": "jellytau",
"version": "0.8.2",
"description": "",
"version": "0.9.1",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://gitea.tourolle.paris/dtourolle/jellytau"
},
"private": true,
"type": "module",
"packageManager": "bun@1.3.5",
"scripts": {
@@ -10,14 +17,20 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "wdio run ./wdio.conf.ts",
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
"test:coverage": "vitest run --coverage",
"test:all": "./scripts/test-all.sh",
"test:rust": "./scripts/test-rust.sh",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check:boundary": "bash scripts/check-frontend-boundary.sh",
"check:links": "bash scripts/check-doc-links.sh",
"check:tooling": "bash scripts/check-tooling.sh",
"hooks:install": "./scripts/install-hooks.sh",
"android:build": "./scripts/build-android.sh",
"android:build:release": "./scripts/build-android.sh release",
"android:build:device": "./scripts/build-android.sh --device",
@@ -42,35 +55,40 @@
"traces:validate": "bun run scripts/extract-traces.ts --format validate",
"release:notes": "bun run scripts/release-notes.ts"
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-log": "2.9.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"@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"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.16",
"@wdio/cli": "^9.5.0",
"@wdio/local-runner": "^9.5.0",
"@wdio/mocha-framework": "^9.5.0",
"@wdio/spec-reporter": "^9.5.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.11.0",
"happy-dom": "^20.0.11",
"jsdom": "^27.4.0",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.47.1",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.6.2",
"typescript-eslint": "^8.67.0",
"vite": "^6.0.3",
"vitest": ">=1.0.0 <5.0.0",
"webdriverio": "^9.5.0"
"vitest": "^4.1.10"
}
}
+45 -2
View File
@@ -8,7 +8,7 @@
# tarball/VCS URL and drop the local-copy prepare() step.
pkgname=jellytau
pkgver=0.0.18
pkgver=0.9.1
pkgrel=1
pkgdesc="A cross-platform Jellyfin client"
arch=('x86_64')
@@ -29,7 +29,45 @@ build() {
bun run build
# Only the raw binary is needed; packaging is done in package() below so we
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
(cd src-tauri && cargo build --release --locked)
#
# 🔴 `tauri/custom-protocol` is not optional. `tauri build` passes it for you;
# a bare `cargo build` does not, and without it Tauri loads the frontend from
# `devUrl` rather than the assets embedded from `frontendDist`. The result
# builds and installs cleanly and then cannot load its own UI. check() guards
# this.
(cd src-tauri && cargo build --release --locked --features tauri/custom-protocol)
}
check() {
cd "$_srcdir"
# A Tauri binary built without `custom-protocol` does not embed the frontend;
# it serves it from `devUrl` (http://localhost:1420) instead. It compiles,
# links and installs perfectly, then launches into "Could not connect to
# localhost: Connection refused" — which is what this package did for its
# entire existence, because `tauri build` adds that feature for you and a bare
# `cargo build` does not.
#
# Test for the *assets*, not for the dev URL: `devUrl` is part of the config
# blob that generate_context!() embeds either way, so its presence proves
# nothing. A content-hashed filename from the vite build can only be in the
# binary if the bundle was embedded — the with-feature binary is ~400 KB
# larger for exactly this reason.
local _binary="src-tauri/target/release/jellytau"
local _asset
_asset="$(basename "$(ls -1 build/_app/immutable/entry/*.js | head -n1)")"
if [ -z "$_asset" ]; then
echo "==> ERROR: no frontend build found — 'bun run build' did not produce build/_app." >&2
return 1
fi
if ! grep -qa "$_asset" "$_binary"; then
echo "==> ERROR: the frontend bundle is not embedded in the binary." >&2
echo " Build with --features tauri/custom-protocol, or the packaged app" >&2
echo " will start up unable to load its own UI." >&2
return 1
fi
}
package() {
@@ -42,6 +80,11 @@ package() {
install -Dm644 "packaging/arch/jellytau.desktop" \
"$pkgdir/usr/share/applications/jellytau.desktop"
# MIT is not in /usr/share/licenses/common, so Arch packaging requires the
# licence text to ship with the package.
install -Dm644 "LICENSE" \
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# Icons (hicolor)
install -Dm644 "src-tauri/icons/32x32.png" \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
+75 -2
View File
@@ -13,11 +13,26 @@ Run all tests (frontend + Rust backend).
### `test-frontend.sh`
Run frontend tests only.
```bash
./scripts/test-frontend.sh # Run all tests
./scripts/test-frontend.sh # Single pass (same as `bun run test`)
./scripts/test-frontend.sh --watch # Watch mode
./scripts/test-frontend.sh --ui # Open UI
```
`bun run test` is `vitest run` — one pass, exit code, done. It used to be bare
`vitest`, which parked in watch mode; CLAUDE.md's "Before Committing" list tells
people to run it, so it had to terminate. The interactive modes moved to their
own entry points:
| Command | Runs |
|---------|------|
| `bun run test` | `vitest run` — single pass |
| `bun run test:watch` | `vitest` — watch mode |
| `bun run test:ui` | `vitest --ui` |
| `bun run test:coverage` | `vitest run --coverage` |
`test-frontend.sh` forwards any extra arguments to vitest and switches to the
long-running form automatically when it sees `--watch`, `-w`, or `--ui`.
### `test-rust.sh`
Run Rust tests only.
```bash
@@ -97,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
@@ -120,6 +136,59 @@ For details, see:
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
## Linting & Formatting
There is no script wrapper for these — they are plain package.json entries:
```bash
bun run lint # eslint .
bun run lint:fix # eslint . --fix
bun run format # prettier --write .
bun run format:check # prettier --check .
```
Config lives in `eslint.config.js` (flat config: typescript-eslint +
eslint-plugin-svelte, tuned for Svelte 5 and TS `strict`), `.prettierrc`, and
`.prettierignore`. `src/lib/api/bindings.ts` is excluded from both — it is
generated by tauri-specta on every Rust build.
`bun run lint` is currently **error-clean but not warning-clean**: several rules
are deliberately set to `warn` because the existing tree has more hits than a
tooling change should touch (unused bindings, `any` at the IPC boundary, unkeyed
`{#each}`). Each one is annotated in `eslint.config.js` with why, and the
intended end state is `error`. Drive them down; do not delete them.
`no-console` is switched **off** for now — see the note in `eslint.config.js`.
## Git Hooks
### `install-hooks.sh`
Point git at the repo's tracked hooks directory (`core.hooksPath`).
```bash
bun run hooks:install # or: ./scripts/install-hooks.sh
```
### `hooks/pre-commit`
Runs the fast half of CLAUDE.md's "Before Committing" list so it is enforced
rather than remembered:
- `bun run check` (svelte-check)
- `bun run test` (vitest, single pass)
- `scripts/check-frontend-boundary.sh`
- `cargo fmt --all -- --check`, **only when staged files touch `src-tauri/`**
`cargo clippy` and `cargo test` are deliberately *not* in the hook — minutes per
commit is how you teach people to reach for `--no-verify`. They run in CI, and
locally via `bun run test:all`.
```bash
git commit --no-verify # skip the hook for one commit
git config --unset core.hooksPath # uninstall
```
The hook skips itself during a merge, rebase, or cherry-pick, and when nothing
is staged.
## Utility Scripts
### `clean.sh`
@@ -132,8 +201,12 @@ Clean all build artifacts.
You can also run these via npm/bun:
```bash
bun run test # Frontend tests (single pass)
bun run test:all # All tests
bun run test:rust # Rust tests
bun run lint # ESLint
bun run format:check # Prettier (check only)
bun run hooks:install # Install the git hooks
bun run android:build # Build Android APK
bun run android:deploy # Deploy to device
bun run android:dev # Build + deploy debug
+24 -4
View File
@@ -82,7 +82,17 @@ fi
if [ "$CLEAN" = "1" ]; then
echo "🧹 Clearing build caches (clean build)..."
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
npm install > /dev/null 2>&1
# `bun install`, NOT `npm install`. This is a bun project (see packageManager
# in package.json) and bun.lock is the lockfile that is committed; npm
# ignores it, re-resolves the tree from package.json alone, and writes a
# package-lock.json that .gitignore then hides.
#
# That is not cosmetic. The Tauri CLI refuses to build when a plugin's Rust
# crate and npm package differ by minor version, so the JS side is pinned
# exactly to match Cargo.lock; a re-resolve is precisely how those halves
# drift apart again. A clean build must not be able to change what gets
# installed.
bun install > /dev/null 2>&1
fi
# Step 1: Sync Android source files
@@ -94,24 +104,34 @@ echo "🎨 Building frontend..."
bun run build
# Step 2: Build Android APK
# `--apk` is a boolean flag, NOT `--apk true`.
#
# tauri-cli took a value here until 2.10; from 2.11 it is a plain flag and the
# stray `true` is parsed as a positional argument, failing with
# "error: unexpected argument 'true' found" before the build starts. Found by
# deploying to a device after the Tauri 2.9.5 -> 2.11.5 upgrade.
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
# A release build in the debug slot: R8 still runs, but the applicationId is
# suffixed and the debug keystore signs it (read by build.gradle.kts from
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
# .debug install cleanly. Deliberately does NOT write keystore.properties.
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk true "${TARGET_ARGS[@]}"
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk "${TARGET_ARGS[@]}"
elif [ "$BUILD_TYPE" = "release" ]; then
# Configure release signing from .env (single source of truth). Must run
# after sync-android-sources.sh, since gen/android is (re)generated there.
./scripts/write-keystore-properties.sh
echo "📦 Building release APK..."
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
bun run tauri android build --apk "${TARGET_ARGS[@]}"
else
echo "📦 Building debug APK..."
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
bun run tauri android build --apk --debug "${TARGET_ARGS[@]}"
fi
echo ""
echo "✅ APK build complete!"
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
# Containerised builds run as root against a bind-mounted tree; hand the
# artifacts back to the host user. No-op when not root. See DR-213.
"$(dirname "$0")/restore-ownership.sh"
+47 -4
View File
@@ -26,19 +26,62 @@ echo "🏷️ Tagging for registry..."
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${FULL_IMAGE_NAME}
# Step 3: Login to registry (if not already logged in)
#
# `docker info | grep Username` only ever reports a Docker Hub session, so for a
# private registry it never matched — meaning this branch fired on every push and
# dropped into an interactive `docker login`, which hangs any non-interactive run
# (a scripted release, or CI). Check the credential store for this specific
# registry instead, and refuse rather than prompt when there is no TTY to
# prompt on.
echo "🔐 Checking registry authentication..."
if ! docker info | grep -q "Username"; then
echo "Not authenticated to Docker. Logging in to ${REGISTRY_HOST}..."
docker login ${REGISTRY_HOST}
DOCKER_CFG="${DOCKER_CONFIG:-$HOME/.docker}/config.json"
if ! grep -q "\"${REGISTRY_HOST}\"" "$DOCKER_CFG" 2>/dev/null; then
if [ -t 0 ]; then
echo "Not authenticated to ${REGISTRY_HOST}. Logging in..."
docker login "${REGISTRY_HOST}"
else
echo "❌ Not authenticated to ${REGISTRY_HOST}, and stdin is not a TTY."
echo " Run this first: docker login ${REGISTRY_HOST}"
exit 1
fi
else
echo " Using stored credentials for ${REGISTRY_HOST}."
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/^/ /"
+23 -1
View File
@@ -26,7 +26,25 @@ bun run build
# --bundles overrides tauri.conf.json bundle.targets so this script controls
# exactly which Linux formats are produced (never NSIS here).
bun run tauri build --bundles "$BUNDLES"
# TRACES: | DR-221
#
# 🔴 NO_STRIP=true is required for the AppImage bundle.
#
# linuxdeploy (which Tauri downloads and runs to build the AppImage) carries its
# own `strip`, and that copy is too old to parse the `.relr.dyn` section modern
# toolchains emit for RELR relocations. It fails on essentially every bundled
# library:
#
# strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
# failed to bundle project `failed to run linuxdeploy-x86_64.AppImage`
#
# Ubuntu 23.10+ links with -z pack-relative-relocs by default, so the CI builder
# image hits this exactly as a modern Arch host does. Skipping the strip step is
# linuxdeploy's own documented escape hatch; the cost is an unstripped, larger
# AppImage (~153 MB for a build that bundles libmpv and its ffmpeg stack).
#
# Remove this only after confirming a linuxdeploy release that understands RELR.
NO_STRIP=true bun run tauri build --bundles "$BUNDLES"
BUNDLE_ROOT="src-tauri/target/release/bundle"
echo ""
@@ -42,3 +60,7 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
echo ""
echo "📦 Copied bundles to $OUTPUT_DIR"
fi
# Containerised builds run as root against a bind-mounted tree; hand the
# artifacts back to the host user. No-op when not root. See DR-213.
"$(dirname "$0")/restore-ownership.sh"
+34 -2
View File
@@ -8,7 +8,7 @@
# it can bundle the NSIS installer from a Linux host.
#
# Playback on Windows: video renders via WebView2 and audio via the webview
# <audio> backend (WebviewAudioBackend) — see docs/build-windows.md.
# <audio> backend (WebviewAudioBackend) — see docs/build/build-windows.md.
#
# Requirements (present in the Docker windows-cross target / unified builder):
# - rustup target x86_64-pc-windows-msvc
@@ -46,6 +46,28 @@ bun run build
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
# that CLI validation — the bundler then picks nsis once it knows the target is
# Windows.
# TRACES: | DR-221
#
# 🔴 Clear the bundle output before building.
#
# The bundle directory is not versioned and is never cleaned by cargo, and the
# CI runner reuses src-tauri/target between builds. The copy step below globs
# `bundle/**/*-setup.exe`, so every stale installer left there was picked up and
# attached to the release: v0.8.2 shipped sixteen Windows installers, thirteen
# of them from earlier versions, and v0.5.0 offered users a download list going
# back to 0.1.0. Every release from v0.1.0 to v0.8.2 did this. It stopped only
# because an unrelated change wiped the runner's target dir, so it is dormant
# rather than fixed.
#
# Filtering the copy by version would hide it; removing the directory means a
# stale file cannot exist to be copied. scripts/check-release-artifacts.sh is
# the backstop if some other path reintroduces one.
BUNDLE_DIR="src-tauri/target/$TARGET/release/bundle"
if [[ -d "$BUNDLE_DIR" ]]; then
echo "🧹 Clearing previous bundle output at $BUNDLE_DIR"
rm -rf "$BUNDLE_DIR"
fi
if [[ "$WIN_BUNDLES" == "none" ]]; then
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
else
@@ -62,8 +84,18 @@ 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"
fi
# Containerised builds run as root against a bind-mounted tree; hand the
# artifacts back to the host user. No-op when not root. See DR-213.
"$(dirname "$0")/restore-ownership.sh"
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# Documentation link integrity: every relative markdown link must point at a
# file that exists.
#
# Implements DR-208 (see docs/requirements.md).
#
# Why this exists: docs/traceability.md is generated into docs/ while its file
# links were emitted repo-root-relative, so all ~2,800 of them resolved to
# docs/src-tauri/… and 404'd — in the Gitea repo browser and on the published
# mdBook site alike. Nobody clicks 2,800 links, so it went unnoticed for months.
# Several hand-written docs had the same defect at smaller scale: links to files
# that had been deleted, and links written as if the doc lived at the repo root.
# A link that does not resolve is a documentation defect of the same kind as a
# compile error, and a grep is enough to catch the whole class.
#
# What it checks: for every tracked `.md` file, every inline markdown link
# `[text](target)` whose target is a *path* — the target is resolved relative to
# the directory of the file containing it, and must exist on disk.
#
# ⚠️ It validates PATHS, NOT ANCHORS. A green run does not mean the links land
# where the text claims.
#
# 🔴 What it deliberately CANNOT see (do not read a green run as proof):
# - **Anchor fragments.** `foo.md#some-heading` is checked only as `foo.md`.
# Resolving the fragment needs a markdown renderer's heading-slug rules
# (which differ between Gitea, GitHub and mdBook), so a link to a heading
# that was renamed still passes here. That is a deliberate scope cut, not an
# oversight.
# - **External URLs.** http(s):// and mailto: are skipped. Checking them means
# network I/O in a gate, which makes the gate flaky and slow; link rot in an
# external URL is also not something a commit can break.
# - **Reference-style links** (`[text][ref]` with a separate `[ref]: target`
# definition) and bare autolinks. This project writes inline links; add the
# pattern here if that changes.
# - **Links inside fenced code blocks**, which are intentionally skipped —
# a template being *shown* to the reader (e.g. the release-notes template in
# docs/release-checklist.md) is sample text, not a live link, and its targets
# are resolved wherever it is eventually pasted, not from the docs tree.
# - **A link that resolves to the wrong existing file.** Existence is not
# correctness.
#
# Usage: bash scripts/check-doc-links.sh
# Exits non-zero, listing file:line and the unresolved target, on any failure.
set -euo pipefail
cd "$(dirname "$0")/.."
# Generated, vendored or build-output trees. Their markdown is not authored here
# and their link targets are not ours to fix.
#
# Only consulted when this is NOT a git checkout — inside one, the tracked-file
# list does this job and does not need maintaining. Kept for the tarball case.
EXCLUDES=(
"./node_modules/*"
"./.svelte-kit/*"
"./build/*"
"./dist/*"
"./src-tauri/gen/*"
"./src-tauri/target/*"
"./.git/*"
# Agent/dev scratch worktrees (.claude/worktrees is itself git-ignored). These
# are full checkouts of the repo, so without this the checker walks every
# in-flight branch and reports its links as if they were ours.
"./.claude/*"
)
# Targets that do not exist in the repo *by design* because the publish-docs job
# writes them into docs/ at build time (see .gitea/workflows/publish-docs.yml).
# Keep this list to genuinely generated pages — anything else here is a broken
# link being hidden.
GENERATED_TARGETS=(
"./docs/README.md" # the site's landing page, written by publish-docs
"./docs/api-redirect.md" # the rustdoc redirect stub, likewise
)
is_generated() {
local candidate="$1"
for generated in "${GENERATED_TARGETS[@]}"; do
[[ "$candidate" == "$generated" ]] && return 0
done
return 1
}
echo "🔎 Checking relative markdown links resolve to files on disk…"
# Ask git which markdown files are ours, rather than walking the filesystem.
#
# This started as a find(1) with a hand-maintained prune list, and that list was
# wrong three times in a row: it walked the scratch worktrees under .claude/,
# then makepkg's vendored cargo registry under packaging/arch/src/ — each time
# reporting a dependency's broken README as if it were ours. Every one of those
# directories is already git-ignored, so the tracked-file list is the exclusion
# rule, and it cannot drift out of date the way EXCLUDES did. It also matches
# what this script always claimed to do.
#
# Untracked-but-not-ignored files are deliberately included: a new doc added in
# a working tree should be checked before it is committed, not after.
if git rev-parse --git-dir >/dev/null 2>&1; then
mapfile -t md_files < <(
{ git ls-files -z --cached --others --exclude-standard -- '*.md' | tr '\0' '\n'; } \
| sed 's|^|./|' | sort -u
)
else
# Not a git checkout (an exported tarball, say): fall back to walking, with
# the prune list below as the only defence.
find_args=(. )
for pattern in "${EXCLUDES[@]}"; do
find_args+=(-path "$pattern" -prune -o)
done
find_args+=(-name "*.md" -type f -print)
mapfile -t md_files < <(find "${find_args[@]}" | sort)
fi
echo " ${#md_files[@]} markdown files"
broken=""
checked=0
for md in "${md_files[@]}"; do
dir="$(dirname "$md")"
# One documented exception: docs-site/SUMMARY.md is mdBook's table of
# contents, and the publish-docs job copies it *into* docs/ before rendering
# (book.toml sets src = "../docs"). Its links are therefore written relative
# to docs/, not to the directory the file is stored in. Resolving it from
# docs/ is what actually validates it — and it is the check that catches a
# SUMMARY entry pointing at a page that does not exist, which mdBook itself
# only warns about.
if [[ "$md" == "./docs-site/SUMMARY.md" ]]; then
dir="./docs"
fi
# Strip fenced code blocks (``` and ~~~) before extracting links, so sample
# markdown shown to the reader is not checked as if it were a live link.
# Line numbers are preserved by blanking the lines rather than deleting them.
#
# Then emit "lineno<TAB>target" for each inline link on each surviving line.
while IFS=$'\t' read -r lineno target; do
[[ -z "${target:-}" ]] && continue
# Skip external schemes and pure-anchor links.
case "$target" in
http://*|https://*|mailto:*|ftp://*|"#"*|"") continue ;;
# A protocol-relative or scheme-ish target we do not resolve.
//*) continue ;;
esac
# Drop any anchor fragment and query string — we check the path only.
path="${target%%#*}"
path="${path%%\?*}"
[[ -z "$path" ]] && continue
# Percent-decode: SvelteKit route directories are literally named `[id]`,
# which docs link as `%5Bid%5D`, and spaces appear as `%20`.
if [[ "$path" == *%* ]]; then
path="$(printf '%b' "${path//%/\\x}")"
fi
checked=$((checked + 1))
if is_generated "$dir/$path"; then
continue
fi
if [[ ! -e "$dir/$path" ]]; then
broken+="${md}:${lineno} -> ${target}"$'\n'
fi
done < <(
awk '
/^[[:space:]]*(```|~~~)/ { fence = !fence; print ""; next }
fence { print ""; next }
{ print }
' "$md" |
grep -noE '\]\([^)[:space:]]+' |
sed -E 's/^([0-9]+):\]\(/\1\t/'
)
done
echo " $checked relative links checked"
if [[ -n "$broken" ]]; then
echo ""
echo "❌ Broken documentation links — these targets do not exist on disk:"
echo ""
echo "$broken" | sed 's/^/ /'
echo " Each link is resolved relative to the directory of the file it is in."
echo " The usual causes:"
echo " • the target file was moved or deleted — update or drop the link;"
echo " • the link was written as if the doc lived at the repo root — a doc"
echo " in docs/ needs '../' to reach src/, scripts/ or CHANGELOG.md;"
echo " • a generated doc emits repo-root-relative hrefs — fix the"
echo " generator, not the output (see scripts/extract-traces.ts)."
exit 1
fi
echo "✅ All relative documentation links resolve."
echo " (Reminder: paths only — anchors and external URLs are NOT checked.)"
+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"
)
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Refuse to publish a release whose artifacts are not all from this release.
#
# TRACES: | DR-220
#
# ./scripts/check-release-artifacts.sh <version> <dir> [<dir>...]
#
# e.g.
# ./scripts/check-release-artifacts.sh v0.9.2 artifacts/linux artifacts/windows
#
# ## The defect this exists for
#
# Every JellyTau release from v0.1.0 to v0.8.2 shipped every Windows installer
# ever built. `src-tauri/target/*/release/bundle/` is not versioned, cargo never
# cleans it, and the CI runner reuses the target directory between builds — so
# the copy step's `bundle/**/*-setup.exe` glob collected the whole history. By
# v0.8.2 that was sixteen installers, thirteen of them stale. v0.5.0 offered
# users a download list going back to 0.1.0.
#
# Nobody noticed for eight months. There was nothing to notice with: the upload
# loop reported success, the assets were real files, and the release page looked
# busy rather than wrong.
#
# The builds now clear the bundle directory first, which removes the cause. This
# is the backstop for the next thing that reintroduces a stale file by a route
# nobody predicted — a cached directory, a restored artifact, a hand-copied fix.
#
# ## What it checks
#
# Every file whose name embeds a semantic version must embed *this* version.
# Files with no version in the name (jellytau-release.apk, jellytau.exe,
# SHA256SUMS, latest.json) are accepted: they are produced fresh each build and
# have no version to disagree with.
set -euo pipefail
if [ "$#" -lt 2 ]; then
echo "usage: $0 <version> <dir> [<dir>...]" >&2
exit 2
fi
VERSION_RAW="$1"
shift
# Accept the tag form (v0.9.2) or the bare form (0.9.2).
VERSION="${VERSION_RAW#v}"
echo "🔎 Checking release artifacts are all version ${VERSION}"
FOUND=0
STALE=0
UNVERSIONED=0
for dir in "$@"; do
if [ ! -d "$dir" ]; then
echo " (no $dir — skipping)"
continue
fi
# -print0/read -d '' so a filename with a space cannot split into two.
while IFS= read -r -d '' file; do
name="$(basename "$file")"
FOUND=$((FOUND + 1))
# First x.y.z in the filename, if any.
embedded="$(printf '%s' "$name" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
if [ -z "$embedded" ]; then
UNVERSIONED=$((UNVERSIONED + 1))
continue
fi
if [ "$embedded" != "$VERSION" ]; then
echo "$name carries version $embedded"
STALE=$((STALE + 1))
fi
done < <(find "$dir" -type f -print0)
done
echo ""
echo " $FOUND file(s) checked; $UNVERSIONED carry no version in the name."
if [ "$FOUND" -eq 0 ]; then
echo "❌ No artifacts found at all. A release with no files is a failed build," >&2
echo " not an empty one." >&2
exit 1
fi
if [ "$STALE" -gt 0 ]; then
echo ""
echo "$STALE artifact(s) belong to a different version than ${VERSION}." >&2
echo "" >&2
echo " This is how every release from v0.1.0 to v0.8.2 came to ship its" >&2
echo " predecessors' Windows installers: src-tauri/target/*/release/bundle/" >&2
echo " is never cleaned and the runner reuses it, so a glob picks up" >&2
echo " whatever was left behind." >&2
echo "" >&2
echo " The builds clear that directory first, so seeing this means a stale" >&2
echo " file arrived by some other route. Find it before publishing — do not" >&2
echo " delete the file and re-run." >&2
exit 1
fi
echo "✅ Every versioned artifact is ${VERSION}."
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Refuse tooling that contradicts what this project actually uses.
#
# TRACES: | DR-222
#
# ./scripts/check-tooling.sh
#
# ## Why
#
# This is a bun project: `packageManager` in package.json says so, bun.lock is
# the committed lockfile, and .gitignore hides the other package managers'
# lockfiles precisely so they cannot be committed by accident.
#
# scripts/build-android.sh nonetheless ran `npm install` on its clean-build
# path. npm ignores bun.lock, re-resolves the whole tree from package.json, and
# writes a package-lock.json that .gitignore then hides from view.
#
# That is not a style preference. The Tauri CLI refuses to build when a plugin's
# Rust crate and npm package differ by minor version, so the JS side is pinned
# exactly against Cargo.lock -- and a silent re-resolve is exactly how those
# halves drift apart again. The drift already cost one release build.
#
# It survived because the clean-build path runs rarely. That is the shape of
# nearly every defect found while preparing v0.10.0: the code that runs on every
# commit was fine, and the code that runs on a release, a clean build or a tag
# had no guard at all.
set -uo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT" || exit 1
FAILED=0
echo "🔎 Checking build tooling is consistent with packageManager…"
# Only the *invocations* matter. A comment explaining why npm is wrong, or a
# .gitignore entry naming package-lock.json, is not a violation -- so match a
# command at the start of a line or after a shell separator.
PATTERN='(^|[;&|(]|&&|\|\||\bthen |\bdo |[[:space:]]{4,})(npm|yarn|pnpm)[[:space:]]+(install|ci|add|run|exec)\b'
MATCHES="$(grep -rInE "$PATTERN" \
--include='*.sh' --include='*.yml' --include='*.yaml' \
scripts/ .gitea/ 2>/dev/null | grep -v '^\s*#' || true)"
if [ -n "$MATCHES" ]; then
echo "❌ A non-bun package manager is invoked:"
echo "$MATCHES" | sed 's/^/ /'
echo ""
echo " This project uses bun (packageManager in package.json, bun.lock"
echo " committed). npm/yarn/pnpm ignore that lockfile and re-resolve the"
echo " dependency tree, which is how the Tauri plugin crate/package"
echo " versions drifted apart and broke a release build."
echo ""
echo " Use: bun install / bun run / bunx"
FAILED=1
fi
# A lockfile from another manager should never exist here; .gitignore hides
# them, so one can sit in a working tree unnoticed and change what installs.
for stray in package-lock.json yarn.lock pnpm-lock.yaml; do
if [ -f "$stray" ]; then
echo "$stray exists. Another package manager has run here."
echo " Delete it and run: bun install"
FAILED=1
fi
done
if [ "$FAILED" -eq 0 ]; then
echo "✅ Only bun is used, and no foreign lockfile is present."
fi
exit "$FAILED"
+155 -29
View File
@@ -11,6 +11,7 @@
*
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
* @req-test: UT-202 - Generated matrix links resolve from docs/
*/
import { describe, it, expect } from "vitest";
@@ -20,12 +21,67 @@ import {
countDefinedRequirements,
computeCoverage,
findDanglingIds,
formatMatrixFileLink,
generateMarkdown,
isTracedSourceFile,
MIN_COVERAGE_PERCENT,
type TracesData,
} from "./extract-traces";
// 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);
// Shell tooling is listed individually, not globbed: most scripts/*.sh
// implement nothing, and adding one should be a decision.
expect(isTracedSourceFile("scripts/check-release-artifacts.sh")).toBe(true);
expect(isTracedSourceFile("scripts/build-desktop-linux.sh")).toBe(true);
expect(isTracedSourceFile("scripts/logcat.sh")).toBe(false);
});
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 = `
@@ -136,9 +192,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", () => {
@@ -154,7 +211,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();
@@ -250,31 +307,100 @@ describe("computeCoverage", () => {
});
});
describe("live requirements.md", () => {
it("parses the real file to the counts the CI gate must use", () => {
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
// (total 114) while the real file had grown to 211. Update these numbers
// deliberately when requirements are added — that edit is the signal the
// denominator is live rather than frozen.
const md = fs.readFileSync(
path.resolve(HERE, "../docs/requirements.md"),
"utf-8"
);
const defined = countDefinedRequirements(md);
describe("generated matrix file links", () => {
// Regression: the generator emitted the repo-root-relative path as the href
// (`](src-tauri/src/…)`), but writes its output to docs/traceability.md — so
// every one of the ~2,800 links resolved to docs/src-tauri/… and 404'd, in
// the repo browser and on the published mdBook site. The markdown generator
// had no test at all, which is why it survived. UT-202.
//
// @req-test: UT-202
expect(defined.UR).toBe(75);
expect(defined.IR).toBe(32);
// 192 = 187 + four requirements added independently on four audit branches,
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
// that landed together: DR-189 (control-bar auto-hide), DR-198 (asset
// scope/CSP), DR-199 (webview mixed-content) and DR-200 (the
// POST_NOTIFICATIONS media-session exemption; renumbered from 198 on
// merge, where it collided). Each branch bumped for its own — merged,
// they sum. Resolve this by summing, never by taking one side. 193 adds
// DR-202 (video keeps the display awake), 194 DR-203 (the handoff
// transcode refusing the player's own load-error retry).
expect(defined.DR).toBe(194);
expect(defined.JA).toBe(36);
expect(defined.total).toBe(337);
/** A minimal TracesData whose single entry points at a file that really exists. */
function fixture(file: string, line = 12): TracesData {
return {
timestamp: new Date().toISOString(),
totalFiles: 1,
totalTraces: 1,
requirements: {
"DR-093": [{ file, line, context: "export function x() {}" }],
},
byType: { UR: [], IR: [], DR: ["DR-093"], JA: [] },
} as TracesData;
}
/** Pull the href out of the first `- **File:** [`x`](href)` line. */
function firstHref(md: string): string {
const m = md.match(/^- \*\*File:\*\* \[`[^`]+`\]\(([^)]+)\)/m);
expect(m).not.toBeNull();
return m![1];
}
it("emits an href that resolves, from docs/, to a file that exists", () => {
// Use a real repo file so "exists on disk" is a genuine assertion.
const target = "scripts/extract-traces.ts";
const md = generateMarkdown(fixture(target));
const href = firstHref(md);
const [relPath] = href.split("#");
// traceability.md is written to docs/, so links resolve from there.
const resolved = path.resolve(HERE, "../docs", relPath);
expect(fs.existsSync(resolved)).toBe(true);
expect(resolved).toBe(path.resolve(HERE, "..", target));
});
it("keeps the repo-root-relative path as the visible link text", () => {
// The text is what a developer copies into an editor or a grep; only the
// href is rewritten for the docs/ location.
const md = generateMarkdown(fixture("src-tauri/src/lib.rs"));
expect(md).toContain("[`src-tauri/src/lib.rs`]");
expect(md).not.toContain("[`../src-tauri/src/lib.rs`]");
});
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)");
});
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => {
const md = generateMarkdown(fixture("scripts/extract-traces.ts"));
const href = firstHref(md);
expect(href.startsWith("../")).toBe(true);
// The pre-fix output — the exact shape that produced docs/scripts/….
expect(href.startsWith("scripts/")).toBe(false);
});
});
describe("live requirements.md", () => {
it("parses the real file into a self-consistent denominator", () => {
// Guards the original regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
// (total 114) while the real file had grown past 200, so the gate compared
// live traces against a frozen denominator and reported 158% coverage.
//
// Deliberately asserts *invariants*, not exact totals. Pinning the counts
// was tried and turned this test into a merge-conflict magnet: every
// requirement added on any branch had to edit the numbers here too, and the
// comment above them grew into a ledger of which branch contributed which
// 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 defined = countDefinedRequirements(md);
// The parser found real rows of every type: a section silently failing to
// parse would shrink the denominator and inflate coverage.
expect(defined.UR).toBeGreaterThan(0);
expect(defined.IR).toBeGreaterThan(0);
expect(defined.DR).toBeGreaterThan(0);
expect(defined.JA).toBeGreaterThan(0);
// The denominator is the sum of its parts, and every counted id is unique —
// double-counting one section is the other way a ratio breaks.
expect(defined.total).toBe(defined.UR + defined.IR + defined.DR + defined.JA);
expect(defined.ids.size).toBe(defined.total);
// The file is live, not frozen: it is well past the 114 the stale gate used.
expect(defined.total).toBeGreaterThan(200);
});
});
+130 -47
View File
@@ -23,7 +23,7 @@ interface RequirementMapping {
[reqId: string]: TraceEntry[];
}
interface TracesData {
export interface TracesData {
timestamp: string;
totalFiles: number;
totalTraces: number;
@@ -56,7 +56,7 @@ interface TracesData {
*
* TRACES: | DR-093
*/
export const MIN_COVERAGE_PERCENT = 82;
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 = 82;
// `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,79 @@ 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",
// Shell tooling that implements a requirement. Named individually rather than
// globbing scripts/*.sh: most of these scripts implement nothing, and the
// point of the list is that adding a file is a decision.
"scripts/install-hooks.sh",
"scripts/check-release-artifacts.sh",
"scripts/build-desktop-linux.sh",
"scripts/build-windows-cross.sh",
"scripts/restore-ownership.sh",
]);
/** 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 +163,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 +188,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 +304,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 +357,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 +384,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 +406,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 +419,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,
};
}
@@ -366,7 +430,34 @@ export function readDefinedRequirements(): DefinedRequirements {
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
}
function generateMarkdown(data: TracesData): string {
/**
* Path prefix that turns a repo-root-relative file path into a link target that
* resolves from `docs/traceability.md`, where this markdown is written.
*
* The generated matrix lives one directory below the repo root, so a bare
* `src-tauri/src/player/mod.rs` href resolves to `docs/src-tauri/…` and 404s
* in the repo browser and on the published mdBook site alike. Every file link
* in the matrix was dead for this reason. The *display text* stays
* repo-root-relative (that is the path a developer types and greps for); only
* the href is rewritten.
*
* TRACES: | DR-093 | UT-202
*/
export const MATRIX_LINK_PREFIX = "../";
/**
* Build the ``[`path`](href#Lnn)`` link used for one trace entry in the matrix.
*
* Exported so extract-traces.test.ts can resolve a generated href against
* `docs/` and assert the target exists on disk.
*
* TRACES: | DR-093 | UT-202
*/
export function formatMatrixFileLink(file: string, line: number): string {
return `[\`${file}\`](${MATRIX_LINK_PREFIX}${file}#L${line})`;
}
export function generateMarkdown(data: TracesData): string {
let md = `# Code Traceability Matrix
**Generated:** ${new Date(data.timestamp).toLocaleString()}
@@ -424,7 +515,7 @@ ${data.byType.JA.join(", ")}
md += `**Locations:** ${entries.length} file(s)\n\n`;
for (const entry of entries) {
md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`;
md += `- **File:** ${formatMatrixFileLink(entry.file, entry.line)}\n`;
md += ` - **Line:** ${entry.line}\n`;
const contextPreview = entry.context.substring(0, 70);
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
@@ -461,16 +552,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\`.`,
);
}
@@ -511,9 +600,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}`);
}
@@ -527,9 +614,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();
@@ -556,7 +641,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`);
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# JellyTau pre-commit hook — the fast half of CLAUDE.md's "Before Committing"
# list, enforced instead of remembered.
#
# TRACES: | DR-207
#
# Install with: bun run hooks:install (sets core.hooksPath=scripts/hooks)
# Skip once with: git commit --no-verify
#
# What runs here is deliberately limited to gates that finish in seconds:
#
# 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
# cold target dir, which turns every commit into a coffee break and trains
# people to reach for --no-verify. CI (.gitea/workflows/build-and-test.yml) is
# where those run; `bun run test:all` is the local equivalent.
set -uo pipefail
# Merge and rebase commits carry someone else's changes, and conflict resolution
# is exactly when a slow gate is least welcome. Let them through — CI still
# gates the merge result.
GIT_DIR_PATH="$(git rev-parse --git-dir 2>/dev/null)" || exit 0
if [ -e "$GIT_DIR_PATH/MERGE_HEAD" ] ||
[ -d "$GIT_DIR_PATH/rebase-merge" ] ||
[ -d "$GIT_DIR_PATH/rebase-apply" ] ||
[ -e "$GIT_DIR_PATH/CHERRY_PICK_HEAD" ]; then
echo "pre-commit: merge/rebase in progress — skipping checks (CI still gates the result)."
exit 0
fi
# Nothing staged (e.g. `git commit --amend` that only edits the message): nothing
# to check.
STAGED="$(git diff --cached --name-only --diff-filter=ACMR)"
if [ -z "$STAGED" ]; then
exit 0
fi
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT" || exit 1
FAILED=0
run_gate() {
label="$1"
shift
echo ""
echo "🔎 pre-commit: $label"
if ! "$@"; then
echo "❌ pre-commit: $label failed"
FAILED=1
fi
}
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.
if printf '%s\n' "$STAGED" | grep -q '^src-tauri/'; then
if command -v cargo >/dev/null 2>&1; then
echo ""
echo "🔎 pre-commit: rustfmt (src-tauri/ is staged)"
if ! (cd src-tauri && cargo fmt --all -- --check); then
echo "❌ pre-commit: cargo fmt --all -- --check failed"
echo " fix with: cd src-tauri && cargo fmt"
FAILED=1
fi
else
echo "⚠️ pre-commit: src-tauri/ staged but cargo is not on PATH — skipping rustfmt."
fi
fi
if [ "$FAILED" -ne 0 ]; then
echo ""
echo "🛑 pre-commit checks failed. Fix them, or bypass deliberately with:"
echo " git commit --no-verify"
exit 1
fi
echo ""
echo "✅ pre-commit checks passed."
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Point git at the repo's tracked hooks directory.
#
# TRACES: | DR-207
#
# bun run hooks:install # or: ./scripts/install-hooks.sh
#
# `core.hooksPath` is used rather than copying files into .git/hooks so the
# hooks stay version-controlled: an update to scripts/hooks/pre-commit reaches
# everyone on their next pull instead of needing a re-install.
#
# The setting is local to this clone (git config, not committed). To undo:
# git config --unset core.hooksPath
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
HOOKS_DIR="scripts/hooks"
if [ ! -d "$HOOKS_DIR" ]; then
echo "$HOOKS_DIR does not exist — are you in the JellyTau repo?" >&2
exit 1
fi
# Git refuses to run a hook that is not executable, and the bit is easy to lose
# on a fresh checkout on some filesystems.
chmod +x "$HOOKS_DIR"/* 2>/dev/null || true
git config core.hooksPath "$HOOKS_DIR"
echo "✅ core.hooksPath = $(git config core.hooksPath)"
echo ""
echo "Installed hooks:"
for hook in "$HOOKS_DIR"/*; do
[ -f "$hook" ] || continue
echo " - $(basename "$hook")"
done
echo ""
echo "pre-commit runs: bun run check, bun run test, check-frontend-boundary.sh,"
echo "and cargo fmt --check when src-tauri/ is staged."
echo "Bypass a single commit with: git commit --no-verify"
+58
View File
@@ -0,0 +1,58 @@
/**
* Tests for release-note derivation.
*
* TRACES: | DR-219 | UT-210
*
* The bug these were written against: `bun run release:notes v0.9.1..HEAD`
* listed *every user requirement in the project* as a feature of the release.
* The range contained a repo-wide `prettier --write` sweep, so `git diff
* --name-only` reported 199 files, their TRACES comments resolved to nearly the
* whole matrix, and the result claimed one release had added the entire
* application.
*
* That mattered more than it looked: build-release.yml now generates the
* published release body from this script, so the noise would have shipped.
*/
import { describe, it, expect } from "vitest";
import { isCosmeticCommit } from "./release-notes";
describe("isCosmeticCommit", () => {
it("treats a formatting sweep as cosmetic", () => {
// The actual commit that triggered this.
expect(isCosmeticCommit("chore(format): run prettier over src/ and scripts/")).toBe(true);
expect(isCosmeticCommit("style: reindent the player module")).toBe(true);
expect(isCosmeticCommit("style(player): reindent")).toBe(true);
});
it("treats a lockfile-only dependency bump as cosmetic", () => {
// Touches package.json/bun.lock, which carry no TRACES, but a `chore(deps)`
// that also edits source would still be caught by that source file.
expect(isCosmeticCommit("chore(deps): bump vitest to 4.1.11")).toBe(true);
});
it("does NOT treat ordinary work as cosmetic", () => {
expect(isCosmeticCommit("fix(player): restart the hero banner timer")).toBe(false);
expect(isCosmeticCommit("feat(updater): in-app update on desktop")).toBe(false);
expect(isCosmeticCommit("ci: make the frontend gates real")).toBe(false);
expect(isCosmeticCommit("docs: add SECURITY.md")).toBe(false);
});
it("does not mistake a chore that is not formatting for a formatting one", () => {
// `chore(release)` bumps versions and must still be attributable; a bare
// `chore:` could be anything, so it is NOT skipped by default.
expect(isCosmeticCommit("chore(release): v0.9.2")).toBe(false);
expect(isCosmeticCommit("chore: tidy up the queue helper")).toBe(false);
});
it("is not fooled by the word format appearing later in a subject", () => {
// A real fix to formatting *code* is not a cosmetic commit.
expect(isCosmeticCommit("fix(duration): format times over 24 hours correctly")).toBe(false);
expect(isCosmeticCommit("feat: add a format picker to settings")).toBe(false);
});
it("handles an empty or malformed subject without throwing", () => {
expect(isCosmeticCommit("")).toBe(false);
expect(isCosmeticCommit(" ")).toBe(false);
});
});
+83 -7
View File
@@ -54,13 +54,86 @@ function loadRequirementDescriptions(): Map<string, string> {
return map;
}
/**
* Commit subjects whose changes carry no requirement meaning.
*
* `chore(format)` / `style` rewrite files without changing behaviour;
* `chore(deps)` moves lockfiles. Anything else including a bare `chore:` and
* `chore(release):` is assumed to mean something and is kept.
*
* Anchored at the start of the subject on purpose: "fix(duration): format times
* over 24 hours" is a real fix to formatting *code*, not a formatting commit.
*/
const COSMETIC_SUBJECT = /^(chore\(format\)|chore\(deps\)|style)(\([^)]*\))?\s*:/i;
/**
* Does this commit subject describe a change with no requirement meaning?
*
* Exported for scripts/release-notes.test.ts.
*
* TRACES: | DR-219
*/
export function isCosmeticCommit(subject: string): boolean {
return COSMETIC_SUBJECT.test(subject.trim());
}
/**
* Files the range changed, excluding those touched only by cosmetic commits.
*
* Why not a plain `git diff --name-only <range>`: that is what this did, and a
* single repo-wide `prettier --write` inside the range made it report 199 files
* whose TRACES comments resolved to nearly the entire requirement matrix. The
* generated notes for v0.9.2 claimed the release had added the whole
* application and build-release.yml publishes this output, so the noise would
* have shipped.
*
* Walking commit by commit and skipping the cosmetic ones keeps a file that a
* sweep *and* a real change both touched: it is still listed by the real
* commit. Only files touched exclusively by cosmetic commits drop out, which is
* exactly the intent.
*
* Merge commits produce no output from `git diff-tree` without `-m`, and are
* skipped deliberately: everything they merge is already in the range as its
* own commit, so including them would double-count.
*/
function changedFiles(range: string): string[] {
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));
// Untagged repo: describe everything currently traced.
if (!range) {
return sh("git ls-files")
.split("\n")
.filter((f) => f && existsSync(f));
}
// NUL between hash and subject so a subject containing anything at all is safe.
const log = sh(`git log --no-merges --format=%H%x00%s ${range}`);
if (!log) return [];
const files = new Set<string>();
let skipped = 0;
for (const line of log.split("\n")) {
const [sha, ...subjectParts] = line.split("\u0000");
const subject = subjectParts.join("\u0000");
if (!sha) continue;
if (isCosmeticCommit(subject)) {
skipped++;
continue;
}
for (const f of sh(`git diff-tree --no-commit-id --name-only -r ${sha}`).split("\n")) {
if (f && existsSync(f)) files.add(f);
}
}
if (skipped > 0) {
// Say what was dropped rather than silently reporting a smaller set.
console.error(
`️ Skipped ${skipped} cosmetic commit(s) (formatting/deps) when deriving notes.`,
);
}
return [...files];
}
/** Collect requirement IDs referenced by TRACES comments in the given files. */
@@ -134,4 +207,7 @@ function main() {
console.log(out.join("\n"));
}
main();
// Guarded so this module stays importable from release-notes.test.ts.
if (import.meta.main) {
main();
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Give build artifacts back to the human who owns the working tree.
#
# TRACES: | DR-213
#
# The containerised builds (docker-compose.yml: desktop-linux-build,
# windows-cross, android-build, test, dev) bind-mount the repo at /app and run
# as root, because their caches live at /root/.cargo and /root/.bun. Everything
# they write into src-tauri/target and dist/ is therefore root-owned *on the
# host* — and it accumulates: one audit found 11,124 such files, which is enough
# to make `cargo clean` and scripts/clean.sh fail with EACCES for the developer.
# Worse, a plain `cargo build` then dies part-way through, because build scripts
# compile for the host and land in target/debug even during a cross-build.
#
# Running the containers as the host uid would be the tidier fix, but it needs
# the cache volumes relocated off /root first. Until that happens, this restores
# ownership at the end of each containerised build, which is self-healing and
# needs no uid plumbing on the host side.
#
# Outside a container this is a no-op: it exits immediately unless it is running
# as root, so the native build scripts can call it unconditionally.
set -uo pipefail
# Not root (a normal developer build) — nothing to fix, and nothing we may fix.
[ "$(id -u)" -eq 0 ] || exit 0
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
# Whoever owns the checkout is who the artifacts should belong to. Reading it
# from the tree means this works for any uid/gid without being told, including
# CI runners whose uid we do not control.
OWNER="$(stat -c '%u:%g' "$REPO_ROOT")"
# uid 0 owning the tree means it is not a bind mount from a normal host account
# (a root-owned checkout, or a CI image that clones as root). Nothing to give back.
if [ "${OWNER%%:*}" = "0" ]; then
exit 0
fi
echo ""
echo "🔑 Restoring ownership of build artifacts to ${OWNER}"
for target in src-tauri/target src-tauri/gen dist build node_modules .svelte-kit; do
[ -e "$REPO_ROOT/$target" ] || continue
chown -R "$OWNER" "$REPO_ROOT/$target" 2>/dev/null || {
echo "⚠️ Could not fully chown $target — you may need:"
echo " sudo chown -R $OWNER $REPO_ROOT/$target"
}
done
echo "✅ Ownership restored."

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